Merge v2.46.0 into develop — resolve all conflicts
- yarn.lock, components.d.ts, ru.po: accepted upstream v2.46.0 - AppSidebar.vue: kept custom sidebar links (MyPoints, LeaderBoard, ChatGPT, MyChild, Profile) + adopted async watch from v2.46.0 - MobileLayout.vue: merged onMounted with sidebarSettings.reload + custom addSideBar() for role-based mobile links - EditProfile.vue: adopted new Dialog options structure (size only) - Courses/Courses.vue: unified tab values to lowercase (enrolled, upcoming, new, created, unpublished) - CourseDetail.vue (old): removed — logic migrated to Courses/CourseDetail.vue; transferred custom tag flex-wrap styling to CourseOverview.vue - LessonForm.vue: kept Rutube video support - App.vue: clean (no conflict markers) - user.py: merged imports + kept custom sign_up params (phone, user_role) - utils.py: kept render_html (Rutube), is_mentor, is_eligible_to_review; added type hints for get_course_progress from v2.46.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
41
.github/workflows/ci.yml
vendored
41
.github/workflows/ci.yml
vendored
@@ -3,10 +3,15 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- develop
|
||||||
|
- main-hotfix
|
||||||
pull_request: {}
|
pull_request: {}
|
||||||
jobs:
|
jobs:
|
||||||
tests:
|
tests:
|
||||||
|
name: Server Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
services:
|
services:
|
||||||
redis-cache:
|
redis-cache:
|
||||||
image: redis:alpine
|
image: redis:alpine
|
||||||
@@ -30,13 +35,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: setup python
|
- name: setup python
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: '3.14'
|
||||||
- name: setup node
|
- name: setup node
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '24'
|
||||||
check-latest: true
|
check-latest: true
|
||||||
- name: setup cache for bench
|
- name: setup cache for bench
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -69,6 +74,9 @@ jobs:
|
|||||||
- name: setup requirements
|
- name: setup requirements
|
||||||
working-directory: /home/runner/frappe-bench
|
working-directory: /home/runner/frappe-bench
|
||||||
run: bench setup requirements --dev
|
run: bench setup requirements --dev
|
||||||
|
- name: block endpoints
|
||||||
|
working-directory: /home/runner/frappe-bench
|
||||||
|
run: bench --site frappe.local set-config block_endpoints 1
|
||||||
- name: allow tests
|
- name: allow tests
|
||||||
working-directory: /home/runner/frappe-bench
|
working-directory: /home/runner/frappe-bench
|
||||||
run: bench --site frappe.local set-config allow_tests true
|
run: bench --site frappe.local set-config allow_tests true
|
||||||
@@ -77,4 +85,27 @@ jobs:
|
|||||||
run: bench --site frappe.local build
|
run: bench --site frappe.local build
|
||||||
- name: run tests
|
- name: run tests
|
||||||
working-directory: /home/runner/frappe-bench
|
working-directory: /home/runner/frappe-bench
|
||||||
run: bench --site frappe.local run-tests --app lms
|
run: bench --site frappe.local run-tests --app lms --coverage
|
||||||
|
- name: Upload coverage data
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
path: /home/runner/frappe-bench/sites/coverage.xml
|
||||||
|
|
||||||
|
coverage:
|
||||||
|
name: Coverage Wrap Up
|
||||||
|
needs: tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Clone
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
|
||||||
|
- name: Upload coverage data
|
||||||
|
uses: codecov/codecov-action@v5
|
||||||
|
with:
|
||||||
|
name: Server
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
fail_ci_if_error: true
|
||||||
|
verbose: true
|
||||||
9
.github/workflows/generate-pot-file.yml
vendored
9
.github/workflows/generate-pot-file.yml
vendored
@@ -22,9 +22,14 @@ jobs:
|
|||||||
ref: ${{ matrix.branch }}
|
ref: ${{ matrix.branch }}
|
||||||
|
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.14"
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
|
||||||
- name: Run script to update POT file
|
- name: Run script to update POT file
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
8
.github/workflows/linters.yml
vendored
8
.github/workflows/linters.yml
vendored
@@ -16,9 +16,9 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 200
|
fetch-depth: 200
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 24
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Check commit titles
|
- name: Check commit titles
|
||||||
@@ -35,9 +35,9 @@ jobs:
|
|||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: '3.14'
|
||||||
|
|
||||||
- name: Cache pip
|
- name: Cache pip
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
|
|||||||
4
.github/workflows/make_release_pr.yml
vendored
4
.github/workflows/make_release_pr.yml
vendored
@@ -18,9 +18,9 @@ jobs:
|
|||||||
owner: frappe
|
owner: frappe
|
||||||
repo: lms
|
repo: lms
|
||||||
title: |-
|
title: |-
|
||||||
"chore: merge 'develop' into 'main'"
|
"chore: merge 'main-hotfix' into 'main'"
|
||||||
body: "Automated weekly release"
|
body: "Automated weekly release"
|
||||||
base: main
|
base: main
|
||||||
head: develop
|
head: main-hotfix
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
4
.github/workflows/on_release.yml
vendored
4
.github/workflows/on_release.yml
vendored
@@ -15,9 +15,9 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 24
|
||||||
- name: Setup dependencies
|
- name: Setup dependencies
|
||||||
run: |
|
run: |
|
||||||
npm install @semantic-release/git @semantic-release/exec --no-save
|
npm install @semantic-release/git @semantic-release/exec --no-save
|
||||||
|
|||||||
13
.github/workflows/ui-tests.yml
vendored
13
.github/workflows/ui-tests.yml
vendored
@@ -4,7 +4,10 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
- main-hotfix
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
# Do not change this as GITHUB_TOKEN is being used by roulette
|
# Do not change this as GITHUB_TOKEN is being used by roulette
|
||||||
@@ -36,9 +39,9 @@ jobs:
|
|||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.14'
|
||||||
|
|
||||||
- name: Check for valid Python & Merge Conflicts
|
- name: Check for valid Python & Merge Conflicts
|
||||||
run: |
|
run: |
|
||||||
@@ -48,9 +51,9 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 24
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Add to Hosts
|
- name: Add to Hosts
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,4 +12,6 @@ node_modules
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
lms/public/frontend
|
lms/public/frontend
|
||||||
lms/www/lms.html
|
lms/www/lms.html
|
||||||
frappe-ui
|
lms/www/_lms.html
|
||||||
|
frappe-ui
|
||||||
|
frappe-semgrep-rules
|
||||||
30
.mergify.yml
Normal file
30
.mergify.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
pull_request_rules:
|
||||||
|
- name: backport to develop
|
||||||
|
conditions:
|
||||||
|
- label="backport develop"
|
||||||
|
actions:
|
||||||
|
backport:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
assignees:
|
||||||
|
- "{{ author }}"
|
||||||
|
|
||||||
|
- name: backport to main-hotfix
|
||||||
|
conditions:
|
||||||
|
- label="backport main-hotfix"
|
||||||
|
actions:
|
||||||
|
backport:
|
||||||
|
branches:
|
||||||
|
- main-hotfix
|
||||||
|
assignees:
|
||||||
|
- "{{ author }}"
|
||||||
|
|
||||||
|
- name: backport to main
|
||||||
|
conditions:
|
||||||
|
- label="backport main"
|
||||||
|
actions:
|
||||||
|
backport:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
assignees:
|
||||||
|
- "{{ author }}"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"branches": ["develop"],
|
"branches": ["main"],
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"@semantic-release/commit-analyzer", {
|
"@semantic-release/commit-analyzer", {
|
||||||
"preset": "angular"
|
"preset": "angular"
|
||||||
|
|||||||
2
codecov.yml
Normal file
2
codecov.yml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ignore:
|
||||||
|
- "**/test_helper.py"
|
||||||
@@ -27,6 +27,10 @@ describe("Batch Creation", () => {
|
|||||||
cy.get("input[placeholder='Jane']").type(randomName);
|
cy.get("input[placeholder='Jane']").type(randomName);
|
||||||
cy.get("button").contains("Add").click();
|
cy.get("button").contains("Add").click();
|
||||||
|
|
||||||
|
// Open Settings
|
||||||
|
cy.get("span").contains("Learning").click();
|
||||||
|
cy.get("span").contains("Settings").click();
|
||||||
|
|
||||||
// Add evaluator
|
// Add evaluator
|
||||||
cy.get("[data-dismissable-layer]")
|
cy.get("[data-dismissable-layer]")
|
||||||
.find("span")
|
.find("span")
|
||||||
@@ -48,26 +52,23 @@ describe("Batch Creation", () => {
|
|||||||
|
|
||||||
// Create a batch
|
// Create a batch
|
||||||
cy.get("button").contains("Create").click();
|
cy.get("button").contains("Create").click();
|
||||||
|
cy.get("span").contains("New Batch").click();
|
||||||
cy.wait(500);
|
cy.wait(500);
|
||||||
cy.url().should("include", "/batches/new/edit");
|
|
||||||
cy.get("label").contains("Title").type("Test Batch");
|
cy.get("label").contains("Title").type("Test Batch");
|
||||||
|
|
||||||
cy.get("label").contains("Start Date").type("2030-10-01");
|
cy.get("label").contains("Start Date").type("2030-10-01");
|
||||||
cy.get("label").contains("End Date").type("2030-10-31");
|
cy.get("label").contains("End Date").type("2030-10-31");
|
||||||
cy.get("label").contains("Start Time").type("10:00");
|
cy.get("label").contains("Start Time").type("10:00");
|
||||||
cy.get("label").contains("End Time").type("11:00");
|
cy.get("label").contains("End Time").type("11:00");
|
||||||
cy.get("label").contains("Timezone").type("IST");
|
cy.get("label").contains("Timezone").type("IST");
|
||||||
cy.get("label").contains("Seat Count").type("10");
|
cy.get("label").contains("Seat Count").type("10");
|
||||||
cy.get("label").contains("Published").click();
|
|
||||||
|
|
||||||
cy.get("label")
|
cy.get("label")
|
||||||
.contains("Short Description")
|
.contains("Description")
|
||||||
.type("Test Batch Short Description to test the UI");
|
.type("Test Batch Short Description to test the UI");
|
||||||
cy.get("div[contenteditable=true").invoke(
|
cy.get("div[contenteditable=true").invoke(
|
||||||
"text",
|
"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."
|
"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 */
|
/* Instructor */
|
||||||
cy.get("label")
|
cy.get("label")
|
||||||
.contains("Instructors")
|
.contains("Instructors")
|
||||||
@@ -85,13 +86,14 @@ describe("Batch Creation", () => {
|
|||||||
cy.get("[id^=headlessui-combobox-option-").first().click();
|
cy.get("[id^=headlessui-combobox-option-").first().click();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
cy.button("Save").click();
|
||||||
|
cy.get("label").contains("Published").click();
|
||||||
cy.button("Save").click();
|
cy.button("Save").click();
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
let batchName;
|
let batchName;
|
||||||
cy.url().then((url) => {
|
cy.url().then((url) => {
|
||||||
console.log(url);
|
console.log(url);
|
||||||
batchName = url.split("/").pop();
|
batchName = url.split("/").pop().split("#")[0];
|
||||||
cy.wrap(batchName).as("batchName");
|
cy.wrap(batchName).as("batchName");
|
||||||
});
|
});
|
||||||
cy.wait(500);
|
cy.wait(500);
|
||||||
@@ -110,7 +112,7 @@ describe("Batch Creation", () => {
|
|||||||
.click();
|
.click();
|
||||||
|
|
||||||
cy.get("@batchName").then((batchName) => {
|
cy.get("@batchName").then((batchName) => {
|
||||||
cy.get(`a[href='/lms/batches/details/${batchName}'`).within(() => {
|
cy.get(`a[href='/lms/batches/${batchName}'`).within(() => {
|
||||||
cy.get("div").contains("Test Batch").should("be.visible");
|
cy.get("div").contains("Test Batch").should("be.visible");
|
||||||
cy.get("div")
|
cy.get("div")
|
||||||
.contains("Test Batch Short Description to test the UI")
|
.contains("Test Batch Short Description to test the UI")
|
||||||
@@ -123,14 +125,11 @@ describe("Batch Creation", () => {
|
|||||||
.should("be.visible");
|
.should("be.visible");
|
||||||
cy.get("span").contains("IST").should("be.visible");
|
cy.get("span").contains("IST").should("be.visible");
|
||||||
cy.get("a").contains("Evaluator").should("be.visible");
|
cy.get("a").contains("Evaluator").should("be.visible");
|
||||||
cy.get("div")
|
cy.contains("div:visible", "10 Seats Left").should(
|
||||||
.contains("10")
|
"be.visible"
|
||||||
.should("be.visible")
|
);
|
||||||
.get("span")
|
|
||||||
.contains("Seats Left")
|
|
||||||
.should("be.visible");
|
|
||||||
});
|
});
|
||||||
cy.get(`a[href='/lms/batches/details/${batchName}'`).click();
|
cy.get(`a[href='/lms/batches/${batchName}'`).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
cy.get("div").contains("Test Batch").should("be.visible");
|
cy.get("div").contains("Test Batch").should("be.visible");
|
||||||
@@ -152,17 +151,22 @@ describe("Batch Creation", () => {
|
|||||||
"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."
|
"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");
|
.should("be.visible");
|
||||||
cy.get("button:visible").contains("Manage Batch").click();
|
cy.get("button:visible").contains("Dashboard").click();
|
||||||
|
|
||||||
/* Add student to batch */
|
/* Add student to batch */
|
||||||
cy.get("button").contains("Add").click();
|
cy.get("button").contains("Enroll").click();
|
||||||
cy.get('div[role="dialog"]').first().find("button").eq(1).click();
|
cy.get('div[role="dialog"]')
|
||||||
cy.get("input[id^='headlessui-combobox-input-v-']").type(randomEmail);
|
.first()
|
||||||
|
.find("div[label='Student']")
|
||||||
|
.find("div")
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
cy.get("input[placeholder='Search']").type(randomEmail);
|
||||||
cy.get("div").contains(randomEmail).click();
|
cy.get("div").contains(randomEmail).click();
|
||||||
cy.get("button").contains("Submit").click();
|
cy.get("button").contains("Submit").click();
|
||||||
|
|
||||||
// Verify Seat Count
|
// Verify Seat Count
|
||||||
cy.get("span").contains("Details").click();
|
cy.get("button:visible").contains("Overview").click();
|
||||||
cy.contains("div:visible", "9 Seats Left").should("be.visible");
|
cy.contains("div:visible", "9 Seats Left").should("be.visible");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ describe("Course Creation", () => {
|
|||||||
|
|
||||||
// Create a course
|
// Create a course
|
||||||
cy.get("button").contains("Create").click();
|
cy.get("button").contains("Create").click();
|
||||||
|
cy.get("span").contains("New Course").click();
|
||||||
cy.wait(500);
|
cy.wait(500);
|
||||||
cy.url().should("include", "/courses/new/edit");
|
|
||||||
|
|
||||||
cy.get("label").contains("Title").type("Test Course");
|
cy.get("label").contains("Title").type("Test Course");
|
||||||
cy.get("label")
|
cy.get("label")
|
||||||
@@ -34,27 +34,13 @@ describe("Course Creation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
cy.get("label")
|
|
||||||
.contains("Preview Video")
|
|
||||||
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
|
|
||||||
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
|
|
||||||
cy.get("label")
|
|
||||||
.contains("Category")
|
|
||||||
.parent()
|
|
||||||
.within(() => {
|
|
||||||
cy.get("button").click();
|
|
||||||
});
|
|
||||||
cy.get("[id^=headlessui-combobox-option-")
|
|
||||||
.should("be.visible")
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
|
|
||||||
/* Instructor */
|
/* Instructor */
|
||||||
cy.get("label")
|
cy.get("label")
|
||||||
.contains("Instructors")
|
.contains("Instructors")
|
||||||
.parent()
|
.parent()
|
||||||
.within(() => {
|
.within(() => {
|
||||||
cy.get("input").click().type("frappe");
|
cy.get("input").click().type("frappe");
|
||||||
|
cy.wait(500);
|
||||||
cy.get("input")
|
cy.get("input")
|
||||||
.invoke("attr", "aria-controls")
|
.invoke("attr", "aria-controls")
|
||||||
.as("instructor_list_id");
|
.as("instructor_list_id");
|
||||||
@@ -67,13 +53,29 @@ describe("Course Creation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
cy.button("Save").last().click();
|
||||||
|
|
||||||
|
// Edit Course Details
|
||||||
|
cy.wait(500);
|
||||||
|
cy.get("label")
|
||||||
|
.contains("Preview Video")
|
||||||
|
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
|
||||||
|
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
|
||||||
|
cy.get("label")
|
||||||
|
.contains("Category")
|
||||||
|
.parent()
|
||||||
|
.within(() => {
|
||||||
|
cy.get("button").click();
|
||||||
|
});
|
||||||
|
cy.get("div").contains("Business").click();
|
||||||
|
|
||||||
cy.get("label").contains("Published").click();
|
cy.get("label").contains("Published").click();
|
||||||
cy.get("label").contains("Published On").type("2021-01-01");
|
cy.get("label").contains("Published On").type("2021-01-01");
|
||||||
cy.button("Save").click();
|
cy.button("Save").click();
|
||||||
|
|
||||||
// Add Chapter
|
// Add Chapter
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.button("Add Chapter").click();
|
cy.button("Add").click();
|
||||||
|
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.get("[data-dismissable-layer]")
|
cy.get("[data-dismissable-layer]")
|
||||||
|
|||||||
Submodule frappe-ui updated: f1bde9bcb2...78025c6794
4
frontend/auto-imports.d.ts
vendored
4
frontend/auto-imports.d.ts
vendored
@@ -6,5 +6,7 @@
|
|||||||
// biome-ignore lint: disable
|
// biome-ignore lint: disable
|
||||||
export {}
|
export {}
|
||||||
declare global {
|
declare global {
|
||||||
|
const LucideGithub: typeof import('~icons/lucide/github').default
|
||||||
|
const LucideLinkedin: typeof import('~icons/lucide/linkedin').default
|
||||||
|
const LucideTwitter: typeof import('~icons/lucide/twitter').default
|
||||||
}
|
}
|
||||||
|
|||||||
34
frontend/components.d.ts
vendored
34
frontend/components.d.ts
vendored
@@ -8,13 +8,10 @@ export {}
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
Annoucements: typeof import('./src/components/Annoucements.vue')['default']
|
Apps: typeof import('./src/components/Sidebar/Apps.vue')['default']
|
||||||
AnnouncementModal: typeof import('./src/components/Modals/AnnouncementModal.vue')['default']
|
AppSidebar: typeof import('./src/components/Sidebar/AppSidebar.vue')['default']
|
||||||
Apps: typeof import('./src/components/Apps.vue')['default']
|
|
||||||
AppSidebar: typeof import('./src/components/AppSidebar.vue')['default']
|
|
||||||
AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default']
|
AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default']
|
||||||
AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default']
|
AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default']
|
||||||
Assessments: typeof import('./src/components/Assessments.vue')['default']
|
|
||||||
Assignment: typeof import('./src/components/Assignment.vue')['default']
|
Assignment: typeof import('./src/components/Assignment.vue')['default']
|
||||||
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
|
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
|
||||||
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
|
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
|
||||||
@@ -23,16 +20,8 @@ declare module 'vue' {
|
|||||||
BadgeAssignments: typeof import('./src/components/Settings/BadgeAssignments.vue')['default']
|
BadgeAssignments: typeof import('./src/components/Settings/BadgeAssignments.vue')['default']
|
||||||
BadgeForm: typeof import('./src/components/Settings/BadgeForm.vue')['default']
|
BadgeForm: typeof import('./src/components/Settings/BadgeForm.vue')['default']
|
||||||
Badges: typeof import('./src/components/Settings/Badges.vue')['default']
|
Badges: typeof import('./src/components/Settings/Badges.vue')['default']
|
||||||
BatchCard: typeof import('./src/components/BatchCard.vue')['default']
|
|
||||||
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
|
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
|
||||||
BatchCourses: typeof import('./src/components/BatchCourses.vue')['default']
|
|
||||||
BatchDashboard: typeof import('./src/components/BatchDashboard.vue')['default']
|
|
||||||
BatchFeedback: typeof import('./src/components/BatchFeedback.vue')['default']
|
|
||||||
BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default']
|
|
||||||
BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default']
|
|
||||||
BatchStudents: typeof import('./src/components/BatchStudents.vue')['default']
|
|
||||||
BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
|
BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
|
||||||
BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default']
|
|
||||||
Categories: typeof import('./src/components/Settings/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']
|
||||||
@@ -41,12 +30,18 @@ declare module 'vue' {
|
|||||||
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
|
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
|
||||||
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
|
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
|
||||||
ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default']
|
ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default']
|
||||||
|
CommandPalette: typeof import('./src/components/CommandPalette/CommandPalette.vue')['default']
|
||||||
|
CommandPaletteGroup: typeof import('./src/components/CommandPalette/CommandPaletteGroup.vue')['default']
|
||||||
|
Configuration: typeof import('./src/components/Sidebar/Configuration.vue')['default']
|
||||||
ContactUsEmail: typeof import('./src/components/ContactUsEmail.vue')['default']
|
ContactUsEmail: typeof import('./src/components/ContactUsEmail.vue')['default']
|
||||||
|
CouponDetails: typeof import('./src/components/Settings/Coupons/CouponDetails.vue')['default']
|
||||||
|
CouponItems: typeof import('./src/components/Settings/Coupons/CouponItems.vue')['default']
|
||||||
|
CouponList: typeof import('./src/components/Settings/Coupons/CouponList.vue')['default']
|
||||||
|
Coupons: typeof import('./src/components/Settings/Coupons/Coupons.vue')['default']
|
||||||
CourseCard: typeof import('./src/components/CourseCard.vue')['default']
|
CourseCard: typeof import('./src/components/CourseCard.vue')['default']
|
||||||
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
|
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
|
||||||
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
|
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
|
||||||
CourseOutline: typeof import('./src/components/CourseOutline.vue')['default']
|
CourseOutline: typeof import('./src/components/CourseOutline.vue')['default']
|
||||||
CourseProgressSummary: typeof import('./src/components/Modals/CourseProgressSummary.vue')['default']
|
|
||||||
CourseReviews: typeof import('./src/components/CourseReviews.vue')['default']
|
CourseReviews: typeof import('./src/components/CourseReviews.vue')['default']
|
||||||
CreateOutline: typeof import('./src/components/CreateOutline.vue')['default']
|
CreateOutline: typeof import('./src/components/CreateOutline.vue')['default']
|
||||||
DateRange: typeof import('./src/components/Common/DateRange.vue')['default']
|
DateRange: typeof import('./src/components/Common/DateRange.vue')['default']
|
||||||
@@ -75,7 +70,6 @@ declare module 'vue' {
|
|||||||
LessonContent: typeof import('./src/components/LessonContent.vue')['default']
|
LessonContent: typeof import('./src/components/LessonContent.vue')['default']
|
||||||
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']
|
|
||||||
LiveClassAttendance: typeof import('./src/components/Modals/LiveClassAttendance.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']
|
||||||
@@ -86,6 +80,7 @@ declare module 'vue' {
|
|||||||
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
|
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
|
||||||
Notes: typeof import('./src/components/Notes/Notes.vue')['default']
|
Notes: typeof import('./src/components/Notes/Notes.vue')['default']
|
||||||
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
|
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
|
||||||
|
NumberChartGraph: typeof import('./src/components/NumberChartGraph.vue')['default']
|
||||||
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
|
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
|
||||||
PaymentGatewayDetails: typeof import('./src/components/Settings/PaymentGatewayDetails.vue')['default']
|
PaymentGatewayDetails: typeof import('./src/components/Settings/PaymentGatewayDetails.vue')['default']
|
||||||
PaymentGateways: typeof import('./src/components/Settings/PaymentGateways.vue')['default']
|
PaymentGateways: typeof import('./src/components/Settings/PaymentGateways.vue')['default']
|
||||||
@@ -103,18 +98,19 @@ declare module 'vue' {
|
|||||||
SettingDetails: typeof import('./src/components/Settings/SettingDetails.vue')['default']
|
SettingDetails: typeof import('./src/components/Settings/SettingDetails.vue')['default']
|
||||||
SettingFields: typeof import('./src/components/Settings/SettingFields.vue')['default']
|
SettingFields: typeof import('./src/components/Settings/SettingFields.vue')['default']
|
||||||
Settings: typeof import('./src/components/Settings/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/Sidebar/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']
|
||||||
Tags: typeof import('./src/components/Tags.vue')['default']
|
Tags: typeof import('./src/components/Tags.vue')['default']
|
||||||
TransactionDetails: typeof import('./src/components/Settings/TransactionDetails.vue')['default']
|
TransactionDetails: typeof import('./src/components/Settings/Transactions/TransactionDetails.vue')['default']
|
||||||
Transactions: typeof import('./src/components/Settings/Transactions.vue')['default']
|
TransactionList: typeof import('./src/components/Settings/Transactions/TransactionList.vue')['default']
|
||||||
|
Transactions: typeof import('./src/components/Settings/Transactions/Transactions.vue')['default']
|
||||||
UnsplashImageBrowser: typeof import('./src/components/UnsplashImageBrowser.vue')['default']
|
UnsplashImageBrowser: typeof import('./src/components/UnsplashImageBrowser.vue')['default']
|
||||||
UpcomingEvaluations: typeof import('./src/components/UpcomingEvaluations.vue')['default']
|
UpcomingEvaluations: typeof import('./src/components/UpcomingEvaluations.vue')['default']
|
||||||
Uploader: typeof import('./src/components/Controls/Uploader.vue')['default']
|
Uploader: typeof import('./src/components/Controls/Uploader.vue')['default']
|
||||||
UploadPlugin: typeof import('./src/components/UploadPlugin.vue')['default']
|
UploadPlugin: typeof import('./src/components/UploadPlugin.vue')['default']
|
||||||
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/Sidebar/UserDropdown.vue')['default']
|
||||||
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
|
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
|
||||||
VideoStatistics: typeof import('./src/components/Modals/VideoStatistics.vue')['default']
|
VideoStatistics: typeof import('./src/components/Modals/VideoStatistics.vue')['default']
|
||||||
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
|
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
|
||||||
|
|||||||
@@ -6,55 +6,57 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"build": "vite build --base=/assets/lms/frontend/ && yarn copy-html-entry",
|
"build": "vite build --base=/assets/lms/frontend/ && yarn copy-html-entry && yarn copy-colors-json",
|
||||||
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html"
|
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/_lms.html",
|
||||||
|
"copy-colors-json": "cp node_modules/frappe-ui/tailwind/colors.json src/utils/frappe-ui-colors.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-html": "^6.4.9",
|
"@codemirror/lang-html": "6.4.9",
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "6.2.4",
|
||||||
"@codemirror/lang-json": "^6.0.1",
|
"@codemirror/lang-json": "6.0.1",
|
||||||
"@codemirror/lang-python": "^6.2.1",
|
"@codemirror/lang-python": "6.2.1",
|
||||||
"@editorjs/checklist": "^1.6.0",
|
"@editorjs/checklist": "1.6.0",
|
||||||
"@editorjs/code": "^2.9.0",
|
"@editorjs/code": "2.9.0",
|
||||||
"@editorjs/editorjs": "^2.29.0",
|
"@editorjs/editorjs": "2.29.0",
|
||||||
"@editorjs/embed": "^2.7.0",
|
"@editorjs/embed": "2.7.0",
|
||||||
"@editorjs/header": "^2.8.1",
|
"@editorjs/header": "2.8.1",
|
||||||
"@editorjs/inline-code": "^1.5.0",
|
"@editorjs/inline-code": "1.5.0",
|
||||||
"@editorjs/nested-list": "^1.4.2",
|
"@editorjs/nested-list": "1.4.2",
|
||||||
"@editorjs/paragraph": "^2.11.3",
|
"@editorjs/paragraph": "2.11.3",
|
||||||
"@editorjs/simple-image": "^1.6.0",
|
"@editorjs/simple-image": "1.6.0",
|
||||||
"@editorjs/table": "^2.4.2",
|
"@editorjs/table": "2.4.2",
|
||||||
"@vueuse/router": "^12.7.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
"ace-builds": "^1.36.2",
|
"ace-builds": "1.36.2",
|
||||||
"apexcharts": "^4.3.0",
|
"apexcharts": "4.3.0",
|
||||||
"chart.js": "^4.4.1",
|
"chart.js": "4.4.1",
|
||||||
"codemirror": "^6.0.1",
|
"codemirror": "6.0.1",
|
||||||
"dayjs": "^1.11.6",
|
"dayjs": "1.11.10",
|
||||||
"dompurify": "^3.2.6",
|
"dompurify": "3.2.6",
|
||||||
"feather-icons": "^4.28.0",
|
"feather-icons": "4.28.0",
|
||||||
"frappe-ui": "^0.1.201",
|
"frappe-ui": "^0.1.261",
|
||||||
"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",
|
||||||
"pinia": "^2.0.33",
|
"pinia": "2.0.33",
|
||||||
"plyr": "^3.7.8",
|
"plyr": "3.7.8",
|
||||||
"socket.io-client": "^4.7.2",
|
"socket.io-client": "4.7.2",
|
||||||
"tailwindcss": "3.4.15",
|
"thememirror": "2.0.1",
|
||||||
"thememirror": "^2.0.1",
|
"typescript": "5.7.2",
|
||||||
"typescript": "^5.7.2",
|
"vue": "^3.5.27",
|
||||||
"vue": "^3.4.23",
|
"vue-chartjs": "5.3.0",
|
||||||
"vue-chartjs": "^5.3.0",
|
"vue-codemirror": "6.1.1",
|
||||||
"vue-codemirror": "^6.1.1",
|
"vue-draggable-next": "2.2.1",
|
||||||
"vue-draggable-next": "^2.2.1",
|
"vue-router": "^4.6.4",
|
||||||
"vue-router": "^4.0.12",
|
"vue3-apexcharts": "1.8.0",
|
||||||
"vue3-apexcharts": "^1.8.0",
|
|
||||||
"vuedraggable": "4.1.0"
|
"vuedraggable": "4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.3",
|
"@vitejs/plugin-vue": "5.0.3",
|
||||||
"autoprefixer": "^10.4.2",
|
"autoprefixer": "10.4.2",
|
||||||
"postcss": "^8.4.5",
|
"postcss": "8.4.5",
|
||||||
"vite": "^5.0.11",
|
"tailwindcss": "^3.4.15",
|
||||||
"vite-plugin-pwa": "^1.0.2"
|
"unplugin-auto-import": "^20.3.0",
|
||||||
|
"vite": "5.0.11",
|
||||||
|
"vite-plugin-pwa": "^1.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,17 @@
|
|||||||
<Layout class="isolate text-base">
|
<Layout class="isolate text-base">
|
||||||
<router-view />
|
<router-view />
|
||||||
</Layout>
|
</Layout>
|
||||||
<!--<InstallPrompt v-if="isMobile" />-->
|
<InstallPrompt v-if="isMobile && !settings.data?.disable_pwa" />
|
||||||
<Dialogs />
|
<Dialogs />
|
||||||
</FrappeUIProvider>
|
</FrappeUIProvider>
|
||||||
</template>
|
</template>
|
||||||
<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, onUnmounted, ref, watch } from 'vue'
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
import { useScreenSize } from './utils/composables'
|
import { useScreenSize } from './utils/composables'
|
||||||
import { usersStore } from '@/stores/user'
|
import { useSettings } from '@/stores/settings'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { posthogSettings } from '@/telemetry'
|
|
||||||
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'
|
||||||
@@ -23,7 +22,7 @@ import InstallPrompt from './components/InstallPrompt.vue'
|
|||||||
const { isMobile } = useScreenSize()
|
const { isMobile } = useScreenSize()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const noSidebar = ref(false)
|
const noSidebar = ref(false)
|
||||||
const { userResource } = usersStore()
|
const { settings } = useSettings()
|
||||||
|
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to, from, next) => {
|
||||||
if (to.query.fromLesson || to.path === '/persona') {
|
if (to.query.fromLesson || to.path === '/persona') {
|
||||||
@@ -47,10 +46,4 @@ const Layout = computed(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
noSidebar.value = false
|
noSidebar.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(userResource, () => {
|
|
||||||
if (userResource.data) {
|
|
||||||
posthogSettings.reload()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,152 +0,0 @@
|
|||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 100;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("Inter-Thin.woff2?v=3.12") format("woff2"),
|
|
||||||
url("Inter-Thin.woff?v=3.12") format("woff");
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 100;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("Inter-ThinItalic.woff2?v=3.12") format("woff2"),
|
|
||||||
url("Inter-ThinItalic.woff?v=3.12") format("woff");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 200;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("Inter-ExtraLight.woff2?v=3.12") format("woff2"),
|
|
||||||
url("Inter-ExtraLight.woff?v=3.12") format("woff");
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 200;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("Inter-ExtraLightItalic.woff2?v=3.12") format("woff2"),
|
|
||||||
url("Inter-ExtraLightItalic.woff?v=3.12") format("woff");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("Inter-Light.woff2?v=3.12") format("woff2"),
|
|
||||||
url("Inter-Light.woff?v=3.12") format("woff");
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("Inter-LightItalic.woff2?v=3.12") format("woff2"),
|
|
||||||
url("Inter-LightItalic.woff?v=3.12") format("woff");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("Inter-Regular.woff2?v=3.12") format("woff2"),
|
|
||||||
url("Inter-Regular.woff?v=3.12") format("woff");
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("Inter-Italic.woff2?v=3.12") format("woff2"),
|
|
||||||
url("Inter-Italic.woff?v=3.12") format("woff");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("Inter-Medium.woff2?v=3.12") format("woff2"),
|
|
||||||
url("Inter-Medium.woff?v=3.12") format("woff");
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("Inter-MediumItalic.woff2?v=3.12") format("woff2"),
|
|
||||||
url("Inter-MediumItalic.woff?v=3.12") format("woff");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("Inter-SemiBold.woff2?v=3.12") format("woff2"),
|
|
||||||
url("Inter-SemiBold.woff?v=3.12") format("woff");
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 600;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("Inter-SemiBoldItalic.woff2?v=3.12") format("woff2"),
|
|
||||||
url("Inter-SemiBoldItalic.woff?v=3.12") format("woff");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("Inter-Bold.woff2?v=3.12") format("woff2"),
|
|
||||||
url("Inter-Bold.woff?v=3.12") format("woff");
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("Inter-BoldItalic.woff2?v=3.12") format("woff2"),
|
|
||||||
url("Inter-BoldItalic.woff?v=3.12") format("woff");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 800;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("Inter-ExtraBold.woff2?v=3.12") format("woff2"),
|
|
||||||
url("Inter-ExtraBold.woff?v=3.12") format("woff");
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 800;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("Inter-ExtraBoldItalic.woff2?v=3.12") format("woff2"),
|
|
||||||
url("Inter-ExtraBoldItalic.woff?v=3.12") format("woff");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("Inter-Black.woff2?v=3.12") format("woff2"),
|
|
||||||
url("Inter-Black.woff?v=3.12") format("woff");
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("Inter-BlackItalic.woff2?v=3.12") format("woff2"),
|
|
||||||
url("Inter-BlackItalic.woff?v=3.12") format("woff");
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="communications.data?.length">
|
|
||||||
<div v-for="comm in communications.data">
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<Avatar :label="comm.sender_full_name" size="lg" />
|
|
||||||
<div class="ml-2 text-ink-gray-7">
|
|
||||||
{{ comm.sender_full_name }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm">
|
|
||||||
{{ timeAgo(comm.communication_date) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="prose prose-sm bg-surface-menu-bar !min-w-full px-4 py-2 rounded-md"
|
|
||||||
v-html="comm.content"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-sm italic text-ink-gray-5">
|
|
||||||
{{ __('No announcements') }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import { createResource, Avatar } from 'frappe-ui'
|
|
||||||
import { timeAgo } from '@/utils'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
batch: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const communications = createResource({
|
|
||||||
url: 'lms.lms.api.get_announcements',
|
|
||||||
makeParams(value) {
|
|
||||||
return {
|
|
||||||
batch: props.batch,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
auto: true,
|
|
||||||
cache: ['announcement', props.batch],
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
.prose-sm p {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -26,28 +26,52 @@
|
|||||||
v-model="quiz"
|
v-model="quiz"
|
||||||
doctype="LMS Quiz"
|
doctype="LMS Quiz"
|
||||||
:label="__('Select a quiz')"
|
:label="__('Select a quiz')"
|
||||||
|
placeholder=" "
|
||||||
:onCreate="(value, close) => redirectToForm()"
|
:onCreate="(value, close) => redirectToForm()"
|
||||||
/>
|
/>
|
||||||
<Link
|
<div v-else class="space-y-4">
|
||||||
v-else
|
<Link
|
||||||
v-model="assignment"
|
v-if="filterAssignmentsByCourse"
|
||||||
doctype="LMS Assignment"
|
v-model="assignment"
|
||||||
:label="__('Select an assignment')"
|
doctype="LMS Assignment"
|
||||||
:onCreate="(value, close) => redirectToForm()"
|
:filters="{
|
||||||
/>
|
course: route.params.courseName,
|
||||||
|
}"
|
||||||
|
placeholder=" "
|
||||||
|
:label="__('Select an Assignment')"
|
||||||
|
:onCreate="(value, close) => redirectToForm()"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
v-else
|
||||||
|
v-model="assignment"
|
||||||
|
doctype="LMS Assignment"
|
||||||
|
placeholder=" "
|
||||||
|
:label="__('Select an Assignment')"
|
||||||
|
:onCreate="(value, close) => redirectToForm()"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
type="checkbox"
|
||||||
|
:label="__('Filter assignments by course')"
|
||||||
|
v-model="filterAssignmentsByCourse"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog } from 'frappe-ui'
|
import { Dialog, FormControl } from 'frappe-ui'
|
||||||
import { onMounted, ref, nextTick } from 'vue'
|
import { nextTick, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { getLmsRoute } from '@/utils/basePath'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
const show = ref(false)
|
const show = ref(false)
|
||||||
const quiz = ref(null)
|
const quiz = ref(null)
|
||||||
const assignment = ref(null)
|
const assignment = ref(null)
|
||||||
|
const filterAssignmentsByCourse = ref(false)
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
type: {
|
type: {
|
||||||
@@ -71,7 +95,10 @@ const addAssessment = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const redirectToForm = () => {
|
const redirectToForm = () => {
|
||||||
if (props.type == 'quiz') window.open('/lms/quizzes/new', '_blank')
|
if (props.type == 'quiz') {
|
||||||
else window.open('/lms/assignments/new', '_blank')
|
window.open(getLmsRoute('quizzes?new=true'), '_blank')
|
||||||
|
} else {
|
||||||
|
window.open(getLmsRoute('assignments?new=true'), '_blank')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
{{ __('Submission by') }} {{ submissionResource.doc?.member_name }}
|
{{ __('Submission by') }} {{ submissionResource.doc?.member_name }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-ink-gray-7 font-medium mb-2">
|
<div class="text-ink-gray-9 font-semibold mb-5">
|
||||||
{{ __('Question') }}:
|
{{ __('Assignment Question') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-html="assignment.data.question"
|
v-html="assignment.data.question"
|
||||||
@@ -25,9 +25,9 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col overflow-y-auto">
|
||||||
<div class="p-5">
|
<div class="p-5 space-y-5">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between">
|
||||||
<div class="font-semibold text-ink-gray-9">
|
<div class="font-semibold text-ink-gray-9">
|
||||||
{{ __('Submission') }}
|
{{ __('Submission') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -42,7 +42,11 @@
|
|||||||
>
|
>
|
||||||
{{ submissionResource.doc?.status }}
|
{{ submissionResource.doc?.status }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button variant="solid" @click="submitAssignment()">
|
<Button
|
||||||
|
v-if="canModifyAssignment"
|
||||||
|
variant="solid"
|
||||||
|
@click="submitAssignment()"
|
||||||
|
>
|
||||||
{{ __('Save') }}
|
{{ __('Save') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,7 +57,7 @@
|
|||||||
!['Pass', 'Fail'].includes(submissionResource.doc?.status) &&
|
!['Pass', 'Fail'].includes(submissionResource.doc?.status) &&
|
||||||
submissionResource.doc?.owner == user.data?.name
|
submissionResource.doc?.owner == user.data?.name
|
||||||
"
|
"
|
||||||
class="bg-surface-blue-2 text-ink-blue-2 p-3 rounded-md leading-5 text-sm mb-4"
|
class="bg-surface-blue-2 text-ink-blue-2 p-3 rounded-md leading-5 text-sm"
|
||||||
>
|
>
|
||||||
{{ __("You've successfully submitted the assignment.") }}
|
{{ __("You've successfully submitted the assignment.") }}
|
||||||
{{
|
{{
|
||||||
@@ -63,17 +67,24 @@
|
|||||||
}}
|
}}
|
||||||
{{ __('Feel free to make edits to your submission if needed.') }}
|
{{ __('Feel free to make edits to your submission if needed.') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showUploader()">
|
<div v-if="showUploader()" class="border rounded-lg p-3">
|
||||||
<div class="text-xs text-ink-gray-5 mt-1 mb-2">
|
<div class="font-semibold mb-2">
|
||||||
{{ __('Add your assignment as {0}').format(assignment.data.type) }}
|
{{ __('Upload Assignment') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-ink-gray-5 text-sm mt-1 mb-4">
|
||||||
|
{{
|
||||||
|
__('You can only upload {0} files').format(assignment.data.type)
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
<FileUploader
|
<FileUploader
|
||||||
v-if="!submissionFile"
|
v-if="!attachment"
|
||||||
:fileTypes="getType()"
|
:fileTypes="getType()"
|
||||||
:uploadArgs="{
|
:uploadArgs="{
|
||||||
private: true,
|
private: true,
|
||||||
}"
|
}"
|
||||||
:validateFile="validateFile"
|
:validateFile="
|
||||||
|
(file) => validateFile(file, assignment.data.type.toLowerCase())
|
||||||
|
"
|
||||||
@success="(file) => saveSubmission(file)"
|
@success="(file) => saveSubmission(file)"
|
||||||
>
|
>
|
||||||
<template #default="{ uploading, progress, openFileSelector }">
|
<template #default="{ uploading, progress, openFileSelector }">
|
||||||
@@ -87,21 +98,20 @@
|
|||||||
</template>
|
</template>
|
||||||
</FileUploader>
|
</FileUploader>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="flex text-ink-gray-7">
|
<div class="flex items-center text-ink-gray-7">
|
||||||
<div class="border self-start rounded-md p-2 mr-2">
|
|
||||||
<FileText class="h-5 w-5 stroke-1.5" />
|
|
||||||
</div>
|
|
||||||
<a
|
<a
|
||||||
:href="submissionFile.file_url"
|
:href="attachment"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="flex flex-col cursor-pointer !no-underline"
|
class="cursor-pointer !no-underline text-sm leading-5"
|
||||||
>
|
>
|
||||||
<span class="text-sm leading-5">
|
<div class="flex items-center">
|
||||||
{{ submissionFile.file_name }}
|
<div class="border rounded-md p-2 mr-2">
|
||||||
</span>
|
<FileText class="h-5 w-5 stroke-1.5" />
|
||||||
<span class="text-sm text-ink-gray-5 mt-1">
|
</div>
|
||||||
{{ getFileSize(submissionFile.file_size) }}
|
<span>
|
||||||
</span>
|
{{ attachment.split('/').pop() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<X
|
<X
|
||||||
v-if="canModifyAssignment"
|
v-if="canModifyAssignment"
|
||||||
@@ -130,10 +140,11 @@
|
|||||||
@change="(val) => (answer = val)"
|
@change="(val) => (answer = val)"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
:fixedMenu="true"
|
:fixedMenu="true"
|
||||||
|
:readonly="!canModifyAssignment"
|
||||||
:uploadArgs="{
|
:uploadArgs="{
|
||||||
private: true,
|
private: true,
|
||||||
}"
|
}"
|
||||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -142,13 +153,13 @@
|
|||||||
user.data?.name == submissionResource.doc?.owner &&
|
user.data?.name == submissionResource.doc?.owner &&
|
||||||
submissionResource.doc?.comments
|
submissionResource.doc?.comments
|
||||||
"
|
"
|
||||||
class="mt-8 p-3 bg-surface-blue-2 rounded-md"
|
class="mt-8 p-3 border rounded-lg bg-surface-gray-2"
|
||||||
>
|
>
|
||||||
<div class="text-sm text-ink-gray-5 font-medium mb-2">
|
<div class="text-ink-gray-5 mb-4">
|
||||||
{{ __('Comments by Evaluator') }}:
|
{{ __('Comments by Evaluator') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="leading-5 text-ink-gray-9"
|
class="leading-6 text-ink-gray-9"
|
||||||
v-html="submissionResource.doc.comments"
|
v-html="submissionResource.doc.comments"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,7 +190,10 @@
|
|||||||
"
|
"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
:fixedMenu="true"
|
:fixedMenu="true"
|
||||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
:uploadArgs="{
|
||||||
|
private: true,
|
||||||
|
}"
|
||||||
|
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,11 +215,11 @@ import {
|
|||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { FileText, X } from 'lucide-vue-next'
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
import { getFileSize } from '@/utils'
|
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { validateFile } from '@/utils'
|
||||||
|
|
||||||
const submissionFile = ref(null)
|
|
||||||
const answer = ref(null)
|
const answer = ref(null)
|
||||||
|
const attachment = ref(null)
|
||||||
const comments = ref(null)
|
const comments = ref(null)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
@@ -255,129 +269,98 @@ const assignment = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const newSubmission = createResource({
|
|
||||||
url: 'frappe.client.insert',
|
|
||||||
makeParams(values) {
|
|
||||||
let doc = {
|
|
||||||
doctype: 'LMS Assignment Submission',
|
|
||||||
assignment: props.assignmentID,
|
|
||||||
member: user.data?.name,
|
|
||||||
}
|
|
||||||
if (showUploader()) {
|
|
||||||
doc.assignment_attachment = submissionFile.value.file_url
|
|
||||||
} else {
|
|
||||||
doc.answer = answer.value
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
doc: doc,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const imageResource = createResource({
|
|
||||||
url: 'lms.lms.api.get_file_info',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
file_url: values.image,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
auto: false,
|
|
||||||
onSuccess(data) {
|
|
||||||
submissionFile.value = data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const submissionResource = createDocumentResource({
|
const submissionResource = createDocumentResource({
|
||||||
doctype: 'LMS Assignment Submission',
|
doctype: 'LMS Assignment Submission',
|
||||||
name: props.submissionName,
|
name: props.submissionName,
|
||||||
|
auto: false,
|
||||||
onError(err) {
|
onError(err) {
|
||||||
toast.error(err.messages?.[0] || err)
|
toast.error(err.messages?.[0] || err)
|
||||||
},
|
},
|
||||||
auto: false,
|
|
||||||
cache: [user.data?.name, props.assignmentID],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(submissionResource, () => {
|
watch(submissionResource, () => {
|
||||||
if (submissionResource.doc) {
|
if (!submissionResource.doc) return
|
||||||
if (submissionResource.doc.assignment_attachment) {
|
console.log(submissionResource.doc)
|
||||||
imageResource.reload({
|
if (submissionResource.doc.answer) {
|
||||||
image: submissionResource.doc.assignment_attachment,
|
answer.value = submissionResource.doc.answer
|
||||||
})
|
|
||||||
}
|
|
||||||
if (submissionResource.doc.answer) {
|
|
||||||
answer.value = submissionResource.doc.answer
|
|
||||||
}
|
|
||||||
if (submissionResource.doc.comments) {
|
|
||||||
comments.value = submissionResource.doc.comments
|
|
||||||
}
|
|
||||||
if (submissionResource.isDirty) {
|
|
||||||
isDirty.value = true
|
|
||||||
} else if (showUploader() && !submissionFile.value) {
|
|
||||||
isDirty.value = true
|
|
||||||
} else if (!showUploader() && !answer.value) {
|
|
||||||
isDirty.value = true
|
|
||||||
} else {
|
|
||||||
isDirty.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
if (submissionResource.doc.assignment_attachment) {
|
||||||
|
attachment.value = submissionResource.doc.assignment_attachment
|
||||||
watch(submissionFile, () => {
|
}
|
||||||
if (props.submissionName == 'new' && submissionFile.value) {
|
if (submissionResource.doc.comments) {
|
||||||
isDirty.value = true
|
comments.value = submissionResource.doc.comments
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const submitAssignment = () => {
|
const submitAssignment = () => {
|
||||||
if (props.submissionName != 'new') {
|
if (props.submissionName != 'new') {
|
||||||
let evaluator =
|
updateSubmission()
|
||||||
submissionResource.doc && submissionResource.doc.owner != user.data?.name
|
|
||||||
? user.data?.name
|
|
||||||
: null
|
|
||||||
|
|
||||||
submissionResource.setValue.submit(
|
|
||||||
{
|
|
||||||
...submissionResource.doc,
|
|
||||||
assignment_attachment: submissionFile.value?.file_url,
|
|
||||||
evaluator: evaluator,
|
|
||||||
comments: comments.value,
|
|
||||||
answer: answer.value,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
toast.success(__('Changes saved successfully'))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
addNewSubmission()
|
addNewSubmission()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const addNewSubmission = () => {
|
const addNewSubmission = () => {
|
||||||
newSubmission.submit(
|
let doc = {
|
||||||
{},
|
doctype: 'LMS Assignment Submission',
|
||||||
|
assignment: props.assignmentID,
|
||||||
|
member: user.data?.name,
|
||||||
|
}
|
||||||
|
if (!showUploader()) {
|
||||||
|
doc.answer = answer.value
|
||||||
|
} else {
|
||||||
|
doc.assignment_attachment = attachment.value
|
||||||
|
}
|
||||||
|
call('frappe.client.insert', {
|
||||||
|
doc: doc,
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
toast.success(__('Assignment submitted successfully'))
|
||||||
|
if (router.currentRoute.value.name == 'AssignmentSubmission') {
|
||||||
|
router.push({
|
||||||
|
name: 'AssignmentSubmission',
|
||||||
|
params: {
|
||||||
|
assignmentID: props.assignmentID,
|
||||||
|
submissionName: data.name,
|
||||||
|
},
|
||||||
|
query: { fromLesson: router.currentRoute.value.query.fromLesson },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
markLessonProgress()
|
||||||
|
router.go()
|
||||||
|
}
|
||||||
|
isDirty.value = false
|
||||||
|
submissionResource.name = data.name
|
||||||
|
submissionResource.reload()
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(err.messages?.[0] || err)
|
||||||
|
console.error(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSubmission = () => {
|
||||||
|
let evaluator =
|
||||||
|
submissionResource.doc && submissionResource.doc.owner != user.data?.name
|
||||||
|
? user.data?.name
|
||||||
|
: null
|
||||||
|
|
||||||
|
submissionResource.setValue.submit(
|
||||||
|
{
|
||||||
|
...submissionResource.doc,
|
||||||
|
evaluator: evaluator,
|
||||||
|
comments: comments.value,
|
||||||
|
answer: answer.value,
|
||||||
|
assignment_attachment: attachment.value,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
toast.success(__('Assignment submitted successfully'))
|
isDirty.value = false
|
||||||
if (router.currentRoute.value.name == 'AssignmentSubmission') {
|
toast.success(__('Changes saved successfully'))
|
||||||
router.push({
|
|
||||||
name: 'AssignmentSubmission',
|
|
||||||
params: {
|
|
||||||
assignmentID: props.assignmentID,
|
|
||||||
submissionName: data.name,
|
|
||||||
},
|
|
||||||
query: { fromLesson: router.currentRoute.value.query.fromLesson },
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
markLessonProgress()
|
|
||||||
router.go()
|
|
||||||
}
|
|
||||||
submissionResource.name = data.name
|
|
||||||
submissionResource.reload()
|
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
toast.error(err.messages?.[0] || err)
|
toast.error(err.messages?.[0] || err)
|
||||||
|
console.error(err)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -385,7 +368,7 @@ const addNewSubmission = () => {
|
|||||||
|
|
||||||
const saveSubmission = (file) => {
|
const saveSubmission = (file) => {
|
||||||
isDirty.value = true
|
isDirty.value = true
|
||||||
submissionFile.value = file
|
attachment.value = file.file_url
|
||||||
}
|
}
|
||||||
|
|
||||||
const markLessonProgress = () => {
|
const markLessonProgress = () => {
|
||||||
@@ -419,24 +402,9 @@ const getType = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateFile = (file) => {
|
|
||||||
let type = assignment.data?.type
|
|
||||||
let extension = file.name.split('.').pop().toLowerCase()
|
|
||||||
if (type == 'Image' && !['jpg', 'jpeg', 'png'].includes(extension)) {
|
|
||||||
return 'Only image file is allowed.'
|
|
||||||
} else if (
|
|
||||||
type == 'Document' &&
|
|
||||||
!['doc', 'docx', 'xml'].includes(extension)
|
|
||||||
) {
|
|
||||||
return 'Only document file is allowed.'
|
|
||||||
} else if (type == 'PDF' && !['pdf'].includes(extension)) {
|
|
||||||
return 'Only PDF file is allowed.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeSubmission = () => {
|
const removeSubmission = () => {
|
||||||
isDirty.value = true
|
isDirty.value = true
|
||||||
submissionFile.value = null
|
submissionResource.doc.assignment_attachment = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const canGradeSubmission = computed(() => {
|
const canGradeSubmission = computed(() => {
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="space-y-10">
|
|
||||||
<UpcomingEvaluations
|
|
||||||
:batch="batch.data.name"
|
|
||||||
:endDate="batch.data.evaluation_end_date"
|
|
||||||
:courses="batch.data.courses"
|
|
||||||
/>
|
|
||||||
<Assessments :batch="batch.data.name" />
|
|
||||||
<!-- <StudentHeatmap /> -->
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
|
||||||
import Assessments from '@/components/Assessments.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
batch: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
isStudent: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,354 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="batch.data" class="">
|
|
||||||
<div class="w-full flex items-center justify-between pb-4">
|
|
||||||
<div class="font-medium text-ink-gray-7">
|
|
||||||
{{ __('Statistics') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
|
|
||||||
<NumberChart
|
|
||||||
class="border rounded-md"
|
|
||||||
:config="{ title: __('Students'), value: students.data?.length || 0 }"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<NumberChart
|
|
||||||
class="border rounded-md"
|
|
||||||
:config="{
|
|
||||||
title: __('Certified'),
|
|
||||||
value: certificationCount.data || 0,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<NumberChart
|
|
||||||
class="border rounded-md"
|
|
||||||
:config="{
|
|
||||||
title: __('Courses'),
|
|
||||||
value: batch.data.courses?.length || 0,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<NumberChart
|
|
||||||
class="border rounded-md"
|
|
||||||
:config="{ title: __('Assessments'), value: assessmentCount || 0 }"
|
|
||||||
/>
|
|
||||||
</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 class="flex items-center justify-between mb-4">
|
|
||||||
<div class="text-ink-gray-7 font-medium">
|
|
||||||
{{ __('Students') }}
|
|
||||||
</div>
|
|
||||||
<Button v-if="!readOnlyMode" @click="openStudentModal()">
|
|
||||||
<template #prefix>
|
|
||||||
<Plus class="h-4 w-4" />
|
|
||||||
</template>
|
|
||||||
{{ __('Add') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="students.data?.length">
|
|
||||||
<ListView
|
|
||||||
:columns="getStudentColumns()"
|
|
||||||
:rows="students.data"
|
|
||||||
row-key="name"
|
|
||||||
: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 getStudentColumns()"
|
|
||||||
:title="item.label"
|
|
||||||
>
|
|
||||||
<template #prefix="{ item }">
|
|
||||||
<FeatherIcon
|
|
||||||
v-if="item.icon"
|
|
||||||
:name="item.icon"
|
|
||||||
class="h-4 w-4 stroke-1.5"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</ListHeaderItem>
|
|
||||||
</ListHeader>
|
|
||||||
<ListRows>
|
|
||||||
<ListRow
|
|
||||||
:row="row"
|
|
||||||
v-for="row in students.data"
|
|
||||||
class="group cursor-pointer"
|
|
||||||
@click="openStudentProgressModal(row)"
|
|
||||||
>
|
|
||||||
<template #default="{ column, item }">
|
|
||||||
<ListRowItem
|
|
||||||
:item="row[column.key]"
|
|
||||||
:align="column.align"
|
|
||||||
class="text-sm"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
|
||||||
<div v-if="column.key == 'full_name'">
|
|
||||||
<Avatar
|
|
||||||
class="flex items-center"
|
|
||||||
:image="row['user_image']"
|
|
||||||
:label="item"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div
|
|
||||||
v-if="column.key == 'progress'"
|
|
||||||
class="flex items-center space-x-4 w-full"
|
|
||||||
>
|
|
||||||
<ProgressBar :progress="row[column.key]" size="sm" />
|
|
||||||
<div class="text-xs">{{ row[column.key] }}%</div>
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
{{ row[column.key] }}
|
|
||||||
</div>
|
|
||||||
</ListRowItem>
|
|
||||||
</template>
|
|
||||||
</ListRow>
|
|
||||||
</ListRows>
|
|
||||||
<ListSelectBanner>
|
|
||||||
<template #actions="{ unselectAll, selections }">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
@click="removeStudents(selections, unselectAll)"
|
|
||||||
>
|
|
||||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ListSelectBanner>
|
|
||||||
</ListView>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-sm italic text-ink-gray-5">
|
|
||||||
{{ __('There are no students in this batch.') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<StudentModal
|
|
||||||
:batch="props.batch.data.name"
|
|
||||||
v-model="showStudentModal"
|
|
||||||
v-model:reloadStudents="students"
|
|
||||||
v-model:batchModal="props.batch"
|
|
||||||
/>
|
|
||||||
<BatchStudentProgress
|
|
||||||
:student="selectedStudent"
|
|
||||||
v-model="showStudentProgressModal"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
AxisChart,
|
|
||||||
Button,
|
|
||||||
createResource,
|
|
||||||
FeatherIcon,
|
|
||||||
ListHeader,
|
|
||||||
ListHeaderItem,
|
|
||||||
ListSelectBanner,
|
|
||||||
ListRow,
|
|
||||||
ListRows,
|
|
||||||
ListView,
|
|
||||||
ListRowItem,
|
|
||||||
NumberChart,
|
|
||||||
toast,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import {
|
|
||||||
BookOpen,
|
|
||||||
GraduationCap,
|
|
||||||
Plus,
|
|
||||||
ShieldCheck,
|
|
||||||
Trash2,
|
|
||||||
User,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import StudentModal from '@/components/Modals/StudentModal.vue'
|
|
||||||
import ProgressBar from '@/components/ProgressBar.vue'
|
|
||||||
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
|
|
||||||
import ApexChart from 'vue3-apexcharts'
|
|
||||||
import { theme } from '@/utils/theme'
|
|
||||||
|
|
||||||
const showStudentModal = ref(false)
|
|
||||||
const showStudentProgressModal = ref(false)
|
|
||||||
const selectedStudent = ref(null)
|
|
||||||
const chartData = ref(null)
|
|
||||||
const showProgressChart = ref(false)
|
|
||||||
const assessmentCount = ref(0)
|
|
||||||
const readOnlyMode = window.read_only_mode
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
batch: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const students = createResource({
|
|
||||||
url: 'lms.lms.utils.get_batch_students',
|
|
||||||
params: {
|
|
||||||
batch: props.batch?.data?.name,
|
|
||||||
},
|
|
||||||
auto: true,
|
|
||||||
onSuccess(data) {
|
|
||||||
chartData.value = getChartData()
|
|
||||||
showProgressChart.value =
|
|
||||||
data.length &&
|
|
||||||
(props.batch?.data?.courses?.length || assessmentCount.value)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const getStudentColumns = () => {
|
|
||||||
let columns = [
|
|
||||||
{
|
|
||||||
label: 'Full Name',
|
|
||||||
key: 'full_name',
|
|
||||||
width: '20rem',
|
|
||||||
icon: 'user',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Progress',
|
|
||||||
key: 'progress',
|
|
||||||
width: '15rem',
|
|
||||||
icon: 'activity',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Last Active',
|
|
||||||
key: 'last_active',
|
|
||||||
width: '10rem',
|
|
||||||
align: 'center',
|
|
||||||
icon: 'clock',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return columns
|
|
||||||
}
|
|
||||||
|
|
||||||
const openStudentModal = () => {
|
|
||||||
showStudentModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const openStudentProgressModal = (row) => {
|
|
||||||
showStudentProgressModal.value = true
|
|
||||||
selectedStudent.value = row
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteStudents = createResource({
|
|
||||||
url: 'lms.lms.api.delete_documents',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doctype: 'LMS Batch Enrollment',
|
|
||||||
documents: values.students,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const removeStudents = (selections, unselectAll) => {
|
|
||||||
deleteStudents.submit(
|
|
||||||
{
|
|
||||||
students: Array.from(selections),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
students.reload()
|
|
||||||
props.batch.reload()
|
|
||||||
toast.success(__('Students deleted successfully'))
|
|
||||||
unselectAll()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getChartData = () => {
|
|
||||||
let tasks = []
|
|
||||||
let data = []
|
|
||||||
|
|
||||||
students.data.forEach((row) => {
|
|
||||||
tasks = countAssessments(row, tasks)
|
|
||||||
tasks = countCourses(row, tasks)
|
|
||||||
})
|
|
||||||
|
|
||||||
tasks.forEach((task) => {
|
|
||||||
data.push({
|
|
||||||
task: task.label,
|
|
||||||
value: task.value,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
const countAssessments = (row, tasks) => {
|
|
||||||
Object.keys(row.assessments).forEach((assessment) => {
|
|
||||||
if (row.assessments[assessment].result === 'Pass') {
|
|
||||||
tasks.filter((task) => task.label === assessment).length
|
|
||||||
? tasks.filter((task) => task.label === assessment)[0].value++
|
|
||||||
: tasks.push({
|
|
||||||
value: 1,
|
|
||||||
label: assessment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return tasks
|
|
||||||
}
|
|
||||||
|
|
||||||
const countCourses = (row, tasks) => {
|
|
||||||
Object.keys(row.courses).forEach((course) => {
|
|
||||||
if (row.courses[course] === 100) {
|
|
||||||
tasks.filter((task) => task.label === course).length
|
|
||||||
? tasks.filter((task) => task.label === course)[0].value++
|
|
||||||
: tasks.push({
|
|
||||||
value: 1,
|
|
||||||
label: course,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return tasks
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(students, () => {
|
|
||||||
if (students.data?.length) {
|
|
||||||
assessmentCount.value = Object.keys(students.data?.[0].assessments).length
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const certificationCount = createResource({
|
|
||||||
url: 'frappe.client.get_count',
|
|
||||||
params: {
|
|
||||||
doctype: 'LMS Certificate',
|
|
||||||
filters: {
|
|
||||||
batch_name: props.batch?.data?.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
auto: true,
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -68,11 +68,12 @@ const props = defineProps({
|
|||||||
|
|
||||||
const certification = createResource({
|
const certification = createResource({
|
||||||
url: 'lms.lms.api.get_certification_details',
|
url: 'lms.lms.api.get_certification_details',
|
||||||
params: {
|
makeParams(values) {
|
||||||
course: props.courseName,
|
return {
|
||||||
|
course: props.courseName,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
auto: user.data ? true : false,
|
auto: user.data ? true : false,
|
||||||
cache: ['certificationData', user.data?.name],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const downloadCertificate = () => {
|
const downloadCertificate = () => {
|
||||||
|
|||||||
272
frontend/src/components/CommandPalette/CommandPalette.vue
Normal file
272
frontend/src/components/CommandPalette/CommandPalette.vue
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog v-model="show" :options="{ size: '2xl' }">
|
||||||
|
<template #body>
|
||||||
|
<div class="text-base">
|
||||||
|
<div class="flex items-center space-x-2 pl-4.5 border-b">
|
||||||
|
<Search class="size-4 text-ink-gray-4" />
|
||||||
|
<input
|
||||||
|
ref="inputRef"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search"
|
||||||
|
class="w-full border-none bg-transparent py-3 !pl-2 pr-4.5 text-base text-ink-gray-7 placeholder-ink-gray-4 focus:ring-0"
|
||||||
|
@input="onInput"
|
||||||
|
v-model="query"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-96 overflow-auto mb-2">
|
||||||
|
<div v-if="query.length" class="mt-5 space-y-5">
|
||||||
|
<CommandPaletteGroup
|
||||||
|
:list="searchResults"
|
||||||
|
@navigateTo="navigateTo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="mt-5 space-y-5">
|
||||||
|
<CommandPaletteGroup
|
||||||
|
:list="jumpToOptions"
|
||||||
|
@navigateTo="navigateTo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center space-x-5 w-full border-t py-2 text-sm text-ink-gray-7 px-4.5"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<MoveUp
|
||||||
|
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
|
||||||
|
/>
|
||||||
|
<MoveDown
|
||||||
|
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{{ __('to navigate') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<CornerDownLeft
|
||||||
|
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{{ __('to select') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="bg-surface-gray-2 p-1 rounded-sm"> esc </span>
|
||||||
|
<span>
|
||||||
|
{{ __('to close') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { createResource, debounce, Dialog } from 'frappe-ui'
|
||||||
|
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
Briefcase,
|
||||||
|
CornerDownLeft,
|
||||||
|
FileSearch,
|
||||||
|
MoveUp,
|
||||||
|
MoveDown,
|
||||||
|
Search,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import CommandPaletteGroup from './CommandPaletteGroup.vue'
|
||||||
|
|
||||||
|
const show = defineModel<boolean>({ required: true, default: false })
|
||||||
|
const router = useRouter()
|
||||||
|
const query = ref<string>('')
|
||||||
|
const searchResults = ref<Array<any>>([])
|
||||||
|
|
||||||
|
const search = createResource({
|
||||||
|
url: 'lms.command_palette.search_sqlite',
|
||||||
|
makeParams: () => ({
|
||||||
|
query: query.value,
|
||||||
|
}),
|
||||||
|
onSuccess() {
|
||||||
|
generateSearchResults()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const debouncedSearch = debounce(() => {
|
||||||
|
if (query.value.length > 2) {
|
||||||
|
search.reload()
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
const onInput = () => {
|
||||||
|
debouncedSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateSearchResults = () => {
|
||||||
|
search.data?.forEach((type: any) => {
|
||||||
|
let result: { title: string; items: any[] } = { title: '', items: [] }
|
||||||
|
result.title = type.title
|
||||||
|
type.items.forEach((item: any) => {
|
||||||
|
let paramName = item.doctype === 'LMS Course' ? 'courseName' : 'batchName'
|
||||||
|
item.route = {
|
||||||
|
name: item.doctype === 'LMS Course' ? 'CourseDetail' : 'BatchDetail',
|
||||||
|
params: {
|
||||||
|
[paramName]: item.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
item.isActive = false
|
||||||
|
})
|
||||||
|
result.items = type.items
|
||||||
|
searchResults.value.push(result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendSearchPage = () => {
|
||||||
|
let searchPage: { title: string; items: Array<any> } = {
|
||||||
|
title: '',
|
||||||
|
items: [],
|
||||||
|
}
|
||||||
|
searchPage.title = __('Jump to')
|
||||||
|
searchPage.items = [
|
||||||
|
{
|
||||||
|
title: __('Search for ') + `"${query.value}"`,
|
||||||
|
route: {
|
||||||
|
name: 'Search',
|
||||||
|
query: {
|
||||||
|
q: query.value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
icon: FileSearch,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
searchResults.value = [searchPage]
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
query,
|
||||||
|
() => {
|
||||||
|
appendSearchPage()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(show, () => {
|
||||||
|
if (!show.value) {
|
||||||
|
query.value = ''
|
||||||
|
searchResults.value = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
addKeyboardShortcuts()
|
||||||
|
})
|
||||||
|
|
||||||
|
const addKeyboardShortcuts = () => {
|
||||||
|
window.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowUp' && show.value) {
|
||||||
|
e.preventDefault()
|
||||||
|
shortcutForArrowKey(-1)
|
||||||
|
} else if (e.key === 'ArrowDown' && show.value) {
|
||||||
|
shortcutForArrowKey(1)
|
||||||
|
} else if (e.key === 'Enter' && show.value) {
|
||||||
|
shortcutForEnter()
|
||||||
|
} else if (e.key === 'Escape' && show.value) {
|
||||||
|
show.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortcutForArrowKey = (direction: number) => {
|
||||||
|
let currentList = query.value.length
|
||||||
|
? searchResults.value
|
||||||
|
: jumpToOptions.value
|
||||||
|
let allItems = currentList.flatMap((result: any) => result.items)
|
||||||
|
let indexOfActive = allItems.findIndex((option: any) => option.isActive)
|
||||||
|
let newIndex = indexOfActive + direction
|
||||||
|
if (newIndex < 0) newIndex = allItems.length - 1
|
||||||
|
if (newIndex >= allItems.length) newIndex = 0
|
||||||
|
allItems[indexOfActive].isActive = false
|
||||||
|
allItems[newIndex].isActive = true
|
||||||
|
nextTick(scrollActiveItemIntoView)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollActiveItemIntoView = () => {
|
||||||
|
const activeItem = document.querySelector(
|
||||||
|
'.hover\\:bg-surface-gray-2.bg-surface-gray-2'
|
||||||
|
) as HTMLElement
|
||||||
|
if (activeItem) {
|
||||||
|
activeItem.scrollIntoView({ block: 'nearest' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortcutForEnter = () => {
|
||||||
|
let currentList = query.value.length
|
||||||
|
? searchResults.value
|
||||||
|
: jumpToOptions.value
|
||||||
|
let allItems = currentList.flatMap((result: any) => result.items)
|
||||||
|
let activeOption = allItems.find((option) => option.isActive)
|
||||||
|
if (activeOption) {
|
||||||
|
navigateTo(activeOption.route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateTo = (route: {
|
||||||
|
name: string
|
||||||
|
params?: Record<string, any>
|
||||||
|
query?: Record<string, any>
|
||||||
|
}) => {
|
||||||
|
show.value = false
|
||||||
|
query.value = ''
|
||||||
|
router.replace({ name: route.name, params: route.params, query: route.query })
|
||||||
|
}
|
||||||
|
|
||||||
|
const jumpToOptions = ref([
|
||||||
|
{
|
||||||
|
title: __('Jump to'),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: 'Advanced Search',
|
||||||
|
icon: Search,
|
||||||
|
route: {
|
||||||
|
name: 'Search',
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Courses',
|
||||||
|
icon: BookOpen,
|
||||||
|
route: {
|
||||||
|
name: 'Courses',
|
||||||
|
},
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Batches',
|
||||||
|
icon: Users,
|
||||||
|
route: {
|
||||||
|
name: 'Batches',
|
||||||
|
},
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Jobs',
|
||||||
|
icon: Briefcase,
|
||||||
|
route: {
|
||||||
|
name: 'Jobs',
|
||||||
|
},
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
mark {
|
||||||
|
background-color: theme('colors.amber.100');
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<div v-for="result in list" class="px-2.5 space-y-2">
|
||||||
|
<div class="text-ink-gray-5 px-2">
|
||||||
|
{{ result.title }}
|
||||||
|
</div>
|
||||||
|
<div class="">
|
||||||
|
<div
|
||||||
|
v-for="item in result.items"
|
||||||
|
class="flex items-center justify-between p-2 rounded hover:bg-surface-gray-2 cursor-pointer"
|
||||||
|
:class="{ 'bg-surface-gray-2': item.isActive }"
|
||||||
|
@click="emit('navigateTo', item.route)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<component
|
||||||
|
v-if="item.icon"
|
||||||
|
:is="item.icon"
|
||||||
|
class="size-4 stroke-1.5 text-ink-gray-6"
|
||||||
|
/>
|
||||||
|
<div v-html="item.title"></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="item.modified" class="text-ink-gray-5">
|
||||||
|
{{ dayjs.unix(item.modified).fromNow(true) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { inject } from 'vue'
|
||||||
|
|
||||||
|
const dayjs = inject<any>('$dayjs')
|
||||||
|
const emit = defineEmits(['navigateTo'])
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
list: Array<{
|
||||||
|
title: string
|
||||||
|
items: Array<{
|
||||||
|
title: string
|
||||||
|
icon?: any
|
||||||
|
isActive?: boolean
|
||||||
|
modified?: string
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -48,7 +48,7 @@ const settingsStore = useSettings()
|
|||||||
|
|
||||||
const sendMail = (close: Function) => {
|
const sendMail = (close: Function) => {
|
||||||
call('frappe.core.doctype.communication.email.make', {
|
call('frappe.core.doctype.communication.email.make', {
|
||||||
recipients: settingsStore.contactUsEmail?.data,
|
recipients: settingsStore.settings?.data?.contact_us_email,
|
||||||
subject: subject.value,
|
subject: subject.value,
|
||||||
content: message.value,
|
content: message.value,
|
||||||
send_email: true,
|
send_email: true,
|
||||||
|
|||||||
@@ -16,13 +16,18 @@
|
|||||||
<button
|
<button
|
||||||
class="flex w-full items-center justify-between focus:outline-none"
|
class="flex w-full items-center justify-between focus:outline-none"
|
||||||
:class="inputClasses"
|
:class="inputClasses"
|
||||||
@click="() => togglePopover()"
|
@click="
|
||||||
|
() => {
|
||||||
|
showOptions = !showOptions
|
||||||
|
togglePopover()
|
||||||
|
}
|
||||||
|
"
|
||||||
:disabled="attrs.readonly"
|
:disabled="attrs.readonly"
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center w-[90%]">
|
||||||
<slot name="prefix" />
|
<slot name="prefix" />
|
||||||
<span
|
<span
|
||||||
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
|
class="block truncate text-base leading-5"
|
||||||
v-if="selectedValue"
|
v-if="selectedValue"
|
||||||
>
|
>
|
||||||
{{ displayValue(selectedValue) }}
|
{{ displayValue(selectedValue) }}
|
||||||
@@ -99,18 +104,17 @@
|
|||||||
name="item-label"
|
name="item-label"
|
||||||
v-bind="{ active, selected, option }"
|
v-bind="{ active, selected, option }"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col space-y-1 text-ink-gray-8">
|
<div class="flex flex-col gap-1 p-1">
|
||||||
<div>
|
<div class="text-base font-medium text-ink-gray-8">
|
||||||
{{ option.label }}
|
{{
|
||||||
|
option.value == option.label && option.description
|
||||||
|
? option.description
|
||||||
|
: option.label
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-ink-gray-5">
|
||||||
|
{{ option.value }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
option.description &&
|
|
||||||
option.description != option.label
|
|
||||||
"
|
|
||||||
class="text-xs text-ink-gray-7"
|
|
||||||
v-html="option.description"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
</slot>
|
</slot>
|
||||||
</li>
|
</li>
|
||||||
@@ -120,7 +124,7 @@
|
|||||||
v-if="groups.length == 0"
|
v-if="groups.length == 0"
|
||||||
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
|
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
|
||||||
>
|
>
|
||||||
No results found
|
{{ __('No results found') }}
|
||||||
</li>
|
</li>
|
||||||
</ComboboxOptions>
|
</ComboboxOptions>
|
||||||
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
|
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
|
||||||
@@ -284,7 +288,7 @@ const inputClasses = computed(() => {
|
|||||||
let variant = props.disabled ? 'disabled' : props.variant
|
let variant = props.disabled ? 'disabled' : props.variant
|
||||||
let variantClasses = {
|
let variantClasses = {
|
||||||
subtle:
|
subtle:
|
||||||
'border border-gray-100 bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
|
'border border-outline-gray-modals bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
|
||||||
outline:
|
outline:
|
||||||
'border border-outline-gray-2 bg-surface-white placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
|
'border border-outline-gray-2 bg-surface-white placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
|
||||||
disabled: [
|
disabled: [
|
||||||
|
|||||||
@@ -3,59 +3,67 @@
|
|||||||
<div class="text-xs text-ink-gray-5 mb-2">
|
<div class="text-xs text-ink-gray-5 mb-2">
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto border rounded-md">
|
<div class="overflow-visible border border-outline-gray-modals rounded-md">
|
||||||
<div
|
<div class="overflow-x-auto">
|
||||||
class="grid items-center space-x-4 p-2 border-b"
|
|
||||||
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-for="(column, index) in columns"
|
class="grid items-center space-x-4 p-2 border-b border-outline-gray-modals"
|
||||||
:key="index"
|
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||||
class="text-sm text-ink-gray-5"
|
|
||||||
>
|
>
|
||||||
{{ column }}
|
|
||||||
</div>
|
|
||||||
<div></div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="(row, rowIndex) in rows"
|
|
||||||
:key="rowIndex"
|
|
||||||
class="grid items-center space-x-4 p-2"
|
|
||||||
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
|
||||||
>
|
|
||||||
<template v-for="key in Object.keys(row)" :key="key">
|
|
||||||
<input
|
|
||||||
v-if="showKey(key)"
|
|
||||||
v-model="row[key]"
|
|
||||||
class="py-1.5 px-2 border-none focus:ring-0 focus:border focus:border-gray-300 focus:bg-surface-gray-2 rounded-sm text-sm focus:outline-none"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="relative" ref="menuRef">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
@click="(event: MouseEvent) => toggleMenu(rowIndex, event)"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<Ellipsis
|
|
||||||
class="size-4 text-ink-gray-7 stroke-1.5 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="menuOpenIndex === rowIndex"
|
v-for="(column, index) in columns"
|
||||||
class="absolute right-[30px] top-5 mt-1 w-32 bg-surface-white border border-outline-gray-1 rounded-md shadow-sm"
|
:key="index"
|
||||||
|
class="text-sm text-ink-gray-5"
|
||||||
>
|
>
|
||||||
<button
|
{{ column }}
|
||||||
@click="deleteRow(rowIndex)"
|
</div>
|
||||||
class="flex items-center space-x-2 w-full text-left px-3 py-2 text-sm text-ink-red-3"
|
<div></div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(row, rowIndex) in rows"
|
||||||
|
:key="rowIndex"
|
||||||
|
class="grid items-center space-x-4 p-2"
|
||||||
|
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||||
|
>
|
||||||
|
<template v-for="key in Object.keys(row)" :key="key">
|
||||||
|
<input
|
||||||
|
v-if="showKey(key)"
|
||||||
|
v-model="row[key]"
|
||||||
|
class="py-1.5 px-2 w-full border-none bg-transparent text-ink-gray-8 focus:ring-0 focus:border focus:border-outline-gray-3 focus:bg-surface-gray-2 rounded-md text-sm focus:outline-none"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="(event: MouseEvent) => toggleMenu(rowIndex, event)"
|
||||||
>
|
>
|
||||||
<Trash2 class="size-4 stroke-1.5" />
|
<template #icon>
|
||||||
<span>
|
<Ellipsis
|
||||||
{{ __('Delete') }}
|
class="size-4 text-ink-gray-7 stroke-1.5 cursor-pointer"
|
||||||
</span>
|
/>
|
||||||
</button>
|
</template>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="menuOpenIndex === rowIndex"
|
||||||
|
ref="menuRef"
|
||||||
|
class="absolute right-0 w-32 z-50 bg-surface-modal border border-outline-gray-modals rounded-md shadow-sm"
|
||||||
|
:class="
|
||||||
|
rowIndex == (rows?.length ?? 0) - 1
|
||||||
|
? 'bottom-full mb-1'
|
||||||
|
: 'top-full mt-1'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="deleteRow(rowIndex)"
|
||||||
|
class="flex items-center space-x-2 w-full text-left px-3 py-2 text-sm text-ink-red-3"
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ __('Delete') }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,17 +81,19 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { nextTick, ref, watch } from 'vue'
|
||||||
import { Button } from 'frappe-ui'
|
import { Button } from 'frappe-ui'
|
||||||
import { Ellipsis, Plus, Trash2 } from 'lucide-vue-next'
|
import { Ellipsis, Plus, Trash2 } from 'lucide-vue-next'
|
||||||
import { onClickOutside } from '@vueuse/core'
|
import { onClickOutside } from '@vueuse/core'
|
||||||
|
|
||||||
const rows = defineModel<Cell[][]>()
|
const rows = defineModel<Record<string, string>[]>()
|
||||||
const menuRef = ref(null)
|
const menuRef = ref(null)
|
||||||
const menuOpenIndex = ref<number | null>(null)
|
const menuOpenIndex = ref<number | null>(null)
|
||||||
const menuTopPosition = ref<string>('')
|
const menuTopPosition = ref<string>('')
|
||||||
|
const menuLeftPosition = ref('0px')
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', value: Cell[][]): void
|
(e: 'update:modelValue', value: Record<string, string>[]): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
type Cell = {
|
type Cell = {
|
||||||
@@ -93,19 +103,19 @@ type Cell = {
|
|||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
modelValue?: Cell[][]
|
modelValue?: Record<string, string>[]
|
||||||
columns?: string[]
|
columns?: string[]
|
||||||
label?: string
|
label?: string
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
columns: [],
|
columns: () => [] as string[],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const columns = ref(props.columns)
|
const columns = ref(props.columns)
|
||||||
|
|
||||||
watch(rows, () => {
|
watch(rows, () => {
|
||||||
if (rows.value?.length < 1) {
|
if (rows.value && rows.value.length < 1) {
|
||||||
addRow()
|
addRow()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -119,12 +129,25 @@ const addRow = () => {
|
|||||||
newRow[column.toLowerCase().split(' ').join('_')] = ''
|
newRow[column.toLowerCase().split(' ').join('_')] = ''
|
||||||
})
|
})
|
||||||
rows.value.push(newRow)
|
rows.value.push(newRow)
|
||||||
|
focusNewRowInput()
|
||||||
emit('update:modelValue', rows.value)
|
emit('update:modelValue', rows.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const focusNewRowInput = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
const rowElements = document.querySelectorAll('.overflow-x-auto .grid')[
|
||||||
|
rows.value!.length
|
||||||
|
]
|
||||||
|
const firstInput = rowElements.querySelector('input')
|
||||||
|
if (firstInput) {
|
||||||
|
;(firstInput as HTMLInputElement).focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const deleteRow = (index: number) => {
|
const deleteRow = (index: number) => {
|
||||||
rows.value.splice(index, 1)
|
rows.value?.splice(index, 1)
|
||||||
emit('update:modelValue', rows.value)
|
emit('update:modelValue', rows.value ?? [])
|
||||||
}
|
}
|
||||||
|
|
||||||
const getGridTemplateColumns = () => {
|
const getGridTemplateColumns = () => {
|
||||||
@@ -133,7 +156,6 @@ const getGridTemplateColumns = () => {
|
|||||||
|
|
||||||
const toggleMenu = (index: number, event: MouseEvent) => {
|
const toggleMenu = (index: number, event: MouseEvent) => {
|
||||||
menuOpenIndex.value = menuOpenIndex.value === index ? null : index
|
menuOpenIndex.value = menuOpenIndex.value === index ? null : index
|
||||||
menuTopPosition.value = `${event.clientY + 10}px`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onClickOutside(menuRef, () => {
|
onClickOutside(menuRef, () => {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ async function setLanguageExtension() {
|
|||||||
if (!languageImport) return
|
if (!languageImport) return
|
||||||
|
|
||||||
const module = await languageImport()
|
const module = await languageImport()
|
||||||
languageExtension.value = (module as any)[props.language]()
|
languageExtension.value = (module as any)[props.language]?.()
|
||||||
|
|
||||||
if (props.completions) {
|
if (props.completions) {
|
||||||
const languageData = (module as any)[`${props.language}Language`]
|
const languageData = (module as any)[`${props.language}Language`]
|
||||||
|
|||||||
@@ -21,8 +21,10 @@
|
|||||||
:style="
|
:style="
|
||||||
modelValue
|
modelValue
|
||||||
? {
|
? {
|
||||||
backgroundColor:
|
backgroundColor: getColor(
|
||||||
theme.backgroundColor[modelValue.toLowerCase()][400],
|
modelValue.toLowerCase(),
|
||||||
|
400
|
||||||
|
),
|
||||||
}
|
}
|
||||||
: {}
|
: {}
|
||||||
"
|
"
|
||||||
@@ -55,8 +57,7 @@
|
|||||||
:key="color"
|
:key="color"
|
||||||
class="size-5 rounded-full cursor-pointer"
|
class="size-5 rounded-full cursor-pointer"
|
||||||
:style="{
|
:style="{
|
||||||
backgroundColor:
|
backgroundColor: getColor(color.toLowerCase(), 400),
|
||||||
theme.backgroundColor[color.toLowerCase()][400],
|
|
||||||
}"
|
}"
|
||||||
@click="
|
@click="
|
||||||
(e) => {
|
(e) => {
|
||||||
@@ -79,7 +80,7 @@
|
|||||||
import { Button, FormControl, Popover } from 'frappe-ui'
|
import { Button, FormControl, Popover } from 'frappe-ui'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { Palette, X } from 'lucide-vue-next'
|
import { Palette, X } from 'lucide-vue-next'
|
||||||
import { theme } from '@/utils/theme'
|
import { getColor } from '@/utils'
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'change'])
|
const emit = defineEmits(['update:modelValue', 'change'])
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
class="w-4 h-4 text-ink-gray-7 stroke-1.5"
|
class="w-4 h-4 text-ink-gray-7 stroke-1.5"
|
||||||
:is="icons.Folder"
|
:is="icons.Folder"
|
||||||
/>
|
/>
|
||||||
<span v-if="selectedIcon">
|
<span v-if="selectedIcon" class="text-ink-gray-7">
|
||||||
{{ selectedIcon }}
|
{{ selectedIcon }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-ink-gray-5">
|
<span v-else class="text-ink-gray-5">
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
:size="attrs.size || 'sm'"
|
:size="attrs.size || 'sm'"
|
||||||
:variant="attrs.variant"
|
:variant="attrs.variant"
|
||||||
:placeholder="attrs.placeholder"
|
:placeholder="attrs.placeholder"
|
||||||
:filterable="false"
|
|
||||||
:readonly="attrs.readonly"
|
:readonly="attrs.readonly"
|
||||||
>
|
>
|
||||||
<template #target="{ open, togglePopover }">
|
<template #target="{ open, togglePopover }">
|
||||||
@@ -67,6 +66,7 @@ import { watchDebounced } from '@vueuse/core'
|
|||||||
import { createResource, Button } from 'frappe-ui'
|
import { createResource, Button } from 'frappe-ui'
|
||||||
import { Plus, X } from 'lucide-vue-next'
|
import { Plus, X } from 'lucide-vue-next'
|
||||||
import { useAttrs, computed, ref } from 'vue'
|
import { useAttrs, computed, ref } from 'vue'
|
||||||
|
import { useSettings } from '@/stores/settings'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
doctype: {
|
doctype: {
|
||||||
@@ -96,13 +96,14 @@ const value = computed({
|
|||||||
set: (val) => {
|
set: (val) => {
|
||||||
return (
|
return (
|
||||||
val?.value &&
|
val?.value &&
|
||||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val?.value)
|
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val.value)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const autocomplete = ref(null)
|
const autocomplete = ref(null)
|
||||||
const text = ref('')
|
const text = ref('')
|
||||||
|
const settingsStore = useSettings()
|
||||||
|
|
||||||
watchDebounced(
|
watchDebounced(
|
||||||
() => autocomplete.value?.query,
|
() => autocomplete.value?.query,
|
||||||
@@ -121,6 +122,16 @@ watchDebounced(
|
|||||||
{ debounce: 300, immediate: true }
|
{ debounce: 300, immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watchDebounced(
|
||||||
|
() => settingsStore.isSettingsOpen,
|
||||||
|
(isOpen, wasOpen) => {
|
||||||
|
if (wasOpen && !isOpen) {
|
||||||
|
reload('')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ debounce: 200 }
|
||||||
|
)
|
||||||
|
|
||||||
const options = createResource({
|
const options = createResource({
|
||||||
url: 'frappe.desk.search.search_link',
|
url: 'frappe.desk.search.search_link',
|
||||||
cache: [props.doctype, text.value],
|
cache: [props.doctype, text.value],
|
||||||
|
|||||||
@@ -1,160 +1,145 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<label class="block mb-1" :class="labelClasses" v-if="label">
|
<label v-if="label" class="block mb-1" :class="labelClasses">
|
||||||
{{ label }}
|
{{ label }}
|
||||||
<span class="text-ink-red-3" v-if="required">*</span>
|
<span v-if="required" class="text-ink-red-3">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="w-full">
|
<Combobox v-model="selectedValue" nullable v-slot="{ open }">
|
||||||
<Combobox v-model="selectedValue" nullable>
|
<div class="relative w-full">
|
||||||
<Popover class="w-full" v-model:show="showOptions">
|
<ComboboxInput
|
||||||
<template #target="{ togglePopover }">
|
ref="search"
|
||||||
<ComboboxInput
|
class="form-input w-full focus-visible:!ring-0"
|
||||||
ref="search"
|
type="text"
|
||||||
class="search-input form-input w-full focus-visible:!ring-0"
|
@change="
|
||||||
type="text"
|
(e) => {
|
||||||
:value="query"
|
query = e.target.value
|
||||||
@change="
|
}
|
||||||
(e) => {
|
"
|
||||||
query = e.target.value
|
autocomplete="off"
|
||||||
showOptions = true
|
@focus="onFocus"
|
||||||
}
|
/>
|
||||||
"
|
<ComboboxButton ref="trigger" class="hidden" />
|
||||||
autocomplete="off"
|
<ComboboxOptions
|
||||||
@focus="() => togglePopover()"
|
v-show="open"
|
||||||
@keydown.delete.capture.stop="removeLastValue"
|
static
|
||||||
/>
|
class="absolute z-20 mt-1 w-full rounded-lg bg-surface-modal border-2 border-outline-gray-modals max-h-[13rem] flex flex-col"
|
||||||
</template>
|
>
|
||||||
<template #body="{ isOpen, close }">
|
<div
|
||||||
<div v-show="isOpen">
|
class="flex-1 my-1 overflow-y-auto px-1.5"
|
||||||
<div
|
:class="options.length ? 'min-h-[6rem]' : 'min-h-[3.8rem]'"
|
||||||
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
|
>
|
||||||
|
<template v-if="options.length">
|
||||||
|
<ComboboxOption
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option"
|
||||||
|
v-slot="{ active }"
|
||||||
>
|
>
|
||||||
<ComboboxOptions
|
<li
|
||||||
class="my-1 min-h-[6rem] max-h-[12rem] overflow-y-auto px-1.5"
|
:class="[
|
||||||
static
|
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
|
||||||
|
{ 'bg-surface-gray-2': active },
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<ComboboxOption
|
<div class="flex flex-col gap-1 p-1">
|
||||||
v-for="option in options"
|
<div class="text-base font-medium text-ink-gray-8">
|
||||||
:key="option.value"
|
{{
|
||||||
:value="option"
|
option.value === option.label
|
||||||
v-slot="{ active }"
|
? option.description
|
||||||
>
|
: option.label
|
||||||
<li
|
}}
|
||||||
:class="[
|
</div>
|
||||||
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
|
<div class="text-sm text-ink-gray-5">
|
||||||
{ 'bg-surface-gray-2': active },
|
{{ option.value }}
|
||||||
]"
|
</div>
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-1 p-1">
|
|
||||||
<div class="text-base font-medium text-ink-gray-8">
|
|
||||||
{{ option.description }}
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-ink-gray-5">
|
|
||||||
{{ option.value }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ComboboxOption>
|
|
||||||
<div class="h-10"></div>
|
|
||||||
<div
|
|
||||||
v-if="attrs.onCreate"
|
|
||||||
class="absolute bottom-2 left-1 w-[99%] pt-2 bg-white border-t"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="w-full !justify-start"
|
|
||||||
:label="__('Create New')"
|
|
||||||
@click="attrs.onCreate(close)"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
|
||||||
<Plus class="h-4 w-4 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</ComboboxOptions>
|
</li>
|
||||||
</div>
|
</ComboboxOption>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="text-ink-gray-7 px-4 py-2">
|
||||||
|
{{ __('No results found') }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</Popover>
|
|
||||||
</Combobox>
|
<div
|
||||||
</div>
|
v-if="attrs.onCreate"
|
||||||
<div v-if="values.length" class="grid grid-cols-2 gap-2 mt-1">
|
class="p-1 bg-surface-white border-t rounded-b-lg"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="w-full !justify-start"
|
||||||
|
:label="__('Create New')"
|
||||||
|
@click="attrs.onCreate()"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ComboboxOptions>
|
||||||
|
</div>
|
||||||
|
</Combobox>
|
||||||
|
|
||||||
|
<!-- Selected values -->
|
||||||
|
<div v-if="values?.length" class="grid grid-cols-2 gap-2 mt-1">
|
||||||
<div
|
<div
|
||||||
v-for="value in values"
|
v-for="value in values"
|
||||||
class="flex items-center justify-between break-all bg-surface-gray-2 text-ink-gray-7 word-wrap p-2 rounded-md mr-2"
|
:key="value"
|
||||||
|
class="flex items-center justify-between break-all bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md"
|
||||||
>
|
>
|
||||||
<span class="break-all">
|
<span>{{ value }}</span>
|
||||||
{{ value }}
|
|
||||||
</span>
|
|
||||||
<X
|
<X
|
||||||
class="size-4 stroke-1.5 cursor-pointer"
|
class="size-4 stroke-1.5 cursor-pointer"
|
||||||
@click="removeValue(value)"
|
@click="removeValue(value)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
Combobox,
|
Combobox,
|
||||||
|
ComboboxButton,
|
||||||
ComboboxInput,
|
ComboboxInput,
|
||||||
ComboboxOptions,
|
ComboboxOptions,
|
||||||
ComboboxOption,
|
ComboboxOption,
|
||||||
} from '@headlessui/vue'
|
} from '@headlessui/vue'
|
||||||
import { createResource, Popover, Button } from 'frappe-ui'
|
import { createResource, Button } from 'frappe-ui'
|
||||||
import { ref, computed, nextTick, useAttrs } from 'vue'
|
import { ref, computed, useAttrs, watch } from 'vue'
|
||||||
import { watchDebounced } from '@vueuse/core'
|
import { watchDebounced } from '@vueuse/core'
|
||||||
import { X, Plus } from 'lucide-vue-next'
|
import { X, Plus } from 'lucide-vue-next'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
label: {
|
label: String,
|
||||||
type: String,
|
size: { type: String, default: 'sm' },
|
||||||
},
|
doctype: { type: String, required: true },
|
||||||
size: {
|
filters: { type: Object, default: () => ({}) },
|
||||||
type: String,
|
validate: Function,
|
||||||
default: 'sm',
|
|
||||||
},
|
|
||||||
doctype: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
filters: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
validate: {
|
|
||||||
type: Function,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
errorMessage: {
|
errorMessage: {
|
||||||
type: Function,
|
type: Function,
|
||||||
default: (value) => `${value} is an Invalid value`,
|
default: (value) => `${value} is an Invalid value`,
|
||||||
},
|
},
|
||||||
required: {
|
required: Boolean,
|
||||||
type: Boolean,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const values = defineModel()
|
const values = defineModel()
|
||||||
const attrs = useAttrs()
|
const attrs = useAttrs()
|
||||||
const emails = ref([])
|
const trigger = ref(null)
|
||||||
const search = ref(null)
|
|
||||||
const error = ref(null)
|
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const text = ref('')
|
const text = ref('')
|
||||||
const showOptions = ref(false)
|
const selectedValue = ref(null)
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
const selectedValue = computed({
|
const emit = defineEmits(['update:modelValue'])
|
||||||
get: () => query.value || '',
|
|
||||||
set: (val) => {
|
watch(selectedValue, (val) => {
|
||||||
query.value = ''
|
if (!val?.value) return
|
||||||
if (val) {
|
query.value = ''
|
||||||
showOptions.value = false
|
addValue(val.value)
|
||||||
}
|
selectedValue.value = null
|
||||||
val?.value && addValue(val.value)
|
emit('update:modelValue', values.value)
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watchDebounced(
|
watchDebounced(
|
||||||
@@ -171,7 +156,6 @@ watchDebounced(
|
|||||||
const filterOptions = createResource({
|
const filterOptions = createResource({
|
||||||
url: 'frappe.desk.search.search_link',
|
url: 'frappe.desk.search.search_link',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
cache: [text.value, props.doctype],
|
|
||||||
auto: true,
|
auto: true,
|
||||||
params: {
|
params: {
|
||||||
txt: text.value,
|
txt: text.value,
|
||||||
@@ -180,7 +164,8 @@ const filterOptions = createResource({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const options = computed(() => {
|
const options = computed(() => {
|
||||||
return filterOptions.data || []
|
const allOptions = filterOptions.data || []
|
||||||
|
return allOptions.filter((option) => !values.value?.includes(option.value))
|
||||||
})
|
})
|
||||||
|
|
||||||
function reload(val) {
|
function reload(val) {
|
||||||
@@ -193,70 +178,46 @@ function reload(val) {
|
|||||||
filterOptions.reload()
|
filterOptions.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
const addValue = (value) => {
|
function onFocus() {
|
||||||
|
if (!filterOptions.data?.length) {
|
||||||
|
reload('')
|
||||||
|
}
|
||||||
|
trigger.value?.$el.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function addValue(value) {
|
||||||
error.value = null
|
error.value = null
|
||||||
if (value) {
|
|
||||||
const splitValues = value.split(',')
|
if (!value) return
|
||||||
splitValues.forEach((value) => {
|
|
||||||
value = value.trim()
|
const splitValues = value.split(',')
|
||||||
if (value) {
|
|
||||||
// check if value is not already in the values array
|
splitValues.forEach((val) => {
|
||||||
if (!values.value?.includes(value)) {
|
val = val.trim()
|
||||||
// check if value is valid
|
|
||||||
if (value && props.validate && !props.validate(value)) {
|
if (!val) return
|
||||||
error.value = props.errorMessage(value)
|
if (values.value?.includes(val)) return
|
||||||
return
|
|
||||||
}
|
if (props.validate && !props.validate(val)) {
|
||||||
// add value to values array
|
error.value = props.errorMessage(val)
|
||||||
if (!values.value) {
|
return
|
||||||
values.value = [value]
|
}
|
||||||
} else {
|
|
||||||
values.value.push(value)
|
if (!values.value) values.value = [val]
|
||||||
}
|
else values.value.push(val)
|
||||||
value = value.replace(value, '')
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
function removeValue(value) {
|
||||||
!error.value && (value = '')
|
let indexToRemove = values.value.indexOf(value)
|
||||||
|
if (indexToRemove > -1) {
|
||||||
|
values.value.splice(indexToRemove, 1)
|
||||||
}
|
}
|
||||||
|
emit('update:modelValue', values.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeValue = (value) => {
|
const labelClasses = computed(() => [
|
||||||
values.value = values.value.filter((v) => v !== value)
|
{ sm: 'text-xs', md: 'text-base' }[props.size || 'sm'],
|
||||||
}
|
'text-ink-gray-5',
|
||||||
|
])
|
||||||
const removeLastValue = () => {
|
|
||||||
if (query.value) return
|
|
||||||
|
|
||||||
let emailRef = emails.value[emails.value.length - 1]?.$el
|
|
||||||
if (document.activeElement === emailRef) {
|
|
||||||
values.value.pop()
|
|
||||||
nextTick(() => {
|
|
||||||
if (values.value.length) {
|
|
||||||
emailRef = emails.value[emails.value.length - 1].$el
|
|
||||||
emailRef?.focus()
|
|
||||||
} else {
|
|
||||||
setFocus()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
emailRef?.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setFocus() {
|
|
||||||
search.value.$el.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({ setFocus })
|
|
||||||
|
|
||||||
const labelClasses = computed(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
sm: 'text-xs',
|
|
||||||
md: 'text-base',
|
|
||||||
}[props.size || 'sm'],
|
|
||||||
'text-ink-gray-5',
|
|
||||||
]
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,18 +2,21 @@
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div v-if="label" class="text-xs text-ink-gray-5 mb-2">
|
<div v-if="label" class="text-xs text-ink-gray-5 mb-2">
|
||||||
{{ __(label) }}
|
{{ __(label) }}
|
||||||
<span class="text-ink-red-3">*</span>
|
<span v-if="required" class="text-ink-red-3">*</span>
|
||||||
</div>
|
</div>
|
||||||
<FileUploader
|
<FileUploader
|
||||||
v-if="!modelValue"
|
v-if="!modelValue"
|
||||||
:fileTypes="['image/*']"
|
:fileTypes="[fileType]"
|
||||||
:validateFile="validateFile"
|
:validateFile="(file: File) => validateFile(file, true, type)"
|
||||||
@success="(file: File) => saveImage(file)"
|
@success="(file: File) => saveFile(file)"
|
||||||
>
|
>
|
||||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="border rounded-md w-fit py-7 px-20">
|
<div class="border rounded-md w-fit py-7 px-20">
|
||||||
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
<component
|
||||||
|
:is="props.type === 'image' ? Image : Video"
|
||||||
|
class="size-5 stroke-1 text-ink-gray-7"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<Button @click="openFileSelector">
|
<Button @click="openFileSelector">
|
||||||
@@ -28,7 +31,20 @@
|
|||||||
</FileUploader>
|
</FileUploader>
|
||||||
<div v-else class="mb-4">
|
<div v-else class="mb-4">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<img :src="modelValue" class="border rounded-md w-44 h-auto" />
|
<img
|
||||||
|
v-if="type == 'image'"
|
||||||
|
:src="modelValue"
|
||||||
|
:class="[
|
||||||
|
'border object-cover',
|
||||||
|
shape === 'circle'
|
||||||
|
? 'w-20 h-20 rounded-full'
|
||||||
|
: 'w-44 h-auto min-h-20 max-h-32 rounded-md',
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<video v-else controls class="border rounded-md w-44 h-auto">
|
||||||
|
<source :src="modelValue" />
|
||||||
|
{{ __('Your browser does not support the video tag.') }}
|
||||||
|
</video>
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<Button @click="removeImage()">
|
<Button @click="removeImage()">
|
||||||
{{ __('Remove') }}
|
{{ __('Remove') }}
|
||||||
@@ -47,7 +63,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { validateFile } from '@/utils'
|
import { validateFile } from '@/utils'
|
||||||
import { Button, FileUploader } from 'frappe-ui'
|
import { Button, FileUploader } from 'frappe-ui'
|
||||||
import { Image } from 'lucide-vue-next'
|
import { Image, Video } from 'lucide-vue-next'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', value: string): void
|
(e: 'update:modelValue', value: string): void
|
||||||
@@ -55,18 +72,28 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
modelValue: string
|
modelValue: string | null
|
||||||
label?: string
|
label?: string
|
||||||
description?: string
|
description?: string
|
||||||
|
type?: 'image' | 'video'
|
||||||
|
required?: boolean
|
||||||
|
shape?: 'square' | 'circle'
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
modelValue: '',
|
modelValue: '',
|
||||||
label: '',
|
label: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
type: 'image',
|
||||||
|
required: true,
|
||||||
|
shape: 'square',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const saveImage = (file: any) => {
|
const fileType = computed(() => {
|
||||||
|
return props.type === 'image' ? 'image/*' : 'video/*'
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveFile = (file: any) => {
|
||||||
emit('update:modelValue', file.file_url)
|
emit('update:modelValue', file.file_url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -136,11 +136,11 @@
|
|||||||
import { Award, BookOpen, GraduationCap, Star, Users } from 'lucide-vue-next'
|
import { Award, BookOpen, GraduationCap, Star, Users } from 'lucide-vue-next'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { Tooltip } from 'frappe-ui'
|
import { Tooltip } from 'frappe-ui'
|
||||||
import { theme } from '@/utils/theme'
|
|
||||||
import { formatAmount } from '@/utils'
|
import { formatAmount } from '@/utils'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import ProgressBar from '@/components/ProgressBar.vue'
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
|
import colors from '@/utils/frappe-ui-colors.json'
|
||||||
|
|
||||||
const { user } = sessionStore()
|
const { user } = sessionStore()
|
||||||
|
|
||||||
@@ -152,19 +152,10 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const getGradientColor = () => {
|
const getGradientColor = () => {
|
||||||
|
let theme = localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
|
||||||
let color = props.course.card_gradient?.toLowerCase() || 'blue'
|
let color = props.course.card_gradient?.toLowerCase() || 'blue'
|
||||||
let colorMap = theme.backgroundColor[color]
|
let colorMap = colors[theme][color]
|
||||||
return `linear-gradient(to top right, black, ${colorMap[400]})`
|
return `linear-gradient(to top right, black, ${colorMap[400]})`
|
||||||
/* return `bg-gradient-to-br from-${color}-100 via-${color}-200 to-${color}-400` */
|
|
||||||
/* return `linear-gradient(to bottom right, ${colorMap[100]}, ${colorMap[400]})` */
|
|
||||||
/* return `radial-gradient(ellipse at 80% 20%, black 20%, ${colorMap[500]} 100%)` */
|
|
||||||
/* return `radial-gradient(ellipse at 30% 70%, black 50%, ${colorMap[500]} 100%)` */
|
|
||||||
/* return `radial-gradient(ellipse at 80% 20%, ${colorMap[100]} 0%, ${colorMap[300]} 50%, ${colorMap[500]} 100%)` */
|
|
||||||
/* return `conic-gradient(from 180deg at 50% 50%, ${colorMap[100]} 0%, ${colorMap[200]} 50%, ${colorMap[400]} 100%)` */
|
|
||||||
/* return `linear-gradient(135deg, ${colorMap[100]}, ${colorMap[300]}), linear-gradient(120deg, rgba(255,255,255,0.4) 0%, transparent 60%) ` */
|
|
||||||
/* return `radial-gradient(circle at 20% 30%, ${colorMap[100]} 0%, transparent 40%),
|
|
||||||
radial-gradient(circle at 80% 40%, ${colorMap[200]} 0%, transparent 50%),
|
|
||||||
linear-gradient(135deg, ${colorMap[300]} 0%, ${colorMap[400]} 100%);` */
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<CertificationLinks :courseName="course.data.name" class="w-full" />
|
<CertificationLinks :courseName="course.data.name" class="w-full" />
|
||||||
</div>
|
</div>
|
||||||
<router-link
|
<router-link
|
||||||
v-else-if="course.data.paid_course"
|
v-else-if="course.data.paid_course && !isAdmin"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'Billing',
|
name: 'Billing',
|
||||||
params: {
|
params: {
|
||||||
@@ -56,14 +56,15 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<Badge
|
<Badge
|
||||||
v-else-if="course.data.disable_self_learning"
|
v-else-if="course.data.disable_self_learning && !isAdmin"
|
||||||
theme="blue"
|
theme="blue"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
class="mb-4"
|
||||||
>
|
>
|
||||||
{{ __('Contact the Administrator to enroll for this course.') }}
|
{{ __('Contact the Administrator to enroll for this course') }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
v-else-if="!user.data?.is_moderator && !is_instructor()"
|
v-else-if="!isAdmin"
|
||||||
@click="enrollStudent()"
|
@click="enrollStudent()"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
@@ -88,35 +89,6 @@
|
|||||||
</template>
|
</template>
|
||||||
{{ __('Get Certificate') }}
|
{{ __('Get Certificate') }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
v-if="user.data?.is_moderator || is_instructor()"
|
|
||||||
class="w-full mt-2"
|
|
||||||
size="md"
|
|
||||||
@click="showProgressSummary"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
|
||||||
<TrendingUp class="size-4 stroke-1.5" />
|
|
||||||
{{ __('Progress Summary') }}
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
<router-link
|
|
||||||
v-if="user?.data?.is_moderator || is_instructor()"
|
|
||||||
:to="{
|
|
||||||
name: 'CourseForm',
|
|
||||||
params: {
|
|
||||||
courseName: course.data.name,
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button variant="subtle" class="w-full mt-2" size="md">
|
|
||||||
<template #prefix>
|
|
||||||
<Pencil class="size-4 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
<span>
|
|
||||||
{{ __('Edit') }}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div
|
<div
|
||||||
@@ -168,12 +140,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CourseProgressSummary
|
|
||||||
v-if="user.data?.is_moderator || is_instructor()"
|
|
||||||
v-model="showProgressModal"
|
|
||||||
:courseName="course.data.name"
|
|
||||||
:enrollments="course.data.enrollments"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
@@ -189,15 +155,14 @@ import {
|
|||||||
import { computed, inject, ref } from 'vue'
|
import { computed, inject, ref } from 'vue'
|
||||||
import { Badge, Button, call, 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 { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||||
import CourseProgressSummary from '@/components/Modals/CourseProgressSummary.vue'
|
import { useTelemetry } from 'frappe-ui/frappe'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const showProgressModal = ref(false)
|
|
||||||
const readOnlyMode = window.read_only_mode
|
const readOnlyMode = window.read_only_mode
|
||||||
|
const { capture } = useTelemetry()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
course: {
|
course: {
|
||||||
@@ -215,13 +180,17 @@ const video_link = computed(() => {
|
|||||||
|
|
||||||
function enrollStudent() {
|
function enrollStudent() {
|
||||||
if (!user.data) {
|
if (!user.data) {
|
||||||
toast.success(__('You need to login first to enroll for this course'))
|
toast.warning(__('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}`
|
||||||
}, 500)
|
}, 500)
|
||||||
} else {
|
} else {
|
||||||
call('lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', {
|
call('frappe.client.insert', {
|
||||||
course: props.course.data.name,
|
doc: {
|
||||||
|
doctype: 'LMS Enrollment',
|
||||||
|
course: props.course.data.name,
|
||||||
|
member: user.data.name,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
capture('enrolled_in_course', {
|
capture('enrolled_in_course', {
|
||||||
@@ -290,7 +259,7 @@ const fetchCertificate = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const showProgressSummary = () => {
|
const isAdmin = computed(() => {
|
||||||
showProgressModal.value = true
|
return user.data?.is_moderator || is_instructor()
|
||||||
}
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -15,7 +15,10 @@
|
|||||||
{{ __(title) }}
|
{{ __(title) }}
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
|
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
|
||||||
{{ __('Add Chapter') }}
|
<template #prefix>
|
||||||
|
<Plus class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Add') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -95,8 +98,8 @@
|
|||||||
name: allowEdit ? 'LessonForm' : 'Lesson',
|
name: allowEdit ? 'LessonForm' : 'Lesson',
|
||||||
params: {
|
params: {
|
||||||
courseName: courseName,
|
courseName: courseName,
|
||||||
chapterNumber: lesson.number.split('.')[0],
|
chapterNumber: lesson.number.split('-')[0],
|
||||||
lessonNumber: lesson.number.split('.')[1],
|
lessonNumber: lesson.number.split('-')[1],
|
||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
@@ -109,6 +112,14 @@
|
|||||||
v-else-if="lesson.icon === 'icon-quiz'"
|
v-else-if="lesson.icon === 'icon-quiz'"
|
||||||
class="h-4 w-4 stroke-1 mr-2"
|
class="h-4 w-4 stroke-1 mr-2"
|
||||||
/>
|
/>
|
||||||
|
<NotebookPen
|
||||||
|
v-else-if="lesson.icon === 'icon-assignment'"
|
||||||
|
class="h-4 w-4 stroke-1 mr-2"
|
||||||
|
/>
|
||||||
|
<SquareCode
|
||||||
|
v-else-if="lesson.icon === 'icon-code'"
|
||||||
|
class="h-4 w-4 stroke-1 mr-2"
|
||||||
|
/>
|
||||||
<FileText
|
<FileText
|
||||||
v-else-if="lesson.icon === 'icon-list'"
|
v-else-if="lesson.icon === 'icon-list'"
|
||||||
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
|
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
|
||||||
@@ -174,7 +185,11 @@ import {
|
|||||||
FilePenLine,
|
FilePenLine,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
MonitorPlay,
|
MonitorPlay,
|
||||||
|
NotebookPen,
|
||||||
|
Plus,
|
||||||
|
SquareCode,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Notebook,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
||||||
@@ -389,8 +404,8 @@ const redirectToChapter = (chapter) => {
|
|||||||
|
|
||||||
const isActiveLesson = (lessonNumber) => {
|
const isActiveLesson = (lessonNumber) => {
|
||||||
return (
|
return (
|
||||||
route.params.chapterNumber == lessonNumber.split('.')[0] &&
|
route.params.chapterNumber == lessonNumber.split('-')[0] &&
|
||||||
route.params.lessonNumber == lessonNumber.split('.')[1]
|
route.params.lessonNumber == lessonNumber.split('-')[1]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid gap-8 mt-10">
|
<div class="grid gap-8 mt-10">
|
||||||
<div v-for="(review, index) in reviews.data">
|
<div v-for="(review, index) in reviews.data">
|
||||||
<div class="flex items-center">
|
<div class="flex">
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'Profile',
|
name: 'Profile',
|
||||||
@@ -46,11 +46,11 @@
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7">
|
||||||
|
{{ review.review }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7">
|
|
||||||
{{ review.review }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,7 +80,7 @@ const props = defineProps({
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
membership: {
|
membership: {
|
||||||
type: Object,
|
type: Object || null,
|
||||||
required: false,
|
required: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,5 +9,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import AppSidebar from './AppSidebar.vue'
|
import AppSidebar from '@/components/Sidebar/AppSidebar.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -93,11 +93,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
|
import {
|
||||||
|
call,
|
||||||
|
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, onUnmounted } from 'vue'
|
import { ref, inject, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useTelemetry } from 'frappe-ui/frappe'
|
||||||
|
|
||||||
const showTopics = defineModel('showTopics')
|
const showTopics = defineModel('showTopics')
|
||||||
const newReply = ref('')
|
const newReply = ref('')
|
||||||
@@ -107,6 +115,7 @@ const allUsers = inject('$allUsers')
|
|||||||
const mentionUsers = ref([])
|
const mentionUsers = ref([])
|
||||||
const renderEditor = ref(false)
|
const renderEditor = ref(false)
|
||||||
const readOnlyMode = window.read_only_mode
|
const readOnlyMode = window.read_only_mode
|
||||||
|
const { capture } = useTelemetry()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
topic: {
|
topic: {
|
||||||
@@ -143,19 +152,6 @@ const replies = createResource({
|
|||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const newReplyResource = createResource({
|
|
||||||
url: 'frappe.client.insert',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doc: {
|
|
||||||
doctype: 'Discussion Reply',
|
|
||||||
reply: newReply.value,
|
|
||||||
topic: props.topic.name,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchMentionUsers = () => {
|
const fetchMentionUsers = () => {
|
||||||
if (user.data?.is_student) {
|
if (user.data?.is_student) {
|
||||||
renderEditor.value = true
|
renderEditor.value = true
|
||||||
@@ -178,78 +174,61 @@ const fetchMentionUsers = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const postReply = () => {
|
const postReply = () => {
|
||||||
newReplyResource.submit(
|
if (!newReply.value) {
|
||||||
{},
|
toast.error(__('Reply cannot be empty.'))
|
||||||
{
|
return
|
||||||
validate() {
|
}
|
||||||
if (!newReply.value) {
|
call('frappe.client.insert', {
|
||||||
return 'Reply cannot be empty'
|
doc: {
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess() {
|
|
||||||
newReply.value = ''
|
|
||||||
replies.reload()
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
toast.error(err.messages?.[0] || err)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const editReplyResource = createResource({
|
|
||||||
url: 'frappe.client.set_value',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doctype: 'Discussion Reply',
|
doctype: 'Discussion Reply',
|
||||||
name: values.name,
|
reply: newReply.value,
|
||||||
fieldname: 'reply',
|
topic: props.topic.name,
|
||||||
value: values.reply,
|
},
|
||||||
}
|
})
|
||||||
},
|
.then((data) => {
|
||||||
})
|
newReply.value = ''
|
||||||
|
replies.reload()
|
||||||
|
capture('discussion_reply_created')
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(err.messages?.[0] || err)
|
||||||
|
console.error(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const postEdited = (reply) => {
|
const postEdited = (reply) => {
|
||||||
editReplyResource.submit(
|
if (!reply.reply) {
|
||||||
{
|
toast.error(__('Reply cannot be empty.'))
|
||||||
name: reply.name,
|
return
|
||||||
reply: reply.reply,
|
}
|
||||||
},
|
call('frappe.client.set_value', {
|
||||||
{
|
doctype: 'Discussion Reply',
|
||||||
validate() {
|
name: reply.name,
|
||||||
if (!reply.reply) {
|
fieldname: 'reply',
|
||||||
return 'Reply cannot be empty'
|
value: reply.reply,
|
||||||
}
|
})
|
||||||
},
|
.then(() => {
|
||||||
onSuccess() {
|
reply.editable = false
|
||||||
reply.editable = false
|
replies.reload()
|
||||||
replies.reload()
|
})
|
||||||
},
|
.catch((err) => {
|
||||||
}
|
toast.error(err.messages?.[0] || err)
|
||||||
)
|
console.error(err)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteReplyResource = createResource({
|
|
||||||
url: 'frappe.client.delete',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doctype: 'Discussion Reply',
|
|
||||||
name: values.name,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteReply = (reply) => {
|
const deleteReply = (reply) => {
|
||||||
deleteReplyResource.submit(
|
call('frappe.client.delete', {
|
||||||
{
|
doctype: 'Discussion Reply',
|
||||||
name: reply.name,
|
name: reply.name,
|
||||||
},
|
})
|
||||||
{
|
.then(() => {
|
||||||
onSuccess() {
|
replies.reload()
|
||||||
replies.reload()
|
})
|
||||||
},
|
.catch((err) => {
|
||||||
}
|
toast.error(err.messages?.[0] || err)
|
||||||
)
|
console.error(err)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
@@ -76,7 +76,14 @@ const isIos = () => {
|
|||||||
const isInStandaloneMode = () =>
|
const isInStandaloneMode = () =>
|
||||||
'standalone' in window.navigator && window.navigator.standalone
|
'standalone' in window.navigator && window.navigator.standalone
|
||||||
|
|
||||||
if (isIos() && !isInStandaloneMode()) iosInstallMessage.value = true
|
if (
|
||||||
|
isIos() &&
|
||||||
|
!isInStandaloneMode() &&
|
||||||
|
localStorage.getItem('learningIosInstallPromptShown') !== 'true'
|
||||||
|
) {
|
||||||
|
iosInstallMessage.value = true
|
||||||
|
localStorage.setItem('learningIosInstallPromptShown', 'true')
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('beforeinstallprompt', (e) => {
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
<template>
|
|
||||||
<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">
|
|
||||||
{{ __('Live Class') }}
|
|
||||||
</div>
|
|
||||||
<Button v-if="canCreateClass()" @click="openLiveClassModal">
|
|
||||||
<template #prefix>
|
|
||||||
<Plus class="h-4 w-4" />
|
|
||||||
</template>
|
|
||||||
<span>
|
|
||||||
{{ __('Add') }}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="liveClasses.data?.length"
|
|
||||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 mt-5"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="cls in liveClasses.data"
|
|
||||||
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">
|
|
||||||
{{ cls.title }}
|
|
||||||
</div>
|
|
||||||
<div class="short-introduction">
|
|
||||||
{{ cls.description }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-auto space-y-3">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
|
||||||
<span>
|
|
||||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<Clock class="w-4 h-4 stroke-1.5" />
|
|
||||||
<span>
|
|
||||||
{{ dayjs(getClassStart(cls)).format('hh:mm A') }} -
|
|
||||||
{{ dayjs(getClassEnd(cls)).format('hh:mm A') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="canAccessClass(cls)"
|
|
||||||
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
|
||||||
:href="cls.start_url"
|
|
||||||
target="_blank"
|
|
||||||
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" />
|
|
||||||
{{ __('Start') }}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
:href="cls.join_url"
|
|
||||||
target="_blank"
|
|
||||||
class="w-full 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"
|
|
||||||
>
|
|
||||||
<Video class="h-4 w-4 stroke-1.5" />
|
|
||||||
{{ __('Join') }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<Tooltip
|
|
||||||
v-else-if="hasClassEnded(cls)"
|
|
||||||
:text="__('This class has ended')"
|
|
||||||
placement="right"
|
|
||||||
>
|
|
||||||
<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 v-else class="text-sm italic text-ink-gray-5 mt-2">
|
|
||||||
{{ __('No live classes scheduled') }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LiveClassModal
|
|
||||||
:batch="props.batch"
|
|
||||||
:zoomAccount="props.zoomAccount"
|
|
||||||
v-model="showLiveClassModal"
|
|
||||||
v-model:reloadLiveClasses="liveClasses"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<LiveClassAttendance v-model="showAttendance" :live_class="attendanceFor" />
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import { createListResource, Button, Tooltip } from 'frappe-ui'
|
|
||||||
import {
|
|
||||||
Plus,
|
|
||||||
Clock,
|
|
||||||
Calendar,
|
|
||||||
Video,
|
|
||||||
Monitor,
|
|
||||||
Info,
|
|
||||||
AlertCircle,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
import { inject, ref } from 'vue'
|
|
||||||
import { formatTime } from '@/utils/'
|
|
||||||
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
|
|
||||||
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
|
|
||||||
|
|
||||||
const user = inject('$user')
|
|
||||||
const showLiveClassModal = ref(false)
|
|
||||||
const dayjs = inject('$dayjs')
|
|
||||||
const readOnlyMode = window.read_only_mode
|
|
||||||
const showAttendance = ref(false)
|
|
||||||
const attendanceFor = ref(null)
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
batch: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
zoomAccount: String,
|
|
||||||
})
|
|
||||||
|
|
||||||
const liveClasses = createListResource({
|
|
||||||
doctype: 'LMS Live Class',
|
|
||||||
filters: {
|
|
||||||
batch_name: props.batch,
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
'title',
|
|
||||||
'description',
|
|
||||||
'time',
|
|
||||||
'date',
|
|
||||||
'duration',
|
|
||||||
'attendees',
|
|
||||||
'start_url',
|
|
||||||
'join_url',
|
|
||||||
'owner',
|
|
||||||
],
|
|
||||||
orderBy: 'date',
|
|
||||||
auto: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const openLiveClassModal = () => {
|
|
||||||
showLiveClassModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const canCreateClass = () => {
|
|
||||||
if (readOnlyMode) return false
|
|
||||||
if (!props.zoomAccount) return false
|
|
||||||
return hasPermission()
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasPermission = () => {
|
|
||||||
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 getClassStart = (cls) => {
|
|
||||||
return new Date(`${cls.date}T${cls.time}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getClassEnd = (cls) => {
|
|
||||||
const classStart = getClassStart(cls)
|
|
||||||
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>
|
|
||||||
<style>
|
|
||||||
.short-introduction {
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
margin: 0.25rem 0 1.5rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -76,12 +76,20 @@ const isModerator = ref(false)
|
|||||||
const isInstructor = ref(false)
|
const isInstructor = ref(false)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Вызываем addSideBar только если userResource уже загружен
|
sidebarSettings.reload(
|
||||||
if (userResource.data) {
|
{},
|
||||||
addSideBar()
|
{
|
||||||
}
|
onSuccess(data) {
|
||||||
addOtherLinks()
|
if (userResource.data) {
|
||||||
filterLinksToShow(data)
|
addSideBar()
|
||||||
|
} else {
|
||||||
|
destructureSidebarLinks()
|
||||||
|
}
|
||||||
|
filterLinksToShow(data)
|
||||||
|
addOtherLinks()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleOutsideClick = (e) => {
|
const handleOutsideClick = (e) => {
|
||||||
@@ -100,6 +108,16 @@ watch(showMenu, (val) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const destructureSidebarLinks = () => {
|
||||||
|
let links = []
|
||||||
|
sidebarLinks.value.forEach((link) => {
|
||||||
|
link.items?.forEach((item) => {
|
||||||
|
links.push(item)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
sidebarLinks.value = links
|
||||||
|
}
|
||||||
|
|
||||||
const filterLinksToShow = (data) => {
|
const filterLinksToShow = (data) => {
|
||||||
Object.keys(data).forEach((key) => {
|
Object.keys(data).forEach((key) => {
|
||||||
if (!parseInt(data[key])) {
|
if (!parseInt(data[key])) {
|
||||||
|
|||||||
@@ -20,11 +20,15 @@
|
|||||||
:options="assessmentTypes"
|
:options="assessmentTypes"
|
||||||
v-model="assessmentType"
|
v-model="assessmentType"
|
||||||
:label="__('Type')"
|
:label="__('Type')"
|
||||||
|
placeholder=" "
|
||||||
|
@update:modelValue="() => (assessment = null)"
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
|
v-if="assessmentType"
|
||||||
v-model="assessment"
|
v-model="assessment"
|
||||||
:doctype="assessmentType"
|
:doctype="assessmentType"
|
||||||
:label="__('Assessment')"
|
:label="__('Assessment')"
|
||||||
|
placeholder=" "
|
||||||
:onCreate="
|
:onCreate="
|
||||||
(value, close) => {
|
(value, close) => {
|
||||||
close()
|
close()
|
||||||
@@ -49,7 +53,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, FormControl, createResource, toast } from 'frappe-ui'
|
import { Dialog, FormControl, createResource, toast } from 'frappe-ui'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import { Link } from 'frappe-ui/frappe'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,12 @@
|
|||||||
:label="__('Submission Type')"
|
:label="__('Submission Type')"
|
||||||
:required="true"
|
:required="true"
|
||||||
/>
|
/>
|
||||||
|
<Link
|
||||||
|
v-model="assignment.course"
|
||||||
|
:label="__('Course')"
|
||||||
|
doctype="LMS Course"
|
||||||
|
placeholder=" "
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-ink-gray-5 mb-2">
|
<div class="text-xs text-ink-gray-5 mb-2">
|
||||||
{{ __('Question') }}
|
{{ __('Question') }}
|
||||||
@@ -37,7 +43,7 @@
|
|||||||
@change="(val) => (assignment.question = val)"
|
@change="(val) => (assignment.question = val)"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
:fixedMenu="true"
|
:fixedMenu="true"
|
||||||
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"
|
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,6 +72,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||||
import { computed, reactive, watch } from 'vue'
|
import { computed, reactive, watch } from 'vue'
|
||||||
|
import { escapeHTML, sanitizeHTML } from '@/utils'
|
||||||
|
import { Link } from 'frappe-ui/frappe'
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const assignments = defineModel<Assignments>('assignments')
|
const assignments = defineModel<Assignments>('assignments')
|
||||||
@@ -74,6 +82,7 @@ interface Assignment {
|
|||||||
title: string
|
title: string
|
||||||
type: string
|
type: string
|
||||||
question: string
|
question: string
|
||||||
|
course?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Assignments {
|
interface Assignments {
|
||||||
@@ -88,6 +97,7 @@ const assignment = reactive({
|
|||||||
title: '',
|
title: '',
|
||||||
type: '',
|
type: '',
|
||||||
question: '',
|
question: '',
|
||||||
|
course: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -106,6 +116,7 @@ watch(
|
|||||||
assignment.title = row.title
|
assignment.title = row.title
|
||||||
assignment.type = row.type
|
assignment.type = row.type
|
||||||
assignment.question = row.question
|
assignment.question = row.question
|
||||||
|
assignment.course = row.course || ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -121,35 +132,49 @@ watch(show, (newVal) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const validateFields = () => {
|
||||||
|
assignment.title = escapeHTML(assignment.title.trim())
|
||||||
|
assignment.question = sanitizeHTML(assignment.question)
|
||||||
|
}
|
||||||
|
|
||||||
const saveAssignment = () => {
|
const saveAssignment = () => {
|
||||||
|
validateFields()
|
||||||
if (props.assignmentID == 'new') {
|
if (props.assignmentID == 'new') {
|
||||||
assignments.value.insert.submit(
|
createAssignment()
|
||||||
{
|
|
||||||
...assignment,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess() {
|
|
||||||
show.value = false
|
|
||||||
toast.success(__('Assignment created successfully'))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
assignments.value.setValue.submit(
|
updateAssignment()
|
||||||
{
|
|
||||||
...assignment,
|
|
||||||
name: props.assignmentID,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess() {
|
|
||||||
show.value = false
|
|
||||||
toast.success(__('Assignment updated successfully'))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createAssignment = () => {
|
||||||
|
assignments.value.insert.submit(
|
||||||
|
{
|
||||||
|
...assignment,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
show.value = false
|
||||||
|
toast.success(__('Assignment created successfully'))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAssignment = () => {
|
||||||
|
assignments.value.setValue.submit(
|
||||||
|
{
|
||||||
|
...assignment,
|
||||||
|
name: props.assignmentID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
show.value = false
|
||||||
|
toast.success(__('Assignment updated successfully'))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const assignmentOptions = computed(() => {
|
const assignmentOptions = computed(() => {
|
||||||
return [
|
return [
|
||||||
{ label: 'PDF', value: 'PDF' },
|
{ label: 'PDF', value: 'PDF' },
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
<Dialog
|
<Dialog
|
||||||
v-model="show"
|
v-model="show"
|
||||||
:options="{
|
:options="{
|
||||||
title: __('Add a course'),
|
title: __('Add a course to the batch'),
|
||||||
size: 'sm',
|
size: 'lg',
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
label: __('Submit'),
|
label: __('Submit'),
|
||||||
@@ -19,14 +19,13 @@
|
|||||||
v-model="course"
|
v-model="course"
|
||||||
:label="__('Course')"
|
:label="__('Course')"
|
||||||
:required="true"
|
:required="true"
|
||||||
|
:filters="{ published: 1 }"
|
||||||
:onCreate="
|
:onCreate="
|
||||||
(value, close) => {
|
(value, close) => {
|
||||||
close()
|
close()
|
||||||
router.push({
|
router.push({
|
||||||
name: 'CourseForm',
|
name: 'Courses',
|
||||||
params: {
|
query: { newCourse: '1' },
|
||||||
courseName: 'new',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
@@ -42,7 +41,7 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, createResource, toast } from 'frappe-ui'
|
import { Dialog, toast } from 'frappe-ui'
|
||||||
import { ref, inject } from 'vue'
|
import { ref, inject } from 'vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
@@ -64,37 +63,28 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const createBatchCourse = createResource({
|
|
||||||
url: 'frappe.client.insert',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doc: {
|
|
||||||
doctype: 'Batch Course',
|
|
||||||
parent: props.batch,
|
|
||||||
parenttype: 'LMS Batch',
|
|
||||||
parentfield: 'courses',
|
|
||||||
course: course.value,
|
|
||||||
evaluator: evaluator.value,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const addCourse = (close) => {
|
const addCourse = (close) => {
|
||||||
createBatchCourse.submit(
|
courses.value.insert.submit(
|
||||||
{},
|
{
|
||||||
|
course: course.value,
|
||||||
|
evaluator: evaluator.value,
|
||||||
|
parent: props.batch,
|
||||||
|
parenttype: 'LMS Batch',
|
||||||
|
parentfield: 'courses',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
if (user.data?.is_system_manager)
|
if (user.data?.is_system_manager)
|
||||||
updateOnboardingStep('add_batch_course')
|
updateOnboardingStep('add_batch_course')
|
||||||
|
|
||||||
close()
|
close()
|
||||||
courses.value.reload()
|
|
||||||
course.value = null
|
course.value = null
|
||||||
evaluator.value = null
|
evaluator.value = null
|
||||||
|
toast.success(__('Course added to batch successfully'))
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
toast.error(err.messages?.[0] || err)
|
toast.error(err.messages?.[0] || err)
|
||||||
|
console.log(err)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Dialog
|
|
||||||
v-model="show"
|
|
||||||
:options="{
|
|
||||||
size: 'xl',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<template #body>
|
|
||||||
<div class="p-5 space-y-10 text-base">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<Avatar :image="student.user_image" size="3xl" />
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div class="text-xl font-semibold text-ink-gray-9">
|
|
||||||
{{ student.full_name }}
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
v-if="
|
|
||||||
Object.keys(student.assessments).length ||
|
|
||||||
Object.keys(student.courses).length
|
|
||||||
"
|
|
||||||
:theme="student.progress === 100 ? 'green' : 'red'"
|
|
||||||
>
|
|
||||||
{{ student.progress }}% {{ __('Complete') }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-ink-gray-7">
|
|
||||||
{{ student.email }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-8">
|
|
||||||
<!-- Assessments -->
|
|
||||||
<div
|
|
||||||
v-if="Object.keys(student.assessments).length"
|
|
||||||
class="space-y-2 text-sm"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
|
|
||||||
>
|
|
||||||
<span class="flex-1">
|
|
||||||
{{ __('Assessment') }}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{{ __('Percentage/Status') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<router-link
|
|
||||||
v-for="assessment in Object.keys(student.assessments)"
|
|
||||||
class="flex items-center text-ink-gray-7 font-medium"
|
|
||||||
:to="{
|
|
||||||
name:
|
|
||||||
student.assessments[assessment].type == 'LMS Assignment'
|
|
||||||
? 'AssignmentSubmission'
|
|
||||||
: '',
|
|
||||||
params:
|
|
||||||
student.assessments[assessment].type == 'LMS Assignment'
|
|
||||||
? {
|
|
||||||
assignmentID:
|
|
||||||
student.assessments[assessment].assessment,
|
|
||||||
submissionName:
|
|
||||||
student.assessments[assessment].submission,
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<span class="flex-1">
|
|
||||||
{{ assessment }}
|
|
||||||
</span>
|
|
||||||
<span v-if="isAssignment(student.assessments[assessment].status)">
|
|
||||||
<Badge
|
|
||||||
:theme="
|
|
||||||
getStatusTheme(student.assessments[assessment].status)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ student.assessments[assessment].status }}
|
|
||||||
</Badge>
|
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
{{ student.assessments[assessment].status }}
|
|
||||||
</span>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Courses -->
|
|
||||||
<div
|
|
||||||
v-if="Object.keys(student.courses).length"
|
|
||||||
class="space-y-2 text-sm"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
|
|
||||||
>
|
|
||||||
<span class="flex-1">
|
|
||||||
{{ __('Courses') }}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{{ __('Progress') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="course in Object.keys(student.courses)"
|
|
||||||
class="flex items-center text-ink-gray-7 font-medium"
|
|
||||||
>
|
|
||||||
<span class="flex-1">
|
|
||||||
{{ course }}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{{ Math.floor(student.courses[course]) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Heatmap -->
|
|
||||||
<StudentHeatmap :member="student.email" :days="120" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import { Avatar, Badge, Dialog } from 'frappe-ui'
|
|
||||||
import StudentHeatmap from '@/components/StudentHeatmap.vue'
|
|
||||||
|
|
||||||
const show = defineModel()
|
|
||||||
const props = defineProps({
|
|
||||||
student: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const isAssignment = (value) => {
|
|
||||||
return isNaN(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusTheme = (status) => {
|
|
||||||
if (status === 'Pass') {
|
|
||||||
return 'green'
|
|
||||||
} else if (status == 'Not Graded') {
|
|
||||||
return 'orange'
|
|
||||||
} else {
|
|
||||||
return 'red'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -80,13 +80,13 @@ import {
|
|||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { reactive, watch, inject } from 'vue'
|
import { reactive, watch, inject } from 'vue'
|
||||||
import { getFileSize } from '@/utils/'
|
import { getFileSize } from '@/utils/'
|
||||||
import { capture } from '@/telemetry'
|
|
||||||
import { FileText, X } from 'lucide-vue-next'
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const outline = defineModel('outline')
|
const outline = defineModel('outline')
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
const { capture } = useTelemetry()
|
||||||
const { updateOnboardingStep } = useOnboarding('learning')
|
const { updateOnboardingStep } = useOnboarding('learning')
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|||||||
@@ -1,231 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Dialog
|
|
||||||
v-model="show"
|
|
||||||
:options="{
|
|
||||||
title: __('Course Progress Summary'),
|
|
||||||
size: '5xl',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<template #body-content>
|
|
||||||
<div
|
|
||||||
class="flex flex-col-reverse md:flex-row justify-between md:space-x-10 text-base mt-10"
|
|
||||||
>
|
|
||||||
<div class="w-full">
|
|
||||||
<div class="flex items-center justify-between space-x-5 mb-4">
|
|
||||||
<FormControl
|
|
||||||
v-model="searchFilter"
|
|
||||||
:placeholder="__('Search by Member')"
|
|
||||||
type="text"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="max-h-[70vh] overflow-y-auto">
|
|
||||||
<ListView
|
|
||||||
v-if="progressList.loading || progressList.data?.length"
|
|
||||||
:columns="progressColumns"
|
|
||||||
:rows="progressList.data"
|
|
||||||
rowKey="name"
|
|
||||||
:options="{
|
|
||||||
selectable: false,
|
|
||||||
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 progressColumns"
|
|
||||||
:key="item.key"
|
|
||||||
>
|
|
||||||
<template #prefix="{ item }">
|
|
||||||
<FeatherIcon
|
|
||||||
:name="item.icon?.toString()"
|
|
||||||
class="h-4 w-4"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</ListHeaderItem>
|
|
||||||
</ListHeader>
|
|
||||||
<ListRows v-for="row in progressList.data">
|
|
||||||
<router-link
|
|
||||||
:to="{
|
|
||||||
name: 'Profile',
|
|
||||||
params: { username: row.member_username },
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<ListRow :row="row">
|
|
||||||
<template #default="{ column, item }">
|
|
||||||
<ListRowItem
|
|
||||||
:item="row[column.key]"
|
|
||||||
:align="column.align"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
|
||||||
<div v-if="column.key == 'member_name'">
|
|
||||||
<Avatar
|
|
||||||
class="flex items-center"
|
|
||||||
:image="row['member_image']"
|
|
||||||
:label="item"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div>
|
|
||||||
{{ row[column.key].toString() }}
|
|
||||||
</div>
|
|
||||||
</ListRowItem>
|
|
||||||
</template>
|
|
||||||
</ListRow>
|
|
||||||
</router-link>
|
|
||||||
</ListRows>
|
|
||||||
</ListView>
|
|
||||||
<div
|
|
||||||
v-if="progressList.data && progressList.hasNextPage"
|
|
||||||
class="flex justify-center my-5"
|
|
||||||
>
|
|
||||||
<Button @click="progressList.next()">
|
|
||||||
{{ __('Load More') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-4 self-start w-full space-y-5">
|
|
||||||
<div
|
|
||||||
class="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-4"
|
|
||||||
>
|
|
||||||
<NumberChart
|
|
||||||
class="border rounded-md w-full"
|
|
||||||
:config="{
|
|
||||||
title: __('Enrollments'),
|
|
||||||
value: memberCount || 0,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
<NumberChart
|
|
||||||
class="border rounded-md w-full"
|
|
||||||
:config="{
|
|
||||||
title: __('Average Progress %'),
|
|
||||||
value: chartDetails.data?.average_progress || 0,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DonutChart
|
|
||||||
:config="{
|
|
||||||
data: chartDetails.data?.progress_distribution || [],
|
|
||||||
title: __('Progress Distribution'),
|
|
||||||
categoryColumn: 'category',
|
|
||||||
valueColumn: 'count',
|
|
||||||
colors: [
|
|
||||||
theme.colors.red['400'],
|
|
||||||
theme.colors.amber['400'],
|
|
||||||
theme.colors.pink['400'],
|
|
||||||
theme.colors.blue['400'],
|
|
||||||
theme.colors.green['400'],
|
|
||||||
],
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
Button,
|
|
||||||
createListResource,
|
|
||||||
createResource,
|
|
||||||
Dialog,
|
|
||||||
DonutChart,
|
|
||||||
FeatherIcon,
|
|
||||||
FormControl,
|
|
||||||
ListView,
|
|
||||||
ListHeader,
|
|
||||||
ListHeaderItem,
|
|
||||||
ListRows,
|
|
||||||
ListRow,
|
|
||||||
ListRowItem,
|
|
||||||
NumberChart,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import { theme } from '@/utils/theme'
|
|
||||||
|
|
||||||
const show = defineModel<boolean>({ default: false })
|
|
||||||
const searchFilter = ref<string | null>(null)
|
|
||||||
type Filters = {
|
|
||||||
course: string | undefined
|
|
||||||
|
|
||||||
member_name?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
courseName?: string
|
|
||||||
enrollments?: number
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const memberCount = ref<number>(props.enrollments || 0)
|
|
||||||
|
|
||||||
const chartDetails = createResource({
|
|
||||||
url: 'lms.lms.api.get_course_progress_distribution',
|
|
||||||
params: {
|
|
||||||
course: props.courseName,
|
|
||||||
},
|
|
||||||
auto: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const progressList = createListResource({
|
|
||||||
doctype: 'LMS Enrollment',
|
|
||||||
filters: {
|
|
||||||
course: props.courseName,
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
'name',
|
|
||||||
'member',
|
|
||||||
'member_name',
|
|
||||||
'member_image',
|
|
||||||
'member_username',
|
|
||||||
'progress',
|
|
||||||
],
|
|
||||||
pageLength: 50,
|
|
||||||
auto: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
watch([searchFilter], () => {
|
|
||||||
let filterApplied = false
|
|
||||||
let filters: Filters = {
|
|
||||||
course: props.courseName,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchFilter.value) {
|
|
||||||
filters.member_name = ['like', `%${searchFilter.value}%`]
|
|
||||||
filterApplied = true
|
|
||||||
}
|
|
||||||
|
|
||||||
progressList.update({
|
|
||||||
filters: filters,
|
|
||||||
})
|
|
||||||
progressList.reload(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
onSuccess(data: any[]) {
|
|
||||||
memberCount.value = filterApplied ? data.length : props.enrollments || 0
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const progressColumns = computed(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: __('Member'),
|
|
||||||
key: 'member_name',
|
|
||||||
width: '60%',
|
|
||||||
icon: 'user',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: __('Progress'),
|
|
||||||
key: 'progress',
|
|
||||||
align: 'right',
|
|
||||||
icon: 'trending-up',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
@change="(val) => (topic.reply = val)"
|
@change="(val) => (topic.reply = val)"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
:fixedMenu="true"
|
:fixedMenu="true"
|
||||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,17 +34,13 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import { call, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||||
Dialog,
|
|
||||||
FormControl,
|
|
||||||
TextEditor,
|
|
||||||
createResource,
|
|
||||||
toast,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { singularize } from '@/utils'
|
import { singularize } from '@/utils'
|
||||||
|
import { useTelemetry } from 'frappe-ui/frappe'
|
||||||
|
|
||||||
const topics = defineModel('reloadTopics')
|
const topics = defineModel('reloadTopics')
|
||||||
|
const { capture } = useTelemetry()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
title: {
|
title: {
|
||||||
@@ -66,64 +62,50 @@ const topic = reactive({
|
|||||||
reply: '',
|
reply: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const topicResource = createResource({
|
|
||||||
url: 'frappe.client.insert',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doc: {
|
|
||||||
doctype: 'Discussion Topic',
|
|
||||||
reference_doctype: props.doctype,
|
|
||||||
reference_docname: props.docname,
|
|
||||||
title: topic.title,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const replyResource = createResource({
|
|
||||||
url: 'frappe.client.insert',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doc: {
|
|
||||||
doctype: 'Discussion Reply',
|
|
||||||
topic: values.topic,
|
|
||||||
reply: topic.reply,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const submitTopic = (close) => {
|
const submitTopic = (close) => {
|
||||||
topicResource.submit(
|
if (!topic.title) {
|
||||||
{},
|
toast.error(__('Title cannot be empty.'))
|
||||||
{
|
return
|
||||||
validate() {
|
}
|
||||||
if (!topic.title) {
|
if (!topic.reply) {
|
||||||
return 'Title cannot be empty.'
|
toast.error(__('Details cannot be empty.'))
|
||||||
}
|
return
|
||||||
if (!topic.reply) {
|
}
|
||||||
return 'Reply cannot be empty.'
|
call('frappe.client.insert', {
|
||||||
}
|
doc: {
|
||||||
},
|
doctype: 'Discussion Topic',
|
||||||
onSuccess(data) {
|
reference_doctype: props.doctype,
|
||||||
replyResource.submit(
|
reference_docname: props.docname,
|
||||||
{
|
title: topic.title,
|
||||||
topic: data.name,
|
},
|
||||||
},
|
})
|
||||||
{
|
.then((data) => {
|
||||||
onSuccess() {
|
createReply(data.name, close)
|
||||||
topic.title = ''
|
})
|
||||||
topic.reply = ''
|
.catch((err) => {
|
||||||
topics.value.reload()
|
toast.error(err.messages?.[0] || err)
|
||||||
close()
|
console.error(err)
|
||||||
},
|
})
|
||||||
}
|
}
|
||||||
)
|
|
||||||
},
|
const createReply = (topicName, close) => {
|
||||||
onError(err) {
|
call('frappe.client.insert', {
|
||||||
toast.error(err.messages?.[0] || err)
|
doc: {
|
||||||
},
|
doctype: 'Discussion Reply',
|
||||||
}
|
topic: topicName,
|
||||||
)
|
reply: topic.reply,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
topic.title = ''
|
||||||
|
topic.reply = ''
|
||||||
|
topics.value.reload()
|
||||||
|
capture('discussion_topic_created')
|
||||||
|
close()
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(err.messages?.[0] || err)
|
||||||
|
console.error(err)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,91 +1,85 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
:options="{
|
:options="{
|
||||||
title: __('Edit your profile'),
|
|
||||||
size: '3xl',
|
size: '3xl',
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: __('Save'),
|
|
||||||
variant: 'solid',
|
|
||||||
onClick: (close) => saveProfile(close),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
|
<template #body-header>
|
||||||
|
<div class="flex items-center justify-between mb-5">
|
||||||
|
<div class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
||||||
|
{{ __('Edit Profile') }}
|
||||||
|
</div>
|
||||||
|
<div class="space-x-2">
|
||||||
|
<Badge v-if="isDirty" theme="orange">
|
||||||
|
{{ __('Not Saved') }}
|
||||||
|
</Badge>
|
||||||
|
<div class="pb-5 float-right">
|
||||||
|
<Button variant="solid" @click="saveProfile()">
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template #body-content>
|
<template #body-content>
|
||||||
<div class="grid grid-cols-2 gap-5">
|
<div class="text-base">
|
||||||
<div class="space-y-4">
|
<div class="grid grid-cols-2 gap-10">
|
||||||
<!-- <Uploader
|
<div class="space-y-4">
|
||||||
v-model="profile.image.file_url"
|
<div class="space-y-4">
|
||||||
label="Profile Image"
|
<Uploader
|
||||||
description="Your profile image to help others recognize you."
|
v-model="profile.image"
|
||||||
/> -->
|
:label="__('Profile Image')"
|
||||||
<div>
|
:required="true"
|
||||||
<div class="text-xs text-ink-gray-5 mb-1">
|
shape="circle"
|
||||||
{{ __('Profile Image') }}
|
/>
|
||||||
</div>
|
|
||||||
<FileUploader
|
|
||||||
v-if="!profile.image"
|
|
||||||
:fileTypes="['image/*']"
|
|
||||||
:validateFile="validateFile"
|
|
||||||
@success="(file) => saveImage(file)"
|
|
||||||
>
|
|
||||||
<template
|
|
||||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
|
||||||
>
|
|
||||||
<div class="mb-4">
|
|
||||||
<Button @click="openFileSelector" :loading="uploading">
|
|
||||||
{{
|
|
||||||
uploading
|
|
||||||
? `Uploading ${progress}%`
|
|
||||||
: 'Upload a profile image'
|
|
||||||
}}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</FileUploader>
|
|
||||||
<div v-else class="mb-4">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<img
|
|
||||||
:src="profile.image.file_url"
|
|
||||||
class="object-cover h-[50px] w-[50px] rounded-full border-4 border-white object-cover"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="text-base flex flex-col ml-2">
|
<FormControl
|
||||||
<span>
|
v-model="profile.first_name"
|
||||||
{{ profile.image.file_name }}
|
:label="__('First Name')"
|
||||||
</span>
|
/>
|
||||||
<span class="text-sm text-ink-gray-4 mt-1">
|
<FormControl
|
||||||
{{ getFileSize(profile.image.file_size) }}
|
v-model="profile.last_name"
|
||||||
</span>
|
:label="__('Last Name')"
|
||||||
</div>
|
/>
|
||||||
<X
|
<FormControl v-model="profile.headline" :label="__('Headline')" />
|
||||||
@click="removeImage()"
|
|
||||||
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
<FormControl
|
||||||
/>
|
v-model="profile.linkedin"
|
||||||
</div>
|
:label="__('LinkedIn ID')"
|
||||||
|
/>
|
||||||
|
<FormControl v-model="profile.github" :label="__('GitHub ID')" />
|
||||||
|
<FormControl
|
||||||
|
v-model="profile.twitter"
|
||||||
|
:label="__('Twitter ID')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FormControl v-model="profile.first_name" :label="__('First Name')" />
|
<div class="space-y-4">
|
||||||
<FormControl v-model="profile.last_name" :label="__('Last Name')" />
|
<FormControl
|
||||||
<FormControl v-model="profile.headline" :label="__('Headline')" />
|
v-model="profile.open_to"
|
||||||
<Link
|
type="select"
|
||||||
:label="__('Language')"
|
:options="[' ', 'Work', 'Hiring']"
|
||||||
v-model="profile.language"
|
:label="__('Open to')"
|
||||||
doctype="Language"
|
:placeholder="__('Looking for new work or hiring talent?')"
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
|
||||||
{{ __('Bio') }}
|
|
||||||
</div>
|
|
||||||
<TextEditor
|
|
||||||
:fixedMenu="true"
|
|
||||||
@change="(val) => (profile.bio = val)"
|
|
||||||
:content="profile.bio"
|
|
||||||
editorClass="prose-sm py-2 px-2 min-h-[200px] border-outline-gray-2 hover:border-outline-gray-3 rounded-b-md bg-surface-gray-3"
|
|
||||||
/>
|
/>
|
||||||
|
<Link
|
||||||
|
:label="__('Language')"
|
||||||
|
v-model="profile.language"
|
||||||
|
doctype="Language"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||||
|
{{ __('Bio') }}
|
||||||
|
</div>
|
||||||
|
<TextEditor
|
||||||
|
:fixedMenu="true"
|
||||||
|
@change="(val) => (profile.bio = val)"
|
||||||
|
:content="profile.bio"
|
||||||
|
:rows="15"
|
||||||
|
editorClass="prose-sm py-2 px-2 min-h-[280px] border-outline-gray-2 hover:border-outline-gray-3 rounded-b-md bg-surface-gray-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,22 +88,22 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Badge,
|
||||||
FormControl,
|
|
||||||
FileUploader,
|
|
||||||
Button,
|
Button,
|
||||||
createResource,
|
createResource,
|
||||||
|
Dialog,
|
||||||
|
FormControl,
|
||||||
TextEditor,
|
TextEditor,
|
||||||
toast,
|
toast,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { ref, reactive, watch } from 'vue'
|
import { ref, reactive, watch } from 'vue'
|
||||||
import { X } from 'lucide-vue-next'
|
import { sanitizeHTML } from '@/utils'
|
||||||
import { getFileSize, decodeEntities } from '@/utils'
|
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import DOMPurify from 'dompurify'
|
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
const reloadProfile = defineModel('reloadProfile')
|
const reloadProfile = defineModel('reloadProfile')
|
||||||
const hasLanguageChanged = ref(false)
|
const hasLanguageChanged = ref(false)
|
||||||
|
const isDirty = ref(false)
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
profile: {
|
profile: {
|
||||||
@@ -124,19 +118,10 @@ const profile = reactive({
|
|||||||
headline: '',
|
headline: '',
|
||||||
bio: '',
|
bio: '',
|
||||||
image: '',
|
image: '',
|
||||||
})
|
open_to: '',
|
||||||
|
linkedin: '',
|
||||||
const imageResource = createResource({
|
github: '',
|
||||||
url: 'lms.lms.api.get_file_info',
|
twitter: '',
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
file_url: values.image,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
auto: false,
|
|
||||||
onSuccess(data) {
|
|
||||||
profile.image = data
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateProfile = createResource({
|
const updateProfile = createResource({
|
||||||
@@ -146,7 +131,7 @@ const updateProfile = createResource({
|
|||||||
doctype: 'User',
|
doctype: 'User',
|
||||||
name: props.profile.data.name,
|
name: props.profile.data.name,
|
||||||
fieldname: {
|
fieldname: {
|
||||||
user_image: profile.image.file_url,
|
user_image: profile.image || null,
|
||||||
...profile,
|
...profile,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -156,28 +141,13 @@ const updateProfile = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const saveProfile = (close) => {
|
const saveProfile = () => {
|
||||||
profile.bio = DOMPurify.sanitize(decodeEntities(profile.bio), {
|
profile.bio = sanitizeHTML(profile.bio)
|
||||||
ALLOWED_TAGS: [
|
|
||||||
'b',
|
|
||||||
'i',
|
|
||||||
'em',
|
|
||||||
'strong',
|
|
||||||
'a',
|
|
||||||
'p',
|
|
||||||
'br',
|
|
||||||
'ul',
|
|
||||||
'ol',
|
|
||||||
'li',
|
|
||||||
'img',
|
|
||||||
],
|
|
||||||
ALLOWED_ATTR: ['href', 'target', 'src'],
|
|
||||||
})
|
|
||||||
updateProfile.submit(
|
updateProfile.submit(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
close()
|
show.value = false
|
||||||
reloadProfile.value.reload()
|
reloadProfile.value.reload()
|
||||||
if (hasLanguageChanged.value) {
|
if (hasLanguageChanged.value) {
|
||||||
hasLanguageChanged.value = false
|
hasLanguageChanged.value = false
|
||||||
@@ -191,20 +161,26 @@ const saveProfile = (close) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateFile = (file) => {
|
watch(
|
||||||
let extension = file.name.split('.').pop().toLowerCase()
|
() => profile,
|
||||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
(newVal) => {
|
||||||
return 'Only image file is allowed.'
|
if (!props.profile.data) return
|
||||||
}
|
let keys = Object.keys(newVal)
|
||||||
}
|
keys.splice(keys.indexOf('image'), 1)
|
||||||
|
for (let key of keys) {
|
||||||
const saveImage = (file) => {
|
if (newVal[key] !== props.profile.data[key]) {
|
||||||
profile.image = file
|
isDirty.value = true
|
||||||
}
|
return
|
||||||
|
}
|
||||||
const removeImage = () => {
|
}
|
||||||
profile.image = null
|
if (profile.image !== props.profile.data.user_image) {
|
||||||
}
|
isDirty.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isDirty.value = false
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.profile.data,
|
() => props.profile.data,
|
||||||
@@ -215,15 +191,20 @@ watch(
|
|||||||
profile.headline = newVal.headline
|
profile.headline = newVal.headline
|
||||||
profile.language = newVal.language
|
profile.language = newVal.language
|
||||||
profile.bio = newVal.bio
|
profile.bio = newVal.bio
|
||||||
if (newVal.user_image) imageResource.submit({ image: newVal.user_image })
|
profile.open_to = newVal.open_to
|
||||||
|
profile.linkedin = newVal.linkedin
|
||||||
|
profile.github = newVal.github
|
||||||
|
profile.twitter = newVal.twitter
|
||||||
|
profile.image = newVal.user_image
|
||||||
|
isDirty.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => profile.language,
|
() => profile.language,
|
||||||
(newVal, oldVal) => {
|
() => {
|
||||||
if (newVal !== oldVal) {
|
if (profile.language !== props.profile.data.language) {
|
||||||
hasLanguageChanged.value = true
|
hasLanguageChanged.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
'Dear {{ member_name }},\n\nYou have been enrolled in our upcoming batch {{ batch_name }}.\n\nThanks,\nFrappe Learning'
|
'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"
|
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<Dialog
|
<Dialog
|
||||||
v-model="show"
|
v-model="show"
|
||||||
:options="{
|
:options="{
|
||||||
title: __('Schedule Evaluation'),
|
title: __('Schedule your evaluation'),
|
||||||
size: 'xl',
|
size: 'xl',
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
@@ -14,64 +14,71 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body-content>
|
<template #body-content>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4 text-base max-h-[60vh]">
|
||||||
<div>
|
<FormControl
|
||||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
v-model="evaluation.course"
|
||||||
{{ __('Course') }}
|
type="select"
|
||||||
|
:label="__('Course')"
|
||||||
|
:options="getCourses()"
|
||||||
|
/>
|
||||||
|
<div v-if="slots.data?.length" class="space-y-4 overflow-y-auto mt-4">
|
||||||
|
<div class="text-ink-gray-9 font-medium">
|
||||||
|
{{ __('Available Slots') }}
|
||||||
</div>
|
</div>
|
||||||
<Select v-model="evaluation.course" :options="getCourses()" />
|
<div class="space-y-5">
|
||||||
</div>
|
<div v-for="row in slots.data" class="space-y-2">
|
||||||
<div>
|
<div class="flex items-center text-ink-gray-7 space-x-2">
|
||||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
<Calendar class="size-3" />
|
||||||
{{ __('Date') }}
|
<div class="text-ink-gray-9">
|
||||||
</div>
|
{{ dayjs(row.date).format('DD MMMM YYYY') }}
|
||||||
<FormControl
|
</div>
|
||||||
type="date"
|
<div>·</div>
|
||||||
v-model="evaluation.date"
|
<div class="text-ink-gray-5">
|
||||||
:min="
|
{{ row.day }}
|
||||||
dayjs()
|
</div>
|
||||||
.add(dayjs.duration({ days: 1 }))
|
</div>
|
||||||
.format('YYYY-MM-DD')
|
<div class="grid grid-cols-3 gap-2">
|
||||||
"
|
<div
|
||||||
/>
|
v-for="slot in row.slots"
|
||||||
</div>
|
class="text-base text-center border rounded-md text-ink-gray-8 p-2 cursor-pointer text-ink-gray-7 hover:bg-surface-gray-2 hover:border-outline-gray-3"
|
||||||
<div v-if="slots.data?.length">
|
@click="saveSlot(slot, row)"
|
||||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
:class="{
|
||||||
{{ __('Select a slot') }}
|
'border-outline-gray-4 text-ink-gray-9':
|
||||||
</div>
|
evaluation.date == row.date &&
|
||||||
<div class="grid grid-cols-2 gap-2">
|
evaluation.start_time == slot.start_time,
|
||||||
<div v-for="slot in slots.data">
|
}"
|
||||||
<div
|
>
|
||||||
class="text-base text-center border rounded-md text-ink-gray-8 bg-surface-gray-3 p-2 cursor-pointer"
|
{{ formatTime(slot.start_time) }} -
|
||||||
@click="saveSlot(slot)"
|
{{ formatTime(slot.end_time) }}
|
||||||
:class="{
|
</div>
|
||||||
'border-outline-gray-4':
|
|
||||||
evaluation.start_time == slot.start_time,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ formatTime(slot.start_time) }} -
|
|
||||||
{{ formatTime(slot.end_time) }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-else-if="!evaluation.course" class="text-ink-gray-7">
|
||||||
v-else-if="evaluation.course && evaluation.date"
|
{{ __('Please select a course to view available slots.') }}
|
||||||
class="text-sm italic text-ink-red-4"
|
</div>
|
||||||
>
|
<div v-else class="text-ink-red-3">
|
||||||
{{ __('No slots available for this date.') }}
|
{{ __('No slots available for the selected course.') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, createResource, Select, FormControl, toast } from 'frappe-ui'
|
import {
|
||||||
import { reactive, watch, inject } from 'vue'
|
call,
|
||||||
|
createResource,
|
||||||
|
dayjs,
|
||||||
|
Dialog,
|
||||||
|
FormControl,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { ref, watch, inject } from 'vue'
|
||||||
|
import { Calendar } from 'lucide-vue-next'
|
||||||
import { formatTime } from '@/utils/'
|
import { formatTime } from '@/utils/'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const dayjs = inject('$dayjs')
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const evaluations = defineModel('reloadEvals')
|
const evaluations = defineModel('reloadEvals')
|
||||||
|
|
||||||
@@ -90,7 +97,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const evaluation = reactive({
|
const evaluation = ref({
|
||||||
course: '',
|
course: '',
|
||||||
date: '',
|
date: '',
|
||||||
start_time: '',
|
start_time: '',
|
||||||
@@ -100,48 +107,28 @@ const evaluation = reactive({
|
|||||||
member: user.data.name,
|
member: user.data.name,
|
||||||
})
|
})
|
||||||
|
|
||||||
const createEvaluation = createResource({
|
|
||||||
url: 'frappe.client.insert',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doc: {
|
|
||||||
doctype: 'LMS Certificate Request',
|
|
||||||
batch_name: values.batch,
|
|
||||||
...values,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
function submitEvaluation(close) {
|
function submitEvaluation(close) {
|
||||||
createEvaluation.submit(evaluation, {
|
if (!evaluation.value.date || !evaluation.value.start_time) {
|
||||||
validate() {
|
toast.warning(__('Please select a slot for your evaluation.'), {
|
||||||
if (!evaluation.course) {
|
duration: 10,
|
||||||
return 'Please select a course.'
|
})
|
||||||
}
|
return
|
||||||
if (!evaluation.date) {
|
}
|
||||||
return 'Please select a date.'
|
call('frappe.client.insert', {
|
||||||
}
|
doc: {
|
||||||
if (!evaluation.start_time) {
|
doctype: 'LMS Certificate Request',
|
||||||
return 'Please select a slot.'
|
batch_name: evaluation.value.batch,
|
||||||
}
|
...evaluation.value,
|
||||||
if (dayjs(evaluation.date).isBefore(dayjs(), 'day')) {
|
|
||||||
return 'Please select a future date.'
|
|
||||||
}
|
|
||||||
if (dayjs(evaluation.date).isAfter(dayjs(props.endDate), 'day')) {
|
|
||||||
return `Please select a date before the end date ${dayjs(
|
|
||||||
props.endDate
|
|
||||||
).format('DD MMMM YYYY')}.`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess() {
|
|
||||||
evaluations.value.reload()
|
|
||||||
close()
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
toast.warning(__(err.messages?.[0] || err))
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
.then(() => {
|
||||||
|
evaluations.value.reload()
|
||||||
|
close()
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(err.messages?.[0] || err)
|
||||||
|
toast.warning(__(err.messages?.[0] || err), { duration: 20 })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCourses = () => {
|
const getCourses = () => {
|
||||||
@@ -156,7 +143,7 @@ const getCourses = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (courses.length === 1) {
|
if (courses.length === 1) {
|
||||||
evaluation.course = courses[0].value
|
evaluation.value.course = courses[0].value
|
||||||
}
|
}
|
||||||
|
|
||||||
return courses
|
return courses
|
||||||
@@ -167,34 +154,22 @@ const slots = createResource({
|
|||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
return {
|
return {
|
||||||
course: values.course,
|
course: values.course,
|
||||||
date: values.date,
|
|
||||||
batch: props.batch,
|
batch: props.batch,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => evaluation.date,
|
() => evaluation.value.course,
|
||||||
(date) => {
|
|
||||||
evaluation.start_time = ''
|
|
||||||
if (date && evaluation.course) {
|
|
||||||
slots.submit(evaluation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => evaluation.course,
|
|
||||||
(course) => {
|
(course) => {
|
||||||
evaluation.date = ''
|
slots.reload(evaluation.value)
|
||||||
evaluation.start_time = ''
|
|
||||||
slots.reset()
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const saveSlot = (slot) => {
|
const saveSlot = (slot, row) => {
|
||||||
evaluation.start_time = slot.start_time
|
evaluation.value.start_time = slot.start_time
|
||||||
evaluation.end_time = slot.end_time
|
evaluation.value.end_time = slot.end_time
|
||||||
evaluation.day = slot.day
|
evaluation.value.date = row.date
|
||||||
|
evaluation.value.day = row.day
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -22,7 +22,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip :text="__('Course')">
|
<Tooltip :text="__('Course')">
|
||||||
<div class="flex items-center space-x-2 w-fit">
|
<div
|
||||||
|
class="flex space-x-2 w-fit cursor-pointer"
|
||||||
|
@click="openLink('course', event.course)"
|
||||||
|
>
|
||||||
<BookOpen class="h-4 w-4 stroke-1.5" />
|
<BookOpen class="h-4 w-4 stroke-1.5" />
|
||||||
<span>
|
<span>
|
||||||
{{ event.course_title }}
|
{{ event.course_title }}
|
||||||
@@ -30,7 +33,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip v-if="event.batch_title" :text="__('Batch')">
|
<Tooltip v-if="event.batch_title" :text="__('Batch')">
|
||||||
<div class="flex items-center space-x-2 w-fit">
|
<div
|
||||||
|
class="flex space-x-2 w-fit cursor-pointer"
|
||||||
|
@click="openLink('batch', event.batch_name)"
|
||||||
|
>
|
||||||
<Users class="h-4 w-4 stroke-1.5" />
|
<Users class="h-4 w-4 stroke-1.5" />
|
||||||
<span>
|
<span>
|
||||||
{{ event.batch_title }}
|
{{ event.batch_title }}
|
||||||
@@ -189,6 +195,8 @@ const user = inject('$user')
|
|||||||
const dayjs = inject('$dayjs')
|
const dayjs = inject('$dayjs')
|
||||||
const tabIndex = ref(0)
|
const tabIndex = ref(0)
|
||||||
const showCertification = ref(false)
|
const showCertification = ref(false)
|
||||||
|
const evaluation = reactive({})
|
||||||
|
const certificate = reactive({})
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
event: {
|
event: {
|
||||||
@@ -197,9 +205,6 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const evaluation = reactive({})
|
|
||||||
const certificate = reactive({})
|
|
||||||
|
|
||||||
watch(user, () => {
|
watch(user, () => {
|
||||||
if (userIsEvaluator()) {
|
if (userIsEvaluator()) {
|
||||||
defaultTemplate.reload()
|
defaultTemplate.reload()
|
||||||
@@ -335,7 +340,7 @@ const certificateDetails = createResource({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
certificate.template = defaultTemplate.data.value
|
certificate.template = defaultTemplate.data?.value
|
||||||
},
|
},
|
||||||
auto: false,
|
auto: false,
|
||||||
})
|
})
|
||||||
@@ -378,6 +383,16 @@ const openCertificate = (certificate) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openLink = (type, name) => {
|
||||||
|
let url = ''
|
||||||
|
if (type === 'course') {
|
||||||
|
url = `/lms/courses/${name}`
|
||||||
|
} else if (type === 'batch') {
|
||||||
|
url = `/lms/batches/${name}#students`
|
||||||
|
}
|
||||||
|
window.open(url, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
const statusOptions = computed(() => {
|
const statusOptions = computed(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="p-5 min-h-[300px]">
|
<div class="p-5 min-h-[300px]">
|
||||||
<div class="text-lg font-semibold mb-4">
|
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||||
{{ __('Training Feedback') }}
|
{{ __('Training Feedback') }}
|
||||||
</div>
|
</div>
|
||||||
<ListView
|
<ListView
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body-content>
|
<template #body-content>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4 text-base">
|
||||||
<p class="text-ink-gray-9">
|
<p class="text-ink-gray-9">
|
||||||
{{
|
{{
|
||||||
__(
|
__(
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
<FileUploader
|
<FileUploader
|
||||||
:fileTypes="['.pdf']"
|
:fileTypes="['.pdf']"
|
||||||
:validateFile="validateFile"
|
:validateFile="validateFile"
|
||||||
|
:uploadArgs="{ private: 1 }"
|
||||||
@success="
|
@success="
|
||||||
(file) => {
|
(file) => {
|
||||||
resume = file
|
resume = file
|
||||||
@@ -38,6 +39,9 @@
|
|||||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||||
<div class="">
|
<div class="">
|
||||||
<Button @click="openFileSelector" :loading="uploading">
|
<Button @click="openFileSelector" :loading="uploading">
|
||||||
|
<template #prefix>
|
||||||
|
<Upload class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
{{
|
{{
|
||||||
uploading ? `Uploading ${progress}%` : 'Upload your resume'
|
uploading ? `Uploading ${progress}%` : 'Upload your resume'
|
||||||
}}
|
}}
|
||||||
@@ -65,7 +69,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, FileUploader, Button, createResource, toast } from 'frappe-ui'
|
import { Dialog, FileUploader, Button, createResource, toast } from 'frappe-ui'
|
||||||
import { FileText } from 'lucide-vue-next'
|
import { FileText, Upload } from 'lucide-vue-next'
|
||||||
import { ref, inject } from 'vue'
|
import { ref, inject } from 'vue'
|
||||||
import { getFileSize } from '@/utils/'
|
import { getFileSize } from '@/utils/'
|
||||||
|
|
||||||
@@ -95,7 +99,7 @@ const jobApplication = createResource({
|
|||||||
doc: {
|
doc: {
|
||||||
doctype: 'LMS Job Application',
|
doctype: 'LMS Job Application',
|
||||||
user: user.data?.name,
|
user: user.data?.name,
|
||||||
resume: resume.value?.file_name,
|
resume: resume.value?.file_url,
|
||||||
job: props.job,
|
job: props.job,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,16 +84,10 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import { Dialog, createResource, Tooltip, FormControl, toast } from 'frappe-ui'
|
||||||
Dialog,
|
|
||||||
createResource,
|
|
||||||
Tooltip,
|
|
||||||
FormControl,
|
|
||||||
Autocomplete,
|
|
||||||
toast,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { reactive, inject, onMounted } from 'vue'
|
import { reactive, inject, onMounted } from 'vue'
|
||||||
import { getTimezones, getUserTimezone } from '@/utils/'
|
import { getTimezones, getUserTimezone } from '@/utils/'
|
||||||
|
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
||||||
|
|
||||||
const liveClasses = defineModel('reloadLiveClasses')
|
const liveClasses = defineModel('reloadLiveClasses')
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog
|
<Dialog
|
||||||
v-model="show"
|
v-model="show"
|
||||||
class="text-base"
|
|
||||||
:options="{
|
:options="{
|
||||||
title: __('Add web page to sidebar'),
|
title: __('Add web page to sidebar'),
|
||||||
size: 'lg',
|
size: 'lg',
|
||||||
@@ -17,15 +16,17 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body-content>
|
<template #body-content>
|
||||||
<Link
|
<div class="text-base">
|
||||||
v-model="page.webpage"
|
<Link
|
||||||
doctype="Web Page"
|
v-model="page.webpage"
|
||||||
:label="__('Web Page')"
|
doctype="Web Page"
|
||||||
:filters="{
|
:label="__('Web Page')"
|
||||||
published: 1,
|
:filters="{
|
||||||
}"
|
published: 1,
|
||||||
/>
|
}"
|
||||||
<IconPicker v-model="page.icon" :label="__('Icon')" class="mt-4" />
|
/>
|
||||||
|
<IconPicker v-model="page.icon" :label="__('Icon')" class="mt-4" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
@change="(val) => (question.question = val)"
|
@change="(val) => (question.question = val)"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
:fixedMenu="true"
|
:fixedMenu="true"
|
||||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-8 mt-4">
|
<div class="grid grid-cols-2 gap-8 mt-4">
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
<Dialog
|
<Dialog
|
||||||
v-model="show"
|
v-model="show"
|
||||||
:options="{
|
:options="{
|
||||||
title: __('Add a Student'),
|
title: __('Enroll a Student'),
|
||||||
size: 'sm',
|
size: 'lg',
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
label: 'Submit',
|
label: 'Submit',
|
||||||
@@ -18,10 +18,25 @@
|
|||||||
<Link
|
<Link
|
||||||
doctype="User"
|
doctype="User"
|
||||||
v-model="student"
|
v-model="student"
|
||||||
:filters="{ ignore_user_type: 1 }"
|
placeholder=" "
|
||||||
|
:label="__('Student')"
|
||||||
:onCreate="
|
:onCreate="
|
||||||
(value, close) => {
|
() => {
|
||||||
openSettings('Members', close)
|
openSettings('Members')
|
||||||
|
show = false
|
||||||
|
}
|
||||||
|
"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
doctype="LMS Payment"
|
||||||
|
v-model="payment"
|
||||||
|
placeholder=" "
|
||||||
|
:label="__('Payment')"
|
||||||
|
:onCreate="
|
||||||
|
() => {
|
||||||
|
openSettings('Transactions')
|
||||||
|
show = false
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
@@ -30,54 +45,49 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, createResource, toast } from 'frappe-ui'
|
import { call, Dialog, toast } from 'frappe-ui'
|
||||||
import { ref, inject } from 'vue'
|
import { ref, inject } from 'vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
import { openSettings } from '@/utils'
|
import { openSettings } from '@/utils'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
const students = defineModel('reloadStudents')
|
const student = ref(null)
|
||||||
const batchModal = defineModel('batchModal')
|
const payment = ref(null)
|
||||||
const student = ref()
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const { updateOnboardingStep } = useOnboarding('learning')
|
const { updateOnboardingStep } = useOnboarding('learning')
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
type: String,
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
students: {
|
||||||
|
type: Object,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const studentResource = createResource({
|
|
||||||
url: 'frappe.client.insert',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doc: {
|
|
||||||
doctype: 'LMS Batch Enrollment',
|
|
||||||
batch: props.batch,
|
|
||||||
member: student.value,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const addStudent = (close) => {
|
const addStudent = (close) => {
|
||||||
studentResource.submit(
|
props.students.insert.submit(
|
||||||
{},
|
{
|
||||||
|
member: student.value,
|
||||||
|
payment: payment.value,
|
||||||
|
batch: props.batch.data?.name,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
if (user.data?.is_system_manager)
|
if (user.data?.is_system_manager)
|
||||||
updateOnboardingStep('add_batch_student')
|
updateOnboardingStep('add_batch_student')
|
||||||
|
|
||||||
students.value.reload()
|
|
||||||
batchModal.value.reload()
|
|
||||||
student.value = null
|
student.value = null
|
||||||
|
payment.value = null
|
||||||
|
props.batch.reload()
|
||||||
close()
|
close()
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
toast.error(err.messages?.[0] || err)
|
toast.error(err.messages?.[0] || err)
|
||||||
|
console.error(err)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user