commit 51fca0a7de887ddc9ddaf27561e84e475b858dda Author: srikanth01234 Date: Mon Sep 28 11:03:02 2026 +0530 first commit diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..1ed2a24 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,20 @@ +{ + "permissions": { + "allow": [ + "PowerShell(docker restart odoo_dance 2>&1)", + "PowerShell(docker exec odoo_dance odoo -c /etc/odoo/odoo.conf -d dance_school_erp --update dance_theme --stop-after-init 2>&1)", + "PowerShell(docker restart odoo_dance)", + "mcp__odoo-mcp__bulk_delete_records", + "Bash(ls -la dance_odoo)", + "Bash(ls -la dance-school-saas)", + "Bash(docker compose *)", + "Bash(curl -s -o /dev/null -w \"/enroll -> %{http_code}\\\\n\" http://localhost:11000/enroll)", + "Bash(curl -s -o /dev/null -w \"/ -> %{http_code}\\\\n\" http://localhost:11000/)", + "Bash(curl -s -o /dev/null -w \"/enroll/thank-you -> %{http_code}\\\\n\" http://localhost:11000/enroll/thank-you)", + "Bash(curl -s http://localhost:11000/enroll)", + "Bash(curl -s -o /dev/null -w \"%{http_code} %{redirect_url}\\\\n\" http://localhost:11000/parent)", + "Bash(curl -s -o /dev/null -w \"%{http_code} %{redirect_url}\\\\n\" http://localhost:11000/teacher)", + "Bash(curl -s -o /dev/null -w \"%{http_code} %{redirect_url}\\\\n\" http://localhost:11000/dashboard)" + ] + } +} diff --git a/.env b/.env new file mode 100644 index 0000000..0951ddb --- /dev/null +++ b/.env @@ -0,0 +1,12 @@ +POSTGRES_DB=postgres +POSTGRES_USER=odoo +POSTGRES_PASSWORD=odoo + +ODOO_DB_HOST=db +ODOO_HTTP_PORT=11000 +ODOO_LIST_DB=True +ODOO_ADMIN_PASSWORD=admin + +ODOO_CONTAINER_NAME=odoo_dance +DB_CONTAINER_NAME=odoo_dance_db +ODOO_DATABASE=Odoo_dance diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a45f326 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +POSTGRES_DB=postgres +POSTGRES_USER=odoo +POSTGRES_PASSWORD=change_me + +ODOO_DB_HOST=db +ODOO_HTTP_PORT=11000 +ODOO_LIST_DB=True +ODOO_ADMIN_PASSWORD=change_me + +ODOO_CONTAINER_NAME=odoo_dance +DB_CONTAINER_NAME=odoo_dance_db +ODOO_DATABASE=Odoo_dance diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..87d6f38 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,17 @@ +{ + "mcpServers": { + "odoo-mcp": { + "type": "stdio", + "command": "py", + "args": ["-3.11", "-m", "odoo_mcp.server"], + "cwd": "${workspaceFolder}", + "env": { + "PYTHONPATH": "E:/MCS_Repos/2026/ODOO-MCP/src", + "ODOO_URL": "http://localhost:11000/", + "ODOO_DATABASE": "sri__odoo", + "ODOO_USERNAME": "srikanth1522003@gmail.com", + "ODOO_PASSWORD": "123456" + } + } + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a17a9e1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM odoo:17.0 + +USER root + +RUN pip3 install openpyxl==3.1.5 + +USER odoo diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d10e43 --- /dev/null +++ b/README.md @@ -0,0 +1,209 @@ +# Dance Odoo + +Dance ERP built on Odoo 17. + +## Current Scope + +The first implemented modules are `dance_student` and `dance_batch`. Students extend `res.partner` instead of using a separate student table, so they remain compatible with Odoo contacts, invoices, messages, activities, and future accounting flows. + +## Run Locally + +```powershell +docker compose up -d +``` + +Odoo runs at: + +```text +http://localhost:11000 +``` + +The configured database is: + +```text +Odoo_dance +``` + +## Student Module + +Installed addon: + +```text +addons/dance_student +``` + +It adds: + +- `is_student` +- `student_code` +- `guardian_id` +- `guardian_ids` +- `guardian_phone` +- `date_of_birth` +- `age` +- `gender` +- `dance_style` +- `level` +- `student_status` +- `join_date` +- `medical_notes` + +Students are available from: + +```text +Dance ERP > Students +``` + +## Batch Module + +Installed addon: + +```text +addons/dance_batch +``` + +It adds: + +- `dance.batch` +- `dance.enrollment` + +The relationship is: + +```text +Student -> Enrollment -> Batch +``` + +This allows one student to join multiple batches and one batch to contain many students. The module also tracks instructor, room, dates, timing, capacity, current strength, and enrollment status. + +Batches are available from: + +```text +Dance ERP > Batches +``` + +Enrollments are available from: + +```text +Dance ERP > Enrollments +``` + +## Session And Attendance Module + +Installed addon: + +```text +addons/dance_attendance +``` + +It adds: + +- `dance.session` +- `dance.attendance` + +The relationship is: + +```text +Batch -> Session -> Attendance -> Student +``` + +Sessions are available from: + +```text +Dance ERP > Sessions +``` + +Attendance records are available from: + +```text +Dance ERP > Attendance +``` + +Daily flow: + +```text +Open today's session +Load Students +Mark Present / Absent / Late +Complete session +``` + +## Fees Module + +Installed addon: + +```text +addons/dance_fees +``` + +It adds: + +- `dance.fee.structure` +- Fee links on batches +- Manual invoice generation from enrollments and batches +- Dance metadata on Odoo invoices + +The relationship is: + +```text +Student -> Enrollment -> Batch -> Fee Structure -> Invoice -> Payment +``` + +Main places: + +```text +Dance ERP > Fee Structures +Dance ERP > Batches +Dance ERP > Enrollments +Accounting > Customers > Invoices +``` + +Manual billing flow: + +```text +Set a fee structure on the batch +Open an active enrollment +Click Create Invoice +Register payment in Odoo +``` + +## Useful Scripts + +Update the student module: + +```powershell +.\scripts\update_module.ps1 dance_student +``` + +Update the batch module: + +```powershell +.\scripts\update_module.ps1 dance_batch +``` + +Update the attendance module: + +```powershell +.\scripts\update_module.ps1 dance_attendance +``` + +Update the fees module: + +```powershell +.\scripts\update_module.ps1 dance_fees +``` + +Create a database backup: + +```powershell +.\scripts\backup_db.ps1 +``` + +Restore a database backup: + +```powershell +.\scripts\restore_db.ps1 -BackupFile .\backups\Odoo_dance_YYYYMMDD_HHMMSS.sql +``` + +## Nginx + +`nginx/odoo.conf` contains a reverse proxy configuration for Odoo. It is not enabled as a Docker service yet, so the current local port setup stays simple and unchanged. diff --git a/addons/dance_attendance/__init__.py b/addons/dance_attendance/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/addons/dance_attendance/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/dance_attendance/__manifest__.py b/addons/dance_attendance/__manifest__.py new file mode 100644 index 0000000..ee30ad2 --- /dev/null +++ b/addons/dance_attendance/__manifest__.py @@ -0,0 +1,17 @@ +{ + "name": "Dance Session Attendance", + "summary": "Class sessions and attendance tracking for dance batches", + "version": "17.0.1.0.0", + "category": "Education", + "author": "Metetroncube", + "license": "LGPL-3", + "depends": ["dance_batch"], + "data": [ + "security/ir.model.access.csv", + "data/sample_data.xml", + "views/dance_session_views.xml", + "views/dance_attendance_views.xml", + ], + "application": False, + "installable": True, +} diff --git a/addons/dance_attendance/__pycache__/__init__.cpython-310.pyc b/addons/dance_attendance/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..06a4cce Binary files /dev/null and b/addons/dance_attendance/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/dance_attendance/__pycache__/__init__.cpython-311.pyc b/addons/dance_attendance/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..becc7c3 Binary files /dev/null and b/addons/dance_attendance/__pycache__/__init__.cpython-311.pyc differ diff --git a/addons/dance_attendance/__pycache__/__manifest__.cpython-311.pyc b/addons/dance_attendance/__pycache__/__manifest__.cpython-311.pyc new file mode 100644 index 0000000..bad144c Binary files /dev/null and b/addons/dance_attendance/__pycache__/__manifest__.cpython-311.pyc differ diff --git a/addons/dance_attendance/data/sample_data.xml b/addons/dance_attendance/data/sample_data.xml new file mode 100644 index 0000000..e88201d --- /dev/null +++ b/addons/dance_attendance/data/sample_data.xml @@ -0,0 +1,21 @@ + + + + + 2026-05-02 + 10.0 + 11.0 + + scheduled + + + + + 2026-05-04 + 18.0 + 19.0 + + scheduled + + + diff --git a/addons/dance_attendance/models/__init__.py b/addons/dance_attendance/models/__init__.py new file mode 100644 index 0000000..8bbd2cd --- /dev/null +++ b/addons/dance_attendance/models/__init__.py @@ -0,0 +1,2 @@ +from . import dance_session +from . import dance_attendance diff --git a/addons/dance_attendance/models/__pycache__/__init__.cpython-310.pyc b/addons/dance_attendance/models/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..7b2eb0a Binary files /dev/null and b/addons/dance_attendance/models/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/dance_attendance/models/__pycache__/__init__.cpython-311.pyc b/addons/dance_attendance/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..b72090b Binary files /dev/null and b/addons/dance_attendance/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/addons/dance_attendance/models/__pycache__/dance_attendance.cpython-310.pyc b/addons/dance_attendance/models/__pycache__/dance_attendance.cpython-310.pyc new file mode 100644 index 0000000..c20cc72 Binary files /dev/null and b/addons/dance_attendance/models/__pycache__/dance_attendance.cpython-310.pyc differ diff --git a/addons/dance_attendance/models/__pycache__/dance_attendance.cpython-311.pyc b/addons/dance_attendance/models/__pycache__/dance_attendance.cpython-311.pyc new file mode 100644 index 0000000..8b7df73 Binary files /dev/null and b/addons/dance_attendance/models/__pycache__/dance_attendance.cpython-311.pyc differ diff --git a/addons/dance_attendance/models/__pycache__/dance_session.cpython-310.pyc b/addons/dance_attendance/models/__pycache__/dance_session.cpython-310.pyc new file mode 100644 index 0000000..4e413be Binary files /dev/null and b/addons/dance_attendance/models/__pycache__/dance_session.cpython-310.pyc differ diff --git a/addons/dance_attendance/models/__pycache__/dance_session.cpython-311.pyc b/addons/dance_attendance/models/__pycache__/dance_session.cpython-311.pyc new file mode 100644 index 0000000..bd0dec6 Binary files /dev/null and b/addons/dance_attendance/models/__pycache__/dance_session.cpython-311.pyc differ diff --git a/addons/dance_attendance/models/dance_attendance.py b/addons/dance_attendance/models/dance_attendance.py new file mode 100644 index 0000000..c2c2ce7 --- /dev/null +++ b/addons/dance_attendance/models/dance_attendance.py @@ -0,0 +1,92 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class DanceAttendance(models.Model): + _name = "dance.attendance" + _description = "Dance Attendance" + _order = "session_id desc, student_id" + + student_id = fields.Many2one( + "res.partner", + required=True, + domain="[('is_student', '=', True)]", + ondelete="restrict", + ) + session_id = fields.Many2one( + "dance.session", + required=True, + ondelete="cascade", + ) + batch_id = fields.Many2one( + related="session_id.batch_id", + store=True, + readonly=True, + ) + date = fields.Date(related="session_id.date", store=True, readonly=True) + status = fields.Selection( + [ + ("present", "Present"), + ("absent", "Absent"), + ("late", "Late"), + ], + default="present", + required=True, + ) + attendance_percent = fields.Float( + string="Attendance %", + compute="_compute_report_measures", + store=True, + group_operator="avg", + ) + present_count = fields.Integer( + string="Present/Late", + compute="_compute_report_measures", + store=True, + ) + absent_count = fields.Integer( + string="Absentees", + compute="_compute_report_measures", + store=True, + ) + total_count = fields.Integer( + string="Sessions", + compute="_compute_report_measures", + store=True, + ) + remarks = fields.Text() + + _sql_constraints = [ + ( + "unique_attendance", + "unique(student_id, session_id)", + "Attendance is already marked for this student in this session.", + ), + ] + + @api.depends("status") + def _compute_report_measures(self): + for attendance in self: + is_absent = attendance.status == "absent" + attendance.attendance_percent = 0.0 if is_absent else 100.0 + attendance.present_count = 0 if is_absent else 1 + attendance.absent_count = 1 if is_absent else 0 + attendance.total_count = 1 + + @api.constrains("student_id", "session_id") + def _check_student_in_batch(self): + for attendance in self: + if not attendance.student_id or not attendance.session_id: + continue + enrollment = self.env["dance.enrollment"].search( + [ + ("student_id", "=", attendance.student_id.id), + ("batch_id", "=", attendance.session_id.batch_id.id), + ("status", "=", "active"), + ], + limit=1, + ) + if not enrollment: + raise ValidationError( + "Attendance can only be marked for active students in this batch." + ) diff --git a/addons/dance_attendance/models/dance_session.py b/addons/dance_attendance/models/dance_session.py new file mode 100644 index 0000000..d3d1657 --- /dev/null +++ b/addons/dance_attendance/models/dance_session.py @@ -0,0 +1,230 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class DanceSession(models.Model): + _name = "dance.session" + _description = "Dance Class Session" + _order = "date desc, start_time desc, id desc" + + name = fields.Char(compute="_compute_name", store=True) + batch_id = fields.Many2one("dance.batch", required=True, ondelete="restrict") + date = fields.Date(required=True, default=fields.Date.context_today) + start_time = fields.Float() + end_time = fields.Float() + room = fields.Char() + instructor_id = fields.Many2one("hr.employee", string="Instructor") + substitute_instructor_id = fields.Many2one( + "hr.employee", + string="Substitute Instructor", + ) + is_cancelled = fields.Boolean(string="Cancelled") + cancellation_reason = fields.Text() + makeup_for_id = fields.Many2one( + "dance.session", + string="Makeup For", + domain="[('status', '=', 'cancelled')]", + ondelete="set null", + ) + makeup_session_ids = fields.One2many( + "dance.session", + "makeup_for_id", + string="Makeup Sessions", + ) + status = fields.Selection( + [ + ("scheduled", "Scheduled"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ], + default="scheduled", + required=True, + ) + notes = fields.Text() + + attendance_ids = fields.One2many( + "dance.attendance", + "session_id", + string="Attendance", + ) + attendance_count = fields.Integer(compute="_compute_attendance_counts") + present_count = fields.Integer(compute="_compute_attendance_counts") + absent_count = fields.Integer(compute="_compute_attendance_counts") + late_count = fields.Integer(compute="_compute_attendance_counts") + + @api.depends("batch_id", "date", "start_time") + def _compute_name(self): + for session in self: + if session.batch_id and session.date: + session.name = "%s - %s" % (session.batch_id.name, session.date) + else: + session.name = "New Session" + + @api.depends("attendance_ids.status") + def _compute_attendance_counts(self): + for session in self: + session.attendance_count = len(session.attendance_ids) + session.present_count = len( + session.attendance_ids.filtered(lambda line: line.status == "present") + ) + session.absent_count = len( + session.attendance_ids.filtered(lambda line: line.status == "absent") + ) + session.late_count = len( + session.attendance_ids.filtered(lambda line: line.status == "late") + ) + + @api.onchange("batch_id") + def _onchange_batch_id(self): + for session in self: + if session.batch_id: + session.instructor_id = session.batch_id.instructor_id + session.start_time = session.batch_id.start_time + session.end_time = session.batch_id.end_time + session.room = session.batch_id.room + + @api.constrains("batch_id", "date") + def _check_date_inside_batch(self): + for session in self: + batch = session.batch_id + if not batch or not session.date: + continue + if batch.start_date and session.date < batch.start_date: + raise ValidationError("Session date cannot be before batch start date.") + if batch.end_date and session.date > batch.end_date: + raise ValidationError("Session date cannot be after batch end date.") + + @api.constrains("start_time", "end_time") + def _check_times(self): + for session in self: + if session.end_time and session.start_time and session.end_time <= session.start_time: + raise ValidationError("End time must be after start time.") + + @api.constrains("date", "start_time", "end_time", "room", "instructor_id", "substitute_instructor_id", "status") + def _check_room_and_instructor_conflicts(self): + for session in self: + if ( + session.status == "cancelled" + or not session.date + or not session.start_time + or not session.end_time + ): + continue + + overlap_domain = [ + ("id", "!=", session.id), + ("date", "=", session.date), + ("status", "!=", "cancelled"), + ("start_time", "<", session.end_time), + ("end_time", ">", session.start_time), + ] + if session.room: + room_conflict = self.search(overlap_domain + [("room", "=", session.room)], limit=1) + if room_conflict: + raise ValidationError( + "Room conflict: %s is already booked for %s from %.2f to %.2f." + % ( + session.room, + room_conflict.name, + room_conflict.start_time, + room_conflict.end_time, + ) + ) + teaching_instructor = session.substitute_instructor_id or session.instructor_id + if teaching_instructor: + instructor_conflict = self.search( + overlap_domain + + [ + "|", + ("instructor_id", "=", teaching_instructor.id), + ("substitute_instructor_id", "=", teaching_instructor.id), + ], + limit=1, + ) + if instructor_conflict: + raise ValidationError( + "Instructor conflict: %s already has %s from %.2f to %.2f." + % ( + teaching_instructor.name, + instructor_conflict.name, + instructor_conflict.start_time, + instructor_conflict.end_time, + ) + ) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get("batch_id"): + batch = self.env["dance.batch"].browse(vals["batch_id"]) + vals.setdefault("instructor_id", batch.instructor_id.id) + vals.setdefault("start_time", batch.start_time) + vals.setdefault("end_time", batch.end_time) + vals.setdefault("room", batch.room) + return super().create(vals_list) + + def write(self, vals): + if vals.get("is_cancelled"): + vals = dict(vals, status="cancelled") + elif vals.get("status") == "cancelled": + vals = dict(vals, is_cancelled=True) + return super().write(vals) + + def action_load_students(self): + for session in self: + active_enrollments = self.env["dance.enrollment"].search( + [ + ("batch_id", "=", session.batch_id.id), + ("status", "=", "active"), + ] + ) + existing_student_ids = set(session.attendance_ids.mapped("student_id").ids) + attendance_vals = [] + for enrollment in active_enrollments: + if enrollment.student_id.id in existing_student_ids: + continue + attendance_vals.append( + { + "session_id": session.id, + "student_id": enrollment.student_id.id, + "status": "present", + } + ) + if attendance_vals: + self.env["dance.attendance"].create(attendance_vals) + return True + + def action_mark_all_present(self): + for session in self: + if not session.attendance_ids: + session.action_load_students() + session.attendance_ids.write({"status": "present"}) + return True + + def action_complete(self): + self.write({"status": "completed"}) + return True + + def action_cancel(self): + self.write({"status": "cancelled", "is_cancelled": True}) + return True + + def action_create_makeup_session(self): + self.ensure_one() + makeup = self.copy( + { + "date": fields.Date.context_today(self), + "status": "scheduled", + "is_cancelled": False, + "cancellation_reason": False, + "makeup_for_id": self.id, + "attendance_ids": False, + } + ) + return { + "name": "Makeup Session", + "type": "ir.actions.act_window", + "res_model": "dance.session", + "res_id": makeup.id, + "view_mode": "form", + } diff --git a/addons/dance_attendance/security/ir.model.access.csv b/addons/dance_attendance/security/ir.model.access.csv new file mode 100644 index 0000000..901f2ed --- /dev/null +++ b/addons/dance_attendance/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_dance_session_user,dance.session user,model_dance_session,base.group_user,1,1,1,1 +access_dance_attendance_user,dance.attendance user,model_dance_attendance,base.group_user,1,1,1,1 diff --git a/addons/dance_attendance/views/dance_attendance_views.xml b/addons/dance_attendance/views/dance_attendance_views.xml new file mode 100644 index 0000000..e85fcce --- /dev/null +++ b/addons/dance_attendance/views/dance_attendance_views.xml @@ -0,0 +1,72 @@ + + + + dance.attendance.tree + dance.attendance + + + + + + + + + + + + + + dance.attendance.form + dance.attendance + +
+ + + + + + + + + + + + + + + + +
+
+
+ + + dance.attendance.search + dance.attendance + + + + + + + + + + + + + + + + + + + + Attendance + dance.attendance + tree,form + + + + +
diff --git a/addons/dance_attendance/views/dance_session_views.xml b/addons/dance_attendance/views/dance_session_views.xml new file mode 100644 index 0000000..7ef3fad --- /dev/null +++ b/addons/dance_attendance/views/dance_session_views.xml @@ -0,0 +1,126 @@ + + + + dance.session.tree + dance.session + + + + + + + + + + + + + + + + + + + dance.session.form + dance.session + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + dance.session.calendar + dance.session + + + + + + + + + + + dance.session.search + dance.session + + + + + + + + + + + + + + + + + + + + + + Sessions + dance.session + tree,form,calendar + + + + +
diff --git a/addons/dance_batch/__init__.py b/addons/dance_batch/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/addons/dance_batch/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/dance_batch/__manifest__.py b/addons/dance_batch/__manifest__.py new file mode 100644 index 0000000..bcd66ec --- /dev/null +++ b/addons/dance_batch/__manifest__.py @@ -0,0 +1,17 @@ +{ + "name": "Dance Batch Management", + "summary": "Batches and enrollments for dance classes", + "version": "17.0.1.0.0", + "category": "Education", + "author": "Metetroncube", + "license": "LGPL-3", + "depends": ["dance_student", "hr"], + "data": [ + "security/ir.model.access.csv", + "data/sample_data.xml", + "views/dance_batch_views.xml", + "views/dance_enrollment_views.xml", + ], + "application": False, + "installable": True, +} diff --git a/addons/dance_batch/__pycache__/__init__.cpython-310.pyc b/addons/dance_batch/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..0436e05 Binary files /dev/null and b/addons/dance_batch/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/dance_batch/__pycache__/__init__.cpython-311.pyc b/addons/dance_batch/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..abe3e03 Binary files /dev/null and b/addons/dance_batch/__pycache__/__init__.cpython-311.pyc differ diff --git a/addons/dance_batch/__pycache__/__manifest__.cpython-311.pyc b/addons/dance_batch/__pycache__/__manifest__.cpython-311.pyc new file mode 100644 index 0000000..13384a0 Binary files /dev/null and b/addons/dance_batch/__pycache__/__manifest__.cpython-311.pyc differ diff --git a/addons/dance_batch/data/sample_data.xml b/addons/dance_batch/data/sample_data.xml new file mode 100644 index 0000000..80e33aa --- /dev/null +++ b/addons/dance_batch/data/sample_data.xml @@ -0,0 +1,116 @@ + + + + Ananya Rao + +91 98765 10001 + ananya.rao@example.com + + + + Ananya Rao + +91 98765 10001 + ananya.rao@example.com + + + + + Rahul Menon + +91 98765 10002 + rahul.menon@example.com + + + + Rahul Menon + +91 98765 10002 + rahul.menon@example.com + + + + + Lakshmi Iyer + +91 98765 20001 + lakshmi.iyer@example.com + + + + Priya Nair + +91 98765 20002 + priya.nair@example.com + + + + Meera Iyer + +91 98765 30001 + meera.iyer@example.com + True + STU-DEMO-001 + + +91 98765 20001 + 2014-08-12 + female + bharatanatyam + beginner + active + 2026-05-01 + + + + Arjun Nair + +91 98765 30002 + arjun.nair@example.com + True + STU-DEMO-002 + + +91 98765 20002 + 2012-11-04 + male + hiphop + intermediate + active + 2026-05-01 + + + + Bharatanatyam Beginner - Sat 10AM + bharatanatyam + beginner + + Studio A + 15 + 2026-05-02 + 2026-08-29 + 10.0 + 11.0 + Sat + active + + + + Hip-Hop Intermediate - Mon/Wed 6PM + hiphop + intermediate + + Studio B + 20 + 2026-05-04 + 2026-08-31 + 18.0 + 19.0 + Mon/Wed + active + + + + + + 2026-05-02 + active + + + + + + 2026-05-04 + active + + diff --git a/addons/dance_batch/models/__init__.py b/addons/dance_batch/models/__init__.py new file mode 100644 index 0000000..1b20d45 --- /dev/null +++ b/addons/dance_batch/models/__init__.py @@ -0,0 +1,2 @@ +from . import dance_batch +from . import dance_enrollment diff --git a/addons/dance_batch/models/__pycache__/__init__.cpython-310.pyc b/addons/dance_batch/models/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..7b4eda3 Binary files /dev/null and b/addons/dance_batch/models/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/dance_batch/models/__pycache__/__init__.cpython-311.pyc b/addons/dance_batch/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..d50f314 Binary files /dev/null and b/addons/dance_batch/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/addons/dance_batch/models/__pycache__/dance_batch.cpython-310.pyc b/addons/dance_batch/models/__pycache__/dance_batch.cpython-310.pyc new file mode 100644 index 0000000..ca2dbe5 Binary files /dev/null and b/addons/dance_batch/models/__pycache__/dance_batch.cpython-310.pyc differ diff --git a/addons/dance_batch/models/__pycache__/dance_batch.cpython-311.pyc b/addons/dance_batch/models/__pycache__/dance_batch.cpython-311.pyc new file mode 100644 index 0000000..b0770d3 Binary files /dev/null and b/addons/dance_batch/models/__pycache__/dance_batch.cpython-311.pyc differ diff --git a/addons/dance_batch/models/__pycache__/dance_enrollment.cpython-310.pyc b/addons/dance_batch/models/__pycache__/dance_enrollment.cpython-310.pyc new file mode 100644 index 0000000..7850298 Binary files /dev/null and b/addons/dance_batch/models/__pycache__/dance_enrollment.cpython-310.pyc differ diff --git a/addons/dance_batch/models/__pycache__/dance_enrollment.cpython-311.pyc b/addons/dance_batch/models/__pycache__/dance_enrollment.cpython-311.pyc new file mode 100644 index 0000000..aa1101c Binary files /dev/null and b/addons/dance_batch/models/__pycache__/dance_enrollment.cpython-311.pyc differ diff --git a/addons/dance_batch/models/dance_batch.py b/addons/dance_batch/models/dance_batch.py new file mode 100644 index 0000000..f5eeb48 --- /dev/null +++ b/addons/dance_batch/models/dance_batch.py @@ -0,0 +1,129 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class DanceBatch(models.Model): + _name = "dance.batch" + _description = "Dance Batch" + _order = "start_date desc, name" + + name = fields.Char(required=True) + dance_style = fields.Selection( + [ + ("bharatanatyam", "Bharatanatyam"), + ("kathak", "Kathak"), + ("hiphop", "Hip-Hop"), + ("contemporary", "Contemporary"), + ("zumba", "Zumba"), + ], + string="Dance Style", + required=True, + ) + level = fields.Selection( + [ + ("beginner", "Beginner"), + ("intermediate", "Intermediate"), + ("advanced", "Advanced"), + ], + required=True, + ) + instructor_id = fields.Many2one("hr.employee", string="Instructor", required=True) + room = fields.Char() + capacity = fields.Integer(default=20) + active_enrollment_count = fields.Integer( + string="Current Strength", + compute="_compute_enrollment_counts", + store=True, + ) + enrollment_count = fields.Integer(compute="_compute_enrollment_counts", store=True) + available_seats = fields.Integer(compute="_compute_enrollment_counts", store=True) + is_almost_full = fields.Boolean(compute="_compute_capacity_warning") + capacity_warning = fields.Char(compute="_compute_capacity_warning") + + start_date = fields.Date(required=True) + end_date = fields.Date() + start_time = fields.Float(string="Start Time") + end_time = fields.Float(string="End Time") + schedule_days = fields.Char( + string="Schedule Days", + help="Example: Sat or Mon/Wed", + ) + + status = fields.Selection( + [ + ("upcoming", "Upcoming"), + ("active", "Active"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ], + default="upcoming", + required=True, + ) + + enrollment_ids = fields.One2many( + "dance.enrollment", + "batch_id", + string="Enrollments", + ) + + @api.depends("enrollment_ids.status", "capacity") + def _compute_enrollment_counts(self): + for batch in self: + active_enrollments = batch.enrollment_ids.filtered( + lambda enrollment: enrollment.status == "active" + ) + batch.active_enrollment_count = len(active_enrollments) + batch.enrollment_count = len(batch.enrollment_ids) + batch.available_seats = max(batch.capacity - len(active_enrollments), 0) + + @api.depends("active_enrollment_count", "capacity", "available_seats") + def _compute_capacity_warning(self): + for batch in self: + batch.is_almost_full = bool( + batch.capacity + and batch.active_enrollment_count < batch.capacity + and batch.available_seats <= 2 + ) + if batch.is_almost_full: + batch.capacity_warning = "Batch almost full (%s/%s)" % ( + batch.active_enrollment_count, + batch.capacity, + ) + elif batch.capacity and batch.active_enrollment_count >= batch.capacity: + batch.capacity_warning = "Batch is full (%s/%s)" % ( + batch.active_enrollment_count, + batch.capacity, + ) + else: + batch.capacity_warning = False + + @api.constrains("capacity") + def _check_capacity(self): + for batch in self: + if batch.capacity < 0: + raise ValidationError("Capacity cannot be negative.") + if batch.capacity and batch.active_enrollment_count > batch.capacity: + raise ValidationError("Capacity cannot be less than current strength.") + + @api.constrains("start_date", "end_date") + def _check_dates(self): + for batch in self: + if batch.end_date and batch.start_date and batch.end_date < batch.start_date: + raise ValidationError("End date cannot be before start date.") + + @api.constrains("start_time", "end_time") + def _check_times(self): + for batch in self: + if batch.end_time and batch.start_time and batch.end_time <= batch.start_time: + raise ValidationError("End time must be after start time.") + + def action_view_enrollments(self): + self.ensure_one() + return { + "name": "Enrollments", + "type": "ir.actions.act_window", + "res_model": "dance.enrollment", + "view_mode": "tree,form", + "domain": [("batch_id", "=", self.id)], + "context": {"default_batch_id": self.id}, + } diff --git a/addons/dance_batch/models/dance_enrollment.py b/addons/dance_batch/models/dance_enrollment.py new file mode 100644 index 0000000..88114c0 --- /dev/null +++ b/addons/dance_batch/models/dance_enrollment.py @@ -0,0 +1,94 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class DanceEnrollment(models.Model): + _name = "dance.enrollment" + _description = "Dance Enrollment" + _order = "enrollment_date desc, id desc" + + name = fields.Char(compute="_compute_name", store=True) + student_id = fields.Many2one( + "res.partner", + required=True, + domain="[('is_student', '=', True)]", + ondelete="restrict", + ) + batch_id = fields.Many2one( + "dance.batch", + required=True, + ondelete="restrict", + ) + enrollment_date = fields.Date(default=fields.Date.context_today, required=True) + status = fields.Selection( + [ + ("active", "Active"), + ("hold", "Hold"), + ("dropped", "Dropped"), + ("completed", "Completed"), + ], + default="active", + required=True, + ) + notes = fields.Text() + + _sql_constraints = [ + ( + "student_batch_unique", + "unique(student_id, batch_id)", + "This student is already enrolled in this batch.", + ), + ] + + @api.depends("student_id", "batch_id") + def _compute_name(self): + for enrollment in self: + if enrollment.student_id and enrollment.batch_id: + enrollment.name = "%s - %s" % ( + enrollment.student_id.display_name, + enrollment.batch_id.name, + ) + else: + enrollment.name = "New Enrollment" + + @api.constrains("batch_id", "status") + def _check_batch_capacity(self): + for enrollment in self: + if enrollment.status != "active" or not enrollment.batch_id.capacity: + continue + active_count = self.search_count( + [ + ("batch_id", "=", enrollment.batch_id.id), + ("status", "=", "active"), + ("id", "!=", enrollment.id), + ] + ) + if active_count + 1 > enrollment.batch_id.capacity: + raise ValidationError("This batch is already at full capacity.") + + @api.constrains("student_id") + def _check_student_flag(self): + for enrollment in self: + if enrollment.student_id and not enrollment.student_id.is_student: + raise ValidationError("Only student contacts can be enrolled in a batch.") + + @api.onchange("batch_id", "status") + def _onchange_batch_capacity_warning(self): + if ( + self.batch_id + and self.status == "active" + and self.batch_id.capacity + and self.batch_id.active_enrollment_count < self.batch_id.capacity + and self.batch_id.available_seats <= 2 + ): + return { + "warning": { + "title": "Batch almost full", + "message": "Batch almost full (%s/%s)." + % ( + self.batch_id.active_enrollment_count, + self.batch_id.capacity, + ), + } + } + return {} diff --git a/addons/dance_batch/security/ir.model.access.csv b/addons/dance_batch/security/ir.model.access.csv new file mode 100644 index 0000000..2bbee92 --- /dev/null +++ b/addons/dance_batch/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_dance_batch_user,dance.batch user,model_dance_batch,base.group_user,1,1,1,1 +access_dance_enrollment_user,dance.enrollment user,model_dance_enrollment,base.group_user,1,1,1,1 diff --git a/addons/dance_batch/views/dance_batch_views.xml b/addons/dance_batch/views/dance_batch_views.xml new file mode 100644 index 0000000..da98281 --- /dev/null +++ b/addons/dance_batch/views/dance_batch_views.xml @@ -0,0 +1,106 @@ + + + + dance.batch.tree + dance.batch + + + + + + + + + + + + + + + + + dance.batch.form + dance.batch + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + dance.batch.search + dance.batch + + + + + + + + + + + + + + + + + + + + Batches + dance.batch + tree,form + + + + +
diff --git a/addons/dance_batch/views/dance_enrollment_views.xml b/addons/dance_batch/views/dance_enrollment_views.xml new file mode 100644 index 0000000..03e5e7e --- /dev/null +++ b/addons/dance_batch/views/dance_enrollment_views.xml @@ -0,0 +1,67 @@ + + + + dance.enrollment.tree + dance.enrollment + + + + + + + + + + + + dance.enrollment.form + dance.enrollment + +
+ + + + + + + + + + + + + + + +
+
+
+ + + dance.enrollment.search + dance.enrollment + + + + + + + + + + + + + + + + + + Enrollments + dance.enrollment + tree,form + + + + +
diff --git a/addons/dance_event/__init__.py b/addons/dance_event/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/addons/dance_event/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/dance_event/__manifest__.py b/addons/dance_event/__manifest__.py new file mode 100644 index 0000000..76f57e3 --- /dev/null +++ b/addons/dance_event/__manifest__.py @@ -0,0 +1,18 @@ +{ + "name": "Dance Event & Recital Management", + "summary": "Events, performances, rehearsals, costumes, and recital WhatsApp updates", + "version": "17.0.1.0.0", + "category": "Education", + "author": "Metetroncube", + "license": "LGPL-3", + "depends": ["dance_batch", "dance_guardian", "dance_whatsapp"], + "data": [ + "security/ir.model.access.csv", + "data/whatsapp_template_data.xml", + "views/dance_event_views.xml", + "views/dance_event_performance_views.xml", + "views/dance_rehearsal_views.xml", + ], + "application": False, + "installable": True, +} diff --git a/addons/dance_event/__pycache__/__init__.cpython-310.pyc b/addons/dance_event/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..05e63b3 Binary files /dev/null and b/addons/dance_event/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/dance_event/__pycache__/__init__.cpython-311.pyc b/addons/dance_event/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..3c43d9f Binary files /dev/null and b/addons/dance_event/__pycache__/__init__.cpython-311.pyc differ diff --git a/addons/dance_event/__pycache__/__manifest__.cpython-311.pyc b/addons/dance_event/__pycache__/__manifest__.cpython-311.pyc new file mode 100644 index 0000000..b91176e Binary files /dev/null and b/addons/dance_event/__pycache__/__manifest__.cpython-311.pyc differ diff --git a/addons/dance_event/data/whatsapp_template_data.xml b/addons/dance_event/data/whatsapp_template_data.xml new file mode 100644 index 0000000..b341bb1 --- /dev/null +++ b/addons/dance_event/data/whatsapp_template_data.xml @@ -0,0 +1,20 @@ + + + + Event Notification + event_notification + {event} is scheduled on {date} at {venue}. Please keep {student} ready for participation. + + + + Rehearsal Reminder + rehearsal_reminder + {event} rehearsal is on {rehearsal_date} at {rehearsal_time} in {room}. Please ensure {student} attends. + + + + Costume Update + costume_update + {event} costume update for {student}. Please contact the academy for fitting or collection details. + + diff --git a/addons/dance_event/models/__init__.py b/addons/dance_event/models/__init__.py new file mode 100644 index 0000000..000db5a --- /dev/null +++ b/addons/dance_event/models/__init__.py @@ -0,0 +1,4 @@ +from . import dance_event +from . import dance_event_performance +from . import dance_rehearsal +from . import dance_whatsapp diff --git a/addons/dance_event/models/__pycache__/__init__.cpython-310.pyc b/addons/dance_event/models/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..8212a87 Binary files /dev/null and b/addons/dance_event/models/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/dance_event/models/__pycache__/__init__.cpython-311.pyc b/addons/dance_event/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..2e63e55 Binary files /dev/null and b/addons/dance_event/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/addons/dance_event/models/__pycache__/dance_event.cpython-310.pyc b/addons/dance_event/models/__pycache__/dance_event.cpython-310.pyc new file mode 100644 index 0000000..41a7f7c Binary files /dev/null and b/addons/dance_event/models/__pycache__/dance_event.cpython-310.pyc differ diff --git a/addons/dance_event/models/__pycache__/dance_event.cpython-311.pyc b/addons/dance_event/models/__pycache__/dance_event.cpython-311.pyc new file mode 100644 index 0000000..e9b49e6 Binary files /dev/null and b/addons/dance_event/models/__pycache__/dance_event.cpython-311.pyc differ diff --git a/addons/dance_event/models/__pycache__/dance_event_performance.cpython-310.pyc b/addons/dance_event/models/__pycache__/dance_event_performance.cpython-310.pyc new file mode 100644 index 0000000..1bc0d91 Binary files /dev/null and b/addons/dance_event/models/__pycache__/dance_event_performance.cpython-310.pyc differ diff --git a/addons/dance_event/models/__pycache__/dance_event_performance.cpython-311.pyc b/addons/dance_event/models/__pycache__/dance_event_performance.cpython-311.pyc new file mode 100644 index 0000000..3f5841c Binary files /dev/null and b/addons/dance_event/models/__pycache__/dance_event_performance.cpython-311.pyc differ diff --git a/addons/dance_event/models/__pycache__/dance_rehearsal.cpython-310.pyc b/addons/dance_event/models/__pycache__/dance_rehearsal.cpython-310.pyc new file mode 100644 index 0000000..03b798f Binary files /dev/null and b/addons/dance_event/models/__pycache__/dance_rehearsal.cpython-310.pyc differ diff --git a/addons/dance_event/models/__pycache__/dance_rehearsal.cpython-311.pyc b/addons/dance_event/models/__pycache__/dance_rehearsal.cpython-311.pyc new file mode 100644 index 0000000..e346797 Binary files /dev/null and b/addons/dance_event/models/__pycache__/dance_rehearsal.cpython-311.pyc differ diff --git a/addons/dance_event/models/__pycache__/dance_whatsapp.cpython-310.pyc b/addons/dance_event/models/__pycache__/dance_whatsapp.cpython-310.pyc new file mode 100644 index 0000000..54d96b3 Binary files /dev/null and b/addons/dance_event/models/__pycache__/dance_whatsapp.cpython-310.pyc differ diff --git a/addons/dance_event/models/__pycache__/dance_whatsapp.cpython-311.pyc b/addons/dance_event/models/__pycache__/dance_whatsapp.cpython-311.pyc new file mode 100644 index 0000000..ff69fe9 Binary files /dev/null and b/addons/dance_event/models/__pycache__/dance_whatsapp.cpython-311.pyc differ diff --git a/addons/dance_event/models/dance_event.py b/addons/dance_event/models/dance_event.py new file mode 100644 index 0000000..d1b3434 --- /dev/null +++ b/addons/dance_event/models/dance_event.py @@ -0,0 +1,153 @@ +from odoo import api, fields, models + + +class DanceEvent(models.Model): + _name = "dance.event" + _description = "Dance Event" + _order = "date desc, name" + + name = fields.Char(string="Event Name", required=True) + event_type = fields.Selection( + [ + ("annual_day", "Annual Day"), + ("competition", "Competition"), + ("workshop", "Workshop"), + ("audition", "Audition"), + ("recital", "Recital"), + ("other", "Other"), + ], + string="Type", + default="annual_day", + required=True, + ) + date = fields.Date(required=True) + venue = fields.Char() + state = fields.Selection( + [ + ("draft", "Draft"), + ("planning", "Planning"), + ("rehearsal", "Rehearsal"), + ("done", "Done"), + ("cancelled", "Cancelled"), + ], + default="draft", + required=True, + ) + batch_ids = fields.Many2many( + "dance.batch", + "dance_event_batch_rel", + "event_id", + "batch_id", + string="Participating Batches", + ) + student_ids = fields.Many2many( + "res.partner", + compute="_compute_student_ids", + string="Participating Students", + ) + performance_ids = fields.One2many( + "dance.event.performance", + "event_id", + string="Performances", + ) + rehearsal_ids = fields.One2many("dance.rehearsal", "event_id", string="Rehearsals") + performance_count = fields.Integer(compute="_compute_counts") + rehearsal_count = fields.Integer(compute="_compute_counts") + participant_count = fields.Integer(compute="_compute_student_ids") + notes = fields.Text() + + @api.depends("batch_ids.enrollment_ids.status", "batch_ids.enrollment_ids.student_id") + def _compute_student_ids(self): + for event in self: + students = event.batch_ids.enrollment_ids.filtered( + lambda enrollment: enrollment.status == "active" + ).mapped("student_id") + event.student_ids = students + event.participant_count = len(students) + + @api.depends("performance_ids", "rehearsal_ids") + def _compute_counts(self): + for event in self: + event.performance_count = len(event.performance_ids) + event.rehearsal_count = len(event.rehearsal_ids) + + def action_start_planning(self): + self.write({"state": "planning"}) + return True + + def action_start_rehearsals(self): + self.write({"state": "rehearsal"}) + return True + + def action_done(self): + self.write({"state": "done"}) + return True + + def action_cancel(self): + self.write({"state": "cancelled"}) + return True + + def action_create_performance_lines(self): + for event in self: + existing_student_ids = set(event.performance_ids.mapped("student_id").ids) + vals_list = [] + for student in event.student_ids: + if student.id in existing_student_ids: + continue + vals_list.append( + { + "event_id": event.id, + "student_id": student.id, + "role": "group", + } + ) + if vals_list: + self.env["dance.event.performance"].create(vals_list) + return True + + def action_send_event_notification(self): + for event in self: + event._create_whatsapp_logs( + "event_notification", + event.performance_ids.mapped("student_id") or event.student_ids, + ) + return True + + def _create_whatsapp_logs(self, trigger, students, rehearsal=False, message=False): + template = self.env["dance.whatsapp.template"].search( + [("trigger", "=", trigger), ("active", "=", True)], + limit=1, + ) + logs = [] + for student in students: + guardians = student.guardian_ids | student.guardian_id + for guardian in guardians: + phone = guardian.mobile or guardian.phone or student.guardian_phone + if not phone: + continue + values = { + "event": self.name, + "student": student.display_name, + "date": self.date or "", + "venue": self.venue or "", + "rehearsal": rehearsal.name if rehearsal else "", + "rehearsal_date": rehearsal.date if rehearsal else "", + "rehearsal_time": rehearsal.display_time if rehearsal else "", + "room": rehearsal.room if rehearsal else "", + } + body = message or (template.render_message(values) if template else self.name) + logs.append( + { + "template_id": template.id if template else False, + "trigger": trigger, + "student_id": student.id, + "guardian_id": guardian.id, + "phone": phone, + "message": body, + "event_id": self.id, + "rehearsal_id": rehearsal.id if rehearsal else False, + } + ) + if logs: + self.env["dance.whatsapp.log"].create(logs) + return len(logs) diff --git a/addons/dance_event/models/dance_event_performance.py b/addons/dance_event/models/dance_event_performance.py new file mode 100644 index 0000000..dc3e694 --- /dev/null +++ b/addons/dance_event/models/dance_event_performance.py @@ -0,0 +1,84 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class DanceEventPerformance(models.Model): + _name = "dance.event.performance" + _description = "Dance Event Performance" + _order = "event_id, sequence, student_id" + + sequence = fields.Integer(default=10) + name = fields.Char(compute="_compute_name", store=True) + event_id = fields.Many2one("dance.event", required=True, ondelete="cascade") + student_id = fields.Many2one( + "res.partner", + required=True, + domain="[('is_student', '=', True)]", + ondelete="restrict", + ) + role = fields.Selection( + [ + ("lead", "Lead Dancer"), + ("solo", "Solo"), + ("group", "Group Dancer"), + ("support", "Support"), + ], + default="group", + required=True, + ) + costume_needed = fields.Char(string="Costume Needed") + costume_status = fields.Selection( + [ + ("not_required", "Not Required"), + ("needed", "Needed"), + ("ordered", "Ordered"), + ("fitted", "Fitted"), + ("issued", "Issued"), + ("returned", "Returned"), + ], + default="needed", + required=True, + ) + notes = fields.Text() + + _sql_constraints = [ + ( + "event_student_unique", + "unique(event_id, student_id)", + "This student is already assigned to this event.", + ), + ] + + @api.depends("event_id", "student_id", "role") + def _compute_name(self): + for performance in self: + if performance.event_id and performance.student_id: + performance.name = "%s - %s" % ( + performance.event_id.name, + performance.student_id.display_name, + ) + else: + performance.name = "New Performance" + + @api.constrains("event_id", "student_id") + def _check_student_participates(self): + for performance in self: + if performance.student_id and performance.event_id.batch_ids: + if performance.student_id not in performance.event_id.student_ids: + raise ValidationError( + "Only active students from participating batches can be assigned." + ) + + def action_send_costume_update(self): + for performance in self: + message = "%s costume update for %s: %s." % ( + performance.event_id.name, + performance.student_id.display_name, + performance.costume_needed or performance.costume_status, + ) + performance.event_id._create_whatsapp_logs( + "costume_update", + performance.student_id, + message=message, + ) + return True diff --git a/addons/dance_event/models/dance_rehearsal.py b/addons/dance_event/models/dance_rehearsal.py new file mode 100644 index 0000000..c359615 --- /dev/null +++ b/addons/dance_event/models/dance_rehearsal.py @@ -0,0 +1,169 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class DanceRehearsal(models.Model): + _name = "dance.rehearsal" + _description = "Dance Event Rehearsal" + _order = "date desc, start_time desc, id desc" + + name = fields.Char(compute="_compute_name", store=True) + event_id = fields.Many2one("dance.event", required=True, ondelete="cascade") + date = fields.Date(required=True, default=fields.Date.context_today) + start_time = fields.Float(string="Start Time", required=True) + end_time = fields.Float(string="End Time") + display_time = fields.Char(compute="_compute_display_time") + room = fields.Char() + status = fields.Selection( + [ + ("scheduled", "Scheduled"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ], + default="scheduled", + required=True, + ) + attendance_ids = fields.One2many( + "dance.rehearsal.attendance", + "rehearsal_id", + string="Attendance", + ) + attendance_count = fields.Integer(compute="_compute_attendance_counts") + present_count = fields.Integer(compute="_compute_attendance_counts") + absent_count = fields.Integer(compute="_compute_attendance_counts") + late_count = fields.Integer(compute="_compute_attendance_counts") + notes = fields.Text() + + @api.depends("event_id", "date", "start_time") + def _compute_name(self): + for rehearsal in self: + if rehearsal.event_id and rehearsal.date: + rehearsal.name = "%s rehearsal - %s" % ( + rehearsal.event_id.name, + rehearsal.date, + ) + else: + rehearsal.name = "New Rehearsal" + + @api.depends("start_time", "end_time") + def _compute_display_time(self): + for rehearsal in self: + rehearsal.display_time = rehearsal._float_to_time(rehearsal.start_time) + if rehearsal.end_time: + rehearsal.display_time += " - %s" % rehearsal._float_to_time(rehearsal.end_time) + + @api.depends("attendance_ids.status") + def _compute_attendance_counts(self): + for rehearsal in self: + rehearsal.attendance_count = len(rehearsal.attendance_ids) + rehearsal.present_count = len( + rehearsal.attendance_ids.filtered(lambda line: line.status == "present") + ) + rehearsal.absent_count = len( + rehearsal.attendance_ids.filtered(lambda line: line.status == "absent") + ) + rehearsal.late_count = len( + rehearsal.attendance_ids.filtered(lambda line: line.status == "late") + ) + + @api.constrains("start_time", "end_time") + def _check_times(self): + for rehearsal in self: + if rehearsal.end_time and rehearsal.start_time and rehearsal.end_time <= rehearsal.start_time: + raise ValidationError("End time must be after start time.") + + def action_load_students(self): + for rehearsal in self: + students = rehearsal.event_id.performance_ids.mapped("student_id") or rehearsal.event_id.student_ids + existing_student_ids = set(rehearsal.attendance_ids.mapped("student_id").ids) + vals_list = [] + for student in students: + if student.id in existing_student_ids: + continue + vals_list.append( + { + "rehearsal_id": rehearsal.id, + "student_id": student.id, + "status": "present", + } + ) + if vals_list: + self.env["dance.rehearsal.attendance"].create(vals_list) + return True + + def action_mark_all_present(self): + for rehearsal in self: + if not rehearsal.attendance_ids: + rehearsal.action_load_students() + rehearsal.attendance_ids.write({"status": "present"}) + return True + + def action_complete(self): + self.write({"status": "completed"}) + return True + + def action_cancel(self): + self.write({"status": "cancelled"}) + return True + + def action_send_rehearsal_reminder(self): + for rehearsal in self: + students = rehearsal.attendance_ids.mapped("student_id") or rehearsal.event_id.performance_ids.mapped("student_id") or rehearsal.event_id.student_ids + rehearsal.event_id._create_whatsapp_logs( + "rehearsal_reminder", + students, + rehearsal=rehearsal, + ) + return True + + def _float_to_time(self, value): + hours = int(value or 0) + minutes = int(round(((value or 0) - hours) * 60)) + suffix = "AM" if hours < 12 else "PM" + display_hours = hours % 12 or 12 + return "%d:%02d %s" % (display_hours, minutes, suffix) + + +class DanceRehearsalAttendance(models.Model): + _name = "dance.rehearsal.attendance" + _description = "Dance Rehearsal Attendance" + _order = "rehearsal_id desc, student_id" + + rehearsal_id = fields.Many2one("dance.rehearsal", required=True, ondelete="cascade") + event_id = fields.Many2one(related="rehearsal_id.event_id", store=True, readonly=True) + student_id = fields.Many2one( + "res.partner", + required=True, + domain="[('is_student', '=', True)]", + ondelete="restrict", + ) + date = fields.Date(related="rehearsal_id.date", store=True, readonly=True) + status = fields.Selection( + [ + ("present", "Present"), + ("absent", "Absent"), + ("late", "Late"), + ], + default="present", + required=True, + ) + remarks = fields.Text() + + _sql_constraints = [ + ( + "unique_rehearsal_attendance", + "unique(student_id, rehearsal_id)", + "Attendance is already marked for this student in this rehearsal.", + ), + ] + + @api.constrains("student_id", "rehearsal_id") + def _check_student_in_event(self): + for attendance in self: + if attendance.student_id and attendance.rehearsal_id: + event = attendance.rehearsal_id.event_id + event_students = event.performance_ids.mapped("student_id") or event.student_ids + if attendance.student_id not in event_students: + raise ValidationError( + "Attendance can only be marked for students participating in this event." + ) diff --git a/addons/dance_event/models/dance_whatsapp.py b/addons/dance_event/models/dance_whatsapp.py new file mode 100644 index 0000000..b2c5329 --- /dev/null +++ b/addons/dance_event/models/dance_whatsapp.py @@ -0,0 +1,42 @@ +from odoo import fields, models + + +class DanceWhatsappTemplate(models.Model): + _inherit = "dance.whatsapp.template" + + trigger = fields.Selection( + selection_add=[ + ("event_notification", "Event Notification"), + ("rehearsal_reminder", "Rehearsal Reminder"), + ("costume_update", "Costume Update"), + ], + ondelete={ + "event_notification": "cascade", + "rehearsal_reminder": "cascade", + "costume_update": "cascade", + }, + ) + + +class DanceWhatsappLog(models.Model): + _inherit = "dance.whatsapp.log" + + trigger = fields.Selection( + selection_add=[ + ("event_notification", "Event Notification"), + ("rehearsal_reminder", "Rehearsal Reminder"), + ("costume_update", "Costume Update"), + ], + ondelete={ + "event_notification": "cascade", + "rehearsal_reminder": "cascade", + "costume_update": "cascade", + }, + ) + event_id = fields.Many2one("dance.event", string="Event", ondelete="set null") + performance_id = fields.Many2one( + "dance.event.performance", + string="Performance", + ondelete="set null", + ) + rehearsal_id = fields.Many2one("dance.rehearsal", string="Rehearsal", ondelete="set null") diff --git a/addons/dance_event/security/ir.model.access.csv b/addons/dance_event/security/ir.model.access.csv new file mode 100644 index 0000000..5d1bd96 --- /dev/null +++ b/addons/dance_event/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_dance_event_user,dance.event user,model_dance_event,base.group_user,1,1,1,1 +access_dance_event_performance_user,dance.event.performance user,model_dance_event_performance,base.group_user,1,1,1,1 +access_dance_rehearsal_user,dance.rehearsal user,model_dance_rehearsal,base.group_user,1,1,1,1 +access_dance_rehearsal_attendance_user,dance.rehearsal.attendance user,model_dance_rehearsal_attendance,base.group_user,1,1,1,1 diff --git a/addons/dance_event/views/dance_event_performance_views.xml b/addons/dance_event/views/dance_event_performance_views.xml new file mode 100644 index 0000000..d8c2041 --- /dev/null +++ b/addons/dance_event/views/dance_event_performance_views.xml @@ -0,0 +1,71 @@ + + + + dance.event.performance.tree + dance.event.performance + + + + + + + + + + + + + dance.event.performance.form + dance.event.performance + +
+
+
+ + + + + + + + + + + + + + + + +
+
+
+ + + dance.event.performance.search + dance.event.performance + + + + + + + + + + + + + + + + + Performances & Costumes + dance.event.performance + tree,form + + + + +
diff --git a/addons/dance_event/views/dance_event_views.xml b/addons/dance_event/views/dance_event_views.xml new file mode 100644 index 0000000..3a5a6c1 --- /dev/null +++ b/addons/dance_event/views/dance_event_views.xml @@ -0,0 +1,120 @@ + + + + dance.event.tree + dance.event + + + + + + + + + + + + + + + + dance.event.form + dance.event + +
+
+
+ +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+
+ + + dance.performance.record.search + dance.performance.record + + + + + + + + + + + + + + + + + + + Grade Cards + dance.performance.record + tree,form + + {'default_assessment_date': context_today()} + + + + Performance Criteria + dance.performance.criterion + tree,form + + + + + +
diff --git a/addons/dance_performance/views/dance_portal_templates.xml b/addons/dance_performance/views/dance_portal_templates.xml new file mode 100644 index 0000000..8bb3105 --- /dev/null +++ b/addons/dance_performance/views/dance_portal_templates.xml @@ -0,0 +1,35 @@ + + + + diff --git a/addons/dance_portal/__init__.py b/addons/dance_portal/__init__.py new file mode 100644 index 0000000..e046e49 --- /dev/null +++ b/addons/dance_portal/__init__.py @@ -0,0 +1 @@ +from . import controllers diff --git a/addons/dance_portal/__manifest__.py b/addons/dance_portal/__manifest__.py new file mode 100644 index 0000000..d210f5e --- /dev/null +++ b/addons/dance_portal/__manifest__.py @@ -0,0 +1,14 @@ +{ + "name": "Dance Parent Portal", + "summary": "Parent-facing portal for attendance, fees, grades, sessions, and events", + "version": "17.0.1.0.0", + "category": "Education", + "author": "Metetroncube", + "license": "LGPL-3", + "depends": ["dance_fees", "dance_attendance", "dance_event", "website", "portal"], + "data": [ + "views/dance_portal_templates.xml", + ], + "application": False, + "installable": True, +} diff --git a/addons/dance_portal/__pycache__/__init__.cpython-310.pyc b/addons/dance_portal/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..58dbcdd Binary files /dev/null and b/addons/dance_portal/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/dance_portal/__pycache__/__init__.cpython-311.pyc b/addons/dance_portal/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..6c53397 Binary files /dev/null and b/addons/dance_portal/__pycache__/__init__.cpython-311.pyc differ diff --git a/addons/dance_portal/__pycache__/__manifest__.cpython-311.pyc b/addons/dance_portal/__pycache__/__manifest__.cpython-311.pyc new file mode 100644 index 0000000..7089bfa Binary files /dev/null and b/addons/dance_portal/__pycache__/__manifest__.cpython-311.pyc differ diff --git a/addons/dance_portal/controllers/__init__.py b/addons/dance_portal/controllers/__init__.py new file mode 100644 index 0000000..8c3feb6 --- /dev/null +++ b/addons/dance_portal/controllers/__init__.py @@ -0,0 +1 @@ +from . import portal diff --git a/addons/dance_portal/controllers/__pycache__/__init__.cpython-310.pyc b/addons/dance_portal/controllers/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..d4ff5a3 Binary files /dev/null and b/addons/dance_portal/controllers/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/dance_portal/controllers/__pycache__/__init__.cpython-311.pyc b/addons/dance_portal/controllers/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..025885f Binary files /dev/null and b/addons/dance_portal/controllers/__pycache__/__init__.cpython-311.pyc differ diff --git a/addons/dance_portal/controllers/__pycache__/portal.cpython-310.pyc b/addons/dance_portal/controllers/__pycache__/portal.cpython-310.pyc new file mode 100644 index 0000000..d441eb8 Binary files /dev/null and b/addons/dance_portal/controllers/__pycache__/portal.cpython-310.pyc differ diff --git a/addons/dance_portal/controllers/__pycache__/portal.cpython-311.pyc b/addons/dance_portal/controllers/__pycache__/portal.cpython-311.pyc new file mode 100644 index 0000000..e147e99 Binary files /dev/null and b/addons/dance_portal/controllers/__pycache__/portal.cpython-311.pyc differ diff --git a/addons/dance_portal/controllers/portal.py b/addons/dance_portal/controllers/portal.py new file mode 100644 index 0000000..2b90fe9 --- /dev/null +++ b/addons/dance_portal/controllers/portal.py @@ -0,0 +1,123 @@ +from odoo import fields, http +from odoo.http import request + +from odoo.addons.portal.controllers.portal import CustomerPortal + + +class DanceCustomerPortal(CustomerPortal): + def _prepare_home_portal_values(self, counters): + values = super()._prepare_home_portal_values(counters) + if "dance_student_count" in counters: + values["dance_student_count"] = request.env["res.partner"].sudo().search_count( + self._get_portal_student_domain() + ) + return values + + def _get_portal_student_domain(self): + partner = request.env.user.partner_id + return [ + ("is_student", "=", True), + "|", + ("guardian_id", "=", partner.id), + ("guardian_ids", "in", partner.id), + ] + + def _get_portal_student(self, student_id): + student = request.env["res.partner"].sudo().browse(student_id) + if not student.exists() or not student.is_student: + return request.env["res.partner"] + if student in request.env["res.partner"].sudo().search(self._get_portal_student_domain()): + return student + return request.env["res.partner"] + + def _get_student_portal_values(self, student): + today = fields.Date.context_today(request.env.user) + attendance = request.env["dance.attendance"].sudo().search( + [("student_id", "=", student.id)], + order="date desc, id desc", + ) + invoices = request.env["account.move"].sudo().search( + [ + ("move_type", "=", "out_invoice"), + ("state", "!=", "cancel"), + "|", + ("dance_student_id", "=", student.id), + ("partner_id", "=", student.id), + ], + order="invoice_date desc, id desc", + ) + enrollments = request.env["dance.enrollment"].sudo().search( + [("student_id", "=", student.id)], + order="enrollment_date desc, id desc", + ) + active_batches = enrollments.filtered(lambda enrollment: enrollment.status == "active").mapped("batch_id") + upcoming_sessions = request.env["dance.session"].sudo().search( + [ + ("batch_id", "in", active_batches.ids), + ("date", ">=", today), + ("status", "=", "scheduled"), + ], + order="date asc, start_time asc, id asc", + limit=20, + ) + upcoming_events = request.env["dance.event.performance"].sudo().search( + [ + ("student_id", "=", student.id), + ("event_id.date", ">=", today), + ("event_id.state", "!=", "cancelled"), + ], + order="id asc", + limit=20, + ).sorted(lambda performance: (performance.event_id.date, performance.sequence, performance.id)) + upcoming_rehearsals = request.env["dance.rehearsal"].sudo().search( + [ + ("event_id.performance_ids.student_id", "=", student.id), + ("date", ">=", today), + ("status", "=", "scheduled"), + ], + order="date asc, start_time asc, id asc", + limit=20, + ) + total_sessions = len(attendance) + present_sessions = len(attendance.filtered(lambda line: line.status != "absent")) + attendance_percent = ( + round((present_sessions / total_sessions) * 100, 2) + if total_sessions + else 0.0 + ) + return { + "page_name": "dance_student", + "student": student, + "attendance": attendance, + "attendance_percent": attendance_percent, + "absent_count": len(attendance.filtered(lambda line: line.status == "absent")), + "invoices": invoices, + "enrollments": enrollments, + "upcoming_sessions": upcoming_sessions, + "upcoming_events": upcoming_events, + "upcoming_rehearsals": upcoming_rehearsals, + } + + @http.route(["/my/students"], type="http", auth="user", website=True) + def portal_my_students(self, **kwargs): + students = request.env["res.partner"].sudo().search( + self._get_portal_student_domain(), + order="name", + ) + values = self._prepare_portal_layout_values() + values.update( + { + "page_name": "dance_students", + "students": students, + } + ) + return request.render("dance_portal.portal_my_students", values) + + @http.route(["/my/students/"], type="http", auth="user", website=True) + def portal_my_student_detail(self, student_id, **kwargs): + student = self._get_portal_student(student_id) + if not student: + return request.redirect("/my/students") + values = self._prepare_portal_layout_values() + values.update(self._get_student_portal_values(student)) + return request.render("dance_portal.portal_my_student_detail", values) diff --git a/addons/dance_portal/views/dance_portal_templates.xml b/addons/dance_portal/views/dance_portal_templates.xml new file mode 100644 index 0000000..6b939c5 --- /dev/null +++ b/addons/dance_portal/views/dance_portal_templates.xml @@ -0,0 +1,284 @@ + + + + + + + + + + diff --git a/addons/dance_reports/__init__.py b/addons/dance_reports/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/addons/dance_reports/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/dance_reports/__manifest__.py b/addons/dance_reports/__manifest__.py new file mode 100644 index 0000000..d0bf31e --- /dev/null +++ b/addons/dance_reports/__manifest__.py @@ -0,0 +1,24 @@ +{ + "name": "Dance Reports Dashboard", + "summary": "Operational reports and KPI dashboard for dance school management", + "version": "17.0.2.0.0", + "category": "Education", + "author": "Metetroncube", + "license": "LGPL-3", + "depends": ["dance_fees", "dance_attendance"], + "data": [ + "security/ir.model.access.csv", + "views/dance_dashboard_views.xml", + "views/dance_report_views.xml", + ], + "assets": { + "web.assets_backend": [ + "dance_reports/static/src/dance_global.scss", + "dance_reports/static/src/dashboard/dance_dashboard.js", + "dance_reports/static/src/dashboard/dance_dashboard.xml", + "dance_reports/static/src/dashboard/dance_dashboard.scss", + ], + }, + "application": False, + "installable": True, +} diff --git a/addons/dance_reports/__pycache__/__init__.cpython-310.pyc b/addons/dance_reports/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..9f8c239 Binary files /dev/null and b/addons/dance_reports/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/dance_reports/__pycache__/__init__.cpython-311.pyc b/addons/dance_reports/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..47509d6 Binary files /dev/null and b/addons/dance_reports/__pycache__/__init__.cpython-311.pyc differ diff --git a/addons/dance_reports/__pycache__/__manifest__.cpython-311.pyc b/addons/dance_reports/__pycache__/__manifest__.cpython-311.pyc new file mode 100644 index 0000000..9d0f033 Binary files /dev/null and b/addons/dance_reports/__pycache__/__manifest__.cpython-311.pyc differ diff --git a/addons/dance_reports/models/__init__.py b/addons/dance_reports/models/__init__.py new file mode 100644 index 0000000..4832a75 --- /dev/null +++ b/addons/dance_reports/models/__init__.py @@ -0,0 +1 @@ +from . import dance_dashboard diff --git a/addons/dance_reports/models/__pycache__/__init__.cpython-310.pyc b/addons/dance_reports/models/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..4e02aa1 Binary files /dev/null and b/addons/dance_reports/models/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/dance_reports/models/__pycache__/__init__.cpython-311.pyc b/addons/dance_reports/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..3edb1d3 Binary files /dev/null and b/addons/dance_reports/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/addons/dance_reports/models/__pycache__/dance_dashboard.cpython-310.pyc b/addons/dance_reports/models/__pycache__/dance_dashboard.cpython-310.pyc new file mode 100644 index 0000000..1af1111 Binary files /dev/null and b/addons/dance_reports/models/__pycache__/dance_dashboard.cpython-310.pyc differ diff --git a/addons/dance_reports/models/__pycache__/dance_dashboard.cpython-311.pyc b/addons/dance_reports/models/__pycache__/dance_dashboard.cpython-311.pyc new file mode 100644 index 0000000..9f07de0 Binary files /dev/null and b/addons/dance_reports/models/__pycache__/dance_dashboard.cpython-311.pyc differ diff --git a/addons/dance_reports/models/dance_dashboard.py b/addons/dance_reports/models/dance_dashboard.py new file mode 100644 index 0000000..4f5a714 --- /dev/null +++ b/addons/dance_reports/models/dance_dashboard.py @@ -0,0 +1,237 @@ +from collections import Counter +from dateutil.relativedelta import relativedelta +from odoo import api, fields, models + + +def _float_to_time(f): + if not f: + return "—" + h = int(f) + m = int(round((f % 1) * 60)) + return f"{h:02d}:{m:02d}" + + +class DanceDashboard(models.Model): + _name = "dance.dashboard" + _description = "Dance Dashboard" + + name = fields.Char(default="Dance Dashboard", required=True) + + # ── Computed fields kept for legacy form view ────────────────────────────── + total_students = fields.Integer(compute="_compute_kpis") + active_batches = fields.Integer(compute="_compute_kpis") + active_enrollments = fields.Integer(compute="_compute_kpis") + dropped_enrollments = fields.Integer(compute="_compute_kpis") + monthly_revenue = fields.Monetary(compute="_compute_kpis", currency_field="currency_id") + pending_fees = fields.Monetary(compute="_compute_kpis", currency_field="currency_id") + currency_id = fields.Many2one("res.currency", compute="_compute_kpis") + + @api.depends_context("company") + def _compute_kpis(self): + today = fields.Date.context_today(self) + month_start = today.replace(day=1) + company = self.env.company + invoice_model = self.env["account.move"] + + paid_this_month = invoice_model.search([ + ("move_type", "=", "out_invoice"), + ("state", "=", "posted"), + ("payment_state", "=", "paid"), + ("dance_student_id", "!=", False), + ("invoice_date", ">=", month_start), + ("invoice_date", "<=", today), + ("company_id", "=", company.id), + ]) + pending_invoices = invoice_model.search([ + ("move_type", "=", "out_invoice"), + ("state", "=", "posted"), + ("payment_state", "in", ["not_paid", "partial"]), + ("dance_student_id", "!=", False), + ("company_id", "=", company.id), + ]) + + values = { + "total_students": self.env["res.partner"].search_count([("is_student", "=", True)]), + "active_batches": self.env["dance.batch"].search_count([("status", "=", "active")]), + "active_enrollments": self.env["dance.enrollment"].search_count([("status", "=", "active")]), + "dropped_enrollments": self.env["dance.enrollment"].search_count([("status", "=", "dropped")]), + "monthly_revenue": sum(paid_this_month.mapped("amount_total")), + "pending_fees": sum(pending_invoices.mapped("amount_residual")), + "currency_id": company.currency_id.id, + } + for dashboard in self: + dashboard.update(values) + + # ── Client-action data endpoint ──────────────────────────────────────────── + + @api.model + def get_dashboard_data(self): + today = fields.Date.context_today(self) + company = self.env.company + invoice_model = self.env["account.move"] + + # ── Core KPIs ────────────────────────────────────────────────────────── + month_start = today.replace(day=1) + + paid_this_month = invoice_model.search([ + ("move_type", "=", "out_invoice"), + ("state", "=", "posted"), + ("payment_state", "=", "paid"), + ("dance_student_id", "!=", False), + ("invoice_date", ">=", month_start), + ("invoice_date", "<=", today), + ("company_id", "=", company.id), + ]) + pending_invoices = invoice_model.search([ + ("move_type", "=", "out_invoice"), + ("state", "=", "posted"), + ("payment_state", "in", ["not_paid", "partial"]), + ("dance_student_id", "!=", False), + ("company_id", "=", company.id), + ]) + + monthly_revenue = sum(paid_this_month.mapped("amount_total")) + pending_fees = sum(pending_invoices.mapped("amount_residual")) + currency = company.currency_id + + def fmt(amount): + sym = currency.symbol or "$" + if amount >= 1_000_000: + return f"{sym}{amount / 1_000_000:.1f}M" + if amount >= 1_000: + return f"{sym}{amount / 1_000:.1f}K" + return f"{sym}{amount:,.0f}" + + sessions_today = self.env["dance.session"].search_count([ + ("date", "=", today), + ("status", "!=", "cancelled"), + ]) + + kpis = { + "total_students": self.env["res.partner"].search_count([("is_student", "=", True)]), + "active_batches": self.env["dance.batch"].search_count([("status", "=", "active")]), + "active_enrollments": self.env["dance.enrollment"].search_count([("status", "=", "active")]), + "dropped_enrollments": self.env["dance.enrollment"].search_count([("status", "=", "dropped")]), + "sessions_today": sessions_today, + "monthly_revenue": monthly_revenue, + "monthly_revenue_fmt": fmt(monthly_revenue), + "pending_fees": pending_fees, + "pending_fees_fmt": fmt(pending_fees), + "currency_symbol": currency.symbol or "$", + } + + return { + "kpis": kpis, + "revenue_chart": self._revenue_last_6_months(today, company, invoice_model), + "attendance_chart": self._attendance_last_30_days(today), + "style_distribution": self._style_distribution(), + "sessions": self._today_sessions(today), + } + + def _revenue_last_6_months(self, today, company, invoice_model): + labels, data = [], [] + for i in range(5, -1, -1): + month_date = today - relativedelta(months=i) + ms = month_date.replace(day=1) + me = (ms + relativedelta(months=1)) - relativedelta(days=1) + if i == 0: + me = today + labels.append(ms.strftime("%b '%y")) + paid = invoice_model.search([ + ("move_type", "=", "out_invoice"), + ("state", "=", "posted"), + ("payment_state", "=", "paid"), + ("dance_student_id", "!=", False), + ("invoice_date", ">=", ms), + ("invoice_date", "<=", me), + ("company_id", "=", company.id), + ]) + data.append(round(sum(paid.mapped("amount_total")), 2)) + return {"labels": labels, "data": data} + + def _attendance_last_30_days(self, today): + cutoff = today - relativedelta(days=30) + att = self.env["dance.attendance"].search([ + ("session_id.date", ">=", cutoff), + ("session_id.date", "<=", today), + ]) + statuses = att.mapped("status") + return { + "present": statuses.count("present"), + "absent": statuses.count("absent"), + "late": statuses.count("late"), + } + + def _style_distribution(self): + enrollments = self.env["dance.enrollment"].search([("status", "=", "active")]) + style_field = self.env["dance.batch"]._fields.get("dance_style") + style_map = dict(style_field.selection) if style_field else {} + + counts = Counter( + e.batch_id.dance_style for e in enrollments if e.batch_id and e.batch_id.dance_style + ) + if not counts: + return {"labels": [], "data": []} + + # Sort by count desc for nicer chart + sorted_items = sorted(counts.items(), key=lambda x: x[1], reverse=True) + return { + "labels": [style_map.get(code, code.title()) for code, _ in sorted_items], + "data": [cnt for _, cnt in sorted_items], + } + + def _today_sessions(self, today): + sessions = self.env["dance.session"].search( + [("date", "=", today)], order="start_time asc" + ) + result = [] + for s in sessions: + result.append({ + "id": s.id, + "batch": s.batch_id.name if s.batch_id else "—", + "time": f"{_float_to_time(s.start_time)} – {_float_to_time(s.end_time)}", + "room": s.room or "—", + "instructor": s.instructor_id.name if s.instructor_id else "—", + "status": s.status, + }) + return result + + # ── Navigation actions (legacy stat buttons) ─────────────────────────────── + + def action_view_students(self): + return {"type": "ir.actions.act_window", "name": "Students", + "res_model": "res.partner", "domain": [("is_student", "=", True)], + "view_mode": "tree,form", "context": {"default_is_student": True}} + + def action_view_batches(self): + return {"type": "ir.actions.act_window", "name": "Active Batches", + "res_model": "dance.batch", "domain": [("status", "=", "active")], + "view_mode": "tree,form"} + + def action_view_enrollments(self): + return {"type": "ir.actions.act_window", "name": "Active Enrollments", + "res_model": "dance.enrollment", "domain": [("status", "=", "active")], + "view_mode": "tree,form"} + + def action_view_dropped(self): + return {"type": "ir.actions.act_window", "name": "Dropped Enrollments", + "res_model": "dance.enrollment", "domain": [("status", "=", "dropped")], + "view_mode": "tree,form"} + + def action_view_revenue(self): + today = fields.Date.context_today(self) + month_start = today.replace(day=1) + return {"type": "ir.actions.act_window", "name": "Revenue This Month", + "res_model": "account.move", + "domain": [("move_type", "=", "out_invoice"), ("state", "=", "posted"), + ("payment_state", "=", "paid"), ("dance_student_id", "!=", False), + ("invoice_date", ">=", month_start), ("invoice_date", "<=", today)], + "view_mode": "tree,form"} + + def action_view_pending(self): + return {"type": "ir.actions.act_window", "name": "Outstanding Fees", + "res_model": "account.move", + "domain": [("move_type", "=", "out_invoice"), ("state", "=", "posted"), + ("payment_state", "in", ["not_paid", "partial"]), + ("dance_student_id", "!=", False)], + "view_mode": "tree,form"} diff --git a/addons/dance_reports/security/ir.model.access.csv b/addons/dance_reports/security/ir.model.access.csv new file mode 100644 index 0000000..62e7f11 --- /dev/null +++ b/addons/dance_reports/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_dance_dashboard_user,dance.dashboard user,model_dance_dashboard,base.group_user,1,1,1,1 diff --git a/addons/dance_reports/static/src/dance_global.scss b/addons/dance_reports/static/src/dance_global.scss new file mode 100644 index 0000000..f8405aa --- /dev/null +++ b/addons/dance_reports/static/src/dance_global.scss @@ -0,0 +1,235 @@ +// ── Dance School ERP — Global brand overrides ──────────────────────────────── +// Applied to the entire Odoo backend via web.assets_backend. +// Brand: #875A7B (mauve-purple) | accent: #5e3d57 + +// ── Top nav bar ──────────────────────────────────────────────────────────── +.o_main_navbar { + background: linear-gradient(135deg, #875A7B 0%, #5e3d57 100%) !important; + border-bottom: none !important; + box-shadow: 0 2px 12px rgba(135, 90, 123, 0.3) !important; + + .o_menu_brand { + color: #fff !important; + font-weight: 700 !important; + letter-spacing: -0.2px !important; + } + + .o_nav_entry, + .o_menu_sections > .o_nav_entry > a, + .o_menu_sections .o_dropdown_menu .o_nav_entry > a { + color: rgba(255, 255, 255, 0.9) !important; + + &:hover { + color: #fff !important; + background: rgba(255, 255, 255, 0.15) !important; + } + } + + // Active breadcrumb / current app highlight + .o_menu_sections .o_nav_entry.active > a, + .o_menu_sections .o_nav_entry > a.active { + color: #fff !important; + background: rgba(255, 255, 255, 0.2) !important; + border-radius: 6px; + } + + // Icons and caret + .fa { color: rgba(255, 255, 255, 0.85) !important; } + + // Systray icons (notifications, user avatar) + .o_systray_item { + color: rgba(255, 255, 255, 0.9) !important; + + &:hover { background: rgba(255, 255, 255, 0.15) !important; } + } + + // Search bar inside navbar + .o_searchview { + border-color: rgba(255, 255, 255, 0.3) !important; + background: rgba(255, 255, 255, 0.12) !important; + border-radius: 8px !important; + + input, .o_searchview_input { + color: #fff !important; + + &::placeholder { color: rgba(255, 255, 255, 0.6) !important; } + } + + .fa { color: rgba(255, 255, 255, 0.7) !important; } + } +} + +// ── Primary buttons ──────────────────────────────────────────────────────── +.btn-primary { + background-color: #875A7B !important; + border-color: #875A7B !important; + color: #fff !important; + + &:hover, &:focus { + background-color: #6f4866 !important; + border-color: #6f4866 !important; + } + + &:active { + background-color: #5e3d57 !important; + border-color: #5e3d57 !important; + } +} + +// Save / Edit button inside form views +.o_form_button_save, +.o_form_button_edit { + background-color: #875A7B !important; + border-color: #875A7B !important; + color: #fff !important; + + &:hover { + background-color: #6f4866 !important; + border-color: #6f4866 !important; + } +} + +// ── Sidebar (app menu / control panel) ──────────────────────────────────── +.o_web_client .o_action_manager .o_control_panel { + border-bottom: 1px solid #ede4ed !important; + background: #fff !important; +} + +// Breadcrumb active item +.o_control_panel_breadcrumbs .breadcrumb-item.active { + color: #875A7B !important; + font-weight: 600; +} + +// ── List view ────────────────────────────────────────────────────────────── +.o_list_view { + .o_list_table thead th { + background: #faf7fb !important; + color: #6b5070 !important; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.4px; + font-weight: 700; + border-bottom: 2px solid #ede4ed !important; + } + + tbody tr:hover td { + background: #fdf7fc !important; + } + + .o_list_record_selector input[type="checkbox"]:checked { + accent-color: #875A7B; + } +} + +// ── Kanban view ──────────────────────────────────────────────────────────── +.o_kanban_view { + .o_kanban_group_header { + background: linear-gradient(135deg, #875A7B 0%, #5e3d57 100%) !important; + color: #fff !important; + border-radius: 8px 8px 0 0 !important; + + .o_column_title { color: #fff !important; font-weight: 700; } + } + + .o_kanban_record { + border-radius: 10px !important; + box-shadow: 0 2px 8px rgba(0,0,0,0.06) !important; + transition: box-shadow 0.15s, transform 0.15s !important; + + &:hover { + box-shadow: 0 6px 18px rgba(135, 90, 123, 0.18) !important; + transform: translateY(-2px); + } + } +} + +// ── Form view ───────────────────────────────────────────────────────────── +.o_form_view { + .o_form_sheet { + border-radius: 12px !important; + box-shadow: 0 2px 16px rgba(0,0,0,0.07) !important; + } + + // Stat buttons in the oe_button_box + .oe_button_box .oe_stat_button { + border-radius: 10px !important; + transition: background 0.15s, transform 0.12s !important; + + &:hover { + background: #fdf7fc !important; + transform: translateY(-1px); + } + + .o_stat_value { color: #875A7B !important; font-weight: 700; } + } + + // Field labels + .o_field_widget label, + .o_label { + color: #6b5070 !important; + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.3px; + } + + // Notebook tabs + .o_notebook .nav-tabs .nav-link.active { + color: #875A7B !important; + border-bottom-color: #875A7B !important; + font-weight: 600; + } +} + +// ── Chatter / messaging ──────────────────────────────────────────────────── +.o_MessageList_message .o_Message_authorAvatar { + border: 2px solid #f0e8f0; +} + +.o_Chatter_sendButton, +.o_Chatter_logButton { + border-color: #875A7B !important; + color: #875A7B !important; + + &:hover { + background: #875A7B !important; + color: #fff !important; + } +} + +// ── Badge / status pill for selection fields ─────────────────────────────── +.badge.o_field_badge { + border-radius: 20px !important; + font-weight: 600 !important; + letter-spacing: 0.2px !important; +} + +// ── Pagination ───────────────────────────────────────────────────────────── +.o_pager .o_pager_value { + color: #875A7B !important; + font-weight: 600; +} + +// ── Dropdown menus ───────────────────────────────────────────────────────── +.dropdown-menu { + border-radius: 10px !important; + box-shadow: 0 8px 24px rgba(0,0,0,0.12) !important; + border: 1px solid #ede4ed !important; +} + +.dropdown-item:hover, +.dropdown-item:focus { + background: #fdf7fc !important; + color: #875A7B !important; +} + +// ── Scrollbar (webkit) ───────────────────────────────────────────────────── +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: #f5f5f5; } +::-webkit-scrollbar-thumb { + background: #d0b8cc; + border-radius: 4px; + + &:hover { background: #875A7B; } +} diff --git a/addons/dance_reports/static/src/dashboard/dance_dashboard.js b/addons/dance_reports/static/src/dashboard/dance_dashboard.js new file mode 100644 index 0000000..a44ca23 --- /dev/null +++ b/addons/dance_reports/static/src/dashboard/dance_dashboard.js @@ -0,0 +1,247 @@ +/** @odoo-module **/ + +import { Component, useState, onWillStart, useRef, useEffect } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { loadBundle } from "@web/core/assets"; + +class DanceDashboard extends Component { + static template = "dance_reports.DanceDashboard"; + + setup() { + this.orm = useService("orm"); + this.actionService = useService("action"); + this.userService = useService("user"); + + this.revenueChartRef = useRef("revenueChart"); + this.attendanceChartRef = useRef("attendanceChart"); + this.styleChartRef = useRef("styleChart"); + this._charts = {}; + + this.state = useState({ + loading: true, + menuOpen: false, + kpis: {}, + sessions: [], + revenue_chart: null, + attendance_chart: null, + style_distribution: null, + today: new Date().toLocaleDateString("en-CA", { + weekday: "long", year: "numeric", month: "long", day: "numeric", + }), + // user info populated in onWillStart + userName: "", + userEmail: "", + userId: null, + userAvatar: "", + }); + + onWillStart(async () => { + await loadBundle("web.chartjs_lib"); + await Promise.all([this._loadData(), this._loadUser()]); + }); + + useEffect( + () => { if (!this.state.loading) this._renderCharts(); }, + () => [this.state.loading] + ); + } + + // ── Data loading ───────────────────────────────────────────────────────── + + async _loadData() { + try { + const data = await this.orm.call("dance.dashboard", "get_dashboard_data", []); + Object.assign(this.state, { + kpis: data.kpis, + sessions: data.sessions, + revenue_chart: data.revenue_chart, + attendance_chart: data.attendance_chart, + style_distribution: data.style_distribution, + }); + } catch (e) { + console.error("Dashboard load error:", e); + } finally { + this.state.loading = false; + } + } + + async _loadUser() { + try { + const uid = this.userService.userId; + const [user] = await this.orm.read( + "res.users", + [uid], + ["name", "email", "partner_id"] + ); + this.state.userName = user.name; + this.state.userEmail = user.email || ""; + this.state.userId = uid; + this.state.userAvatar = `/web/image/res.users/${uid}/avatar_128`; + } catch (e) { + this.state.userName = this.userService.name || "User"; + } + } + + async onRefresh() { + Object.values(this._charts).forEach((c) => c.destroy()); + this._charts = {}; + this.state.loading = true; + await this._loadData(); + } + + // ── Hamburger menu ─────────────────────────────────────────────────────── + + toggleMenu() { + this.state.menuOpen = !this.state.menuOpen; + } + + closeMenu() { + this.state.menuOpen = false; + } + + openMyProfile() { + this.closeMenu(); + this.actionService.doAction({ + type: "ir.actions.act_window", + name: "My Profile", + res_model: "res.users", + res_id: this.state.userId, + views: [[false, "form"]], + target: "current", + }); + } + + openSettings() { + this.closeMenu(); + this.actionService.doAction("base_setup.action_general_configuration"); + } + + goHome() { + this.closeMenu(); + // Navigate to Odoo app switcher home + this.actionService.doAction({ type: "ir.actions.act_url", url: "/odoo", target: "self" }); + } + + logout() { + window.location.href = "/web/session/logout?redirect=/web/login"; + } + + // Drawer navigation — opens any Odoo model list from the side drawer + openModel(model, name, domain) { + this.closeMenu(); + this.actionService.doAction({ + type: "ir.actions.act_window", + name, + res_model: model, + domain: domain || [], + views: [[false, "list"], [false, "form"]], + }); + } + + // ── Charts ─────────────────────────────────────────────────────────────── + + _renderCharts() { + const Chart = window.Chart; + if (!Chart) return; + Object.values(this._charts).forEach((c) => c.destroy()); + this._charts = {}; + + // Revenue bar + const revEl = this.revenueChartRef.el; + if (revEl && this.state.revenue_chart) { + const sym = this.state.kpis.currency_symbol || "$"; + this._charts.revenue = new Chart(revEl, { + type: "bar", + data: { + labels: this.state.revenue_chart.labels, + datasets: [{ + label: "Revenue", + data: this.state.revenue_chart.data, + backgroundColor: "rgba(135,90,123,0.85)", + hoverBackgroundColor: "#875A7B", + borderRadius: 8, + borderSkipped: false, + }], + }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { callbacks: { label: (c) => ` ${sym}${c.parsed.y.toLocaleString()}` } }, + }, + scales: { + y: { beginAtZero: true, grid: { color: "rgba(0,0,0,0.04)" }, + ticks: { color: "#888", callback: (v) => v >= 1000 ? `${sym}${(v/1000).toFixed(0)}K` : `${sym}${v}` } }, + x: { grid: { display: false }, ticks: { color: "#888" } }, + }, + }, + }); + } + + // Attendance donut + const attEl = this.attendanceChartRef.el; + if (attEl && this.state.attendance_chart) { + const att = this.state.attendance_chart; + const total = att.present + att.absent + att.late || 1; + this._charts.attendance = new Chart(attEl, { + type: "doughnut", + data: { + labels: [ + `Present (${Math.round(att.present/total*100)}%)`, + `Absent (${Math.round(att.absent/total*100)}%)`, + `Late (${Math.round(att.late/total*100)}%)`, + ], + datasets: [{ data: [att.present, att.absent, att.late], + backgroundColor: ["#27AE60","#E74C3C","#F39C12"], + borderWidth: 0, hoverOffset: 8 }], + }, + options: { responsive: true, maintainAspectRatio: false, cutout: "70%", + plugins: { legend: { position: "bottom", + labels: { padding: 16, usePointStyle: true, font: { size: 12 } } } } }, + }); + } + + // Style donut + const styleEl = this.styleChartRef.el; + if (styleEl && this.state.style_distribution?.data?.length) { + const sd = this.state.style_distribution; + const palette = ["#875A7B","#4ECDC4","#F39C12","#27AE60","#E74C3C","#3498DB","#9B59B6","#1ABC9C"]; + this._charts.style = new Chart(styleEl, { + type: "doughnut", + data: { + labels: sd.labels, + datasets: [{ data: sd.data, + backgroundColor: palette.slice(0, sd.labels.length), + borderWidth: 0, hoverOffset: 8 }], + }, + options: { responsive: true, maintainAspectRatio: false, cutout: "60%", + plugins: { legend: { position: "bottom", + labels: { padding: 12, usePointStyle: true, font: { size: 12 } } } } }, + }); + } + } + + // ── KPI card navigation ────────────────────────────────────────────────── + + navigate(section) { + const today = new Date().toISOString().split("T")[0]; + const ms = new Date(); ms.setDate(1); + const monthStart = ms.toISOString().split("T")[0]; + const lf = [[false, "list"], [false, "form"]]; + + const routes = { + students: { type:"ir.actions.act_window", name:"Students", res_model:"res.partner", domain:[["is_student","=",true]], views:lf, context:{default_is_student:true} }, + batches: { type:"ir.actions.act_window", name:"Active Batches", res_model:"dance.batch", domain:[["status","=","active"]], views:lf }, + enrolled: { type:"ir.actions.act_window", name:"Active Enrollments", res_model:"dance.enrollment",domain:[["status","=","active"]], views:lf }, + sessions_today:{ type:"ir.actions.act_window", name:"Today's Sessions", res_model:"dance.session", domain:[["date","=",today]], views:lf }, + revenue: { type:"ir.actions.act_window", name:"Revenue This Month", res_model:"account.move", views:lf, + domain:[["move_type","=","out_invoice"],["state","=","posted"],["payment_state","=","paid"],["dance_student_id","!=",false],["invoice_date",">=",monthStart],["invoice_date","<=",today]] }, + pending: { type:"ir.actions.act_window", name:"Outstanding Fees", res_model:"account.move", views:lf, + domain:[["move_type","=","out_invoice"],["state","=","posted"],["payment_state","in",["not_paid","partial"]],["dance_student_id","!=",false]] }, + }; + if (routes[section]) this.actionService.doAction(routes[section]); + } +} + +registry.category("actions").add("dance_school_dashboard_action", DanceDashboard); diff --git a/addons/dance_reports/static/src/dashboard/dance_dashboard.scss b/addons/dance_reports/static/src/dashboard/dance_dashboard.scss new file mode 100644 index 0000000..d6e1b91 --- /dev/null +++ b/addons/dance_reports/static/src/dashboard/dance_dashboard.scss @@ -0,0 +1,467 @@ +// ── Dance School ERP Dashboard ─────────────────────────────────────────────── +// Brand: #875A7B (mauve/purple) | Complementary: teal, amber, green, coral + +// ── Reset & Wrapper ──────────────────────────────────────────────────────── +.ds-wrap { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #f0f2f7; + position: absolute; + inset: 0; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +// ── Drawer Backdrop ──────────────────────────────────────────────────────── +.ds-drawer-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + z-index: 1000; + animation: dsBackdropIn 0.2s ease; +} + +@keyframes dsBackdropIn { + from { opacity: 0; } + to { opacity: 1; } +} + +// ── Slide-out Drawer ─────────────────────────────────────────────────────── +.ds-drawer { + position: fixed; + top: 0; + right: 0; + height: 100%; + width: 300px; + background: #fff; + z-index: 1001; + box-shadow: -4px 0 24px rgba(0, 0, 0, 0.18); + display: flex; + flex-direction: column; + overflow-y: auto; + transform: translateX(100%); + transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1); + padding-bottom: 16px; + + &.ds-drawer-open { + transform: translateX(0); + } +} + +.ds-drawer-user { + display: flex; + align-items: center; + gap: 12px; + padding: 24px 20px 20px; + background: linear-gradient(135deg, #875A7B 0%, #5e3d57 100%); + color: #fff; + flex-shrink: 0; +} + +.ds-drawer-avatar { + width: 52px; + height: 52px; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.5); + object-fit: cover; + flex-shrink: 0; + background: rgba(255, 255, 255, 0.2); +} + +.ds-drawer-user-info { + flex: 1; + min-width: 0; +} + +.ds-drawer-user-name { + font-size: 15px; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ds-drawer-user-email { + font-size: 12px; + opacity: 0.8; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ds-drawer-divider { + height: 1px; + background: #f0f0f0; + margin: 6px 0; + flex-shrink: 0; +} + +.ds-drawer-section-label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.8px; + color: #aaa; + padding: 10px 20px 4px; + flex-shrink: 0; +} + +.ds-drawer-link { + display: flex; + align-items: center; + gap: 12px; + padding: 11px 20px; + font-size: 14px; + color: #3d3d3d; + cursor: pointer; + text-decoration: none; + transition: background 0.15s, color 0.15s; + flex-shrink: 0; + + i { + width: 18px; + text-align: center; + color: #875A7B; + font-size: 15px; + } + + &:hover { + background: #fdf7fc; + color: #875A7B; + } +} + +.ds-drawer-logout { + color: #E74C3C; + margin-top: 4px; + + i { color: #E74C3C; } + + &:hover { + background: #fdf0ef; + color: #c0392b; + i { color: #c0392b; } + } +} + +// ── Header ───────────────────────────────────────────────────────────────── +.ds-header { + background: linear-gradient(135deg, #875A7B 0%, #5e3d57 100%); + color: #fff; + padding: 20px 32px; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 4px 20px rgba(135, 90, 123, 0.35); + flex-shrink: 0; +} + +.ds-header-brand { + display: flex; + align-items: center; + gap: 14px; +} + +.ds-header-icon { + font-size: 40px; + line-height: 1; + filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2)); +} + +.ds-header-title { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.3px; +} + +.ds-header-sub { + font-size: 13px; + opacity: 0.8; + margin-top: 2px; +} + +.ds-header-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.ds-btn-refresh { + background: rgba(255,255,255,0.18); + border: 1px solid rgba(255,255,255,0.35); + color: #fff; + border-radius: 8px; + padding: 8px 18px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: background 0.2s; + + &:hover { background: rgba(255,255,255,0.3); } + + i { font-size: 13px; } +} + +.ds-btn-hamburger { + background: rgba(255,255,255,0.18); + border: 1px solid rgba(255,255,255,0.35); + color: #fff; + border-radius: 8px; + width: 40px; + height: 40px; + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; + + &:hover { background: rgba(255,255,255,0.3); } +} + +// ── Loading ──────────────────────────────────────────────────────────────── +.ds-loading { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + color: #875A7B; + font-size: 15px; + font-weight: 500; + min-height: 300px; +} + +// ── Content area ─────────────────────────────────────────────────────────── +.ds-content { + padding: 24px 32px 32px; + display: flex; + flex-direction: column; + gap: 20px; +} + +// ── KPI Row ──────────────────────────────────────────────────────────────── +.ds-kpi-row { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 16px; + + @media (max-width: 1200px) { grid-template-columns: repeat(3, 1fr); } + @media (max-width: 768px) { grid-template-columns: repeat(2, 1fr); } +} + +// ── Individual KPI card ──────────────────────────────────────────────────── +.ds-kpi { + border-radius: 16px; + padding: 22px 20px 18px; + color: #fff; + cursor: pointer; + display: flex; + align-items: flex-start; + gap: 14px; + position: relative; + overflow: hidden; + transition: transform 0.18s ease, box-shadow 0.18s ease; + box-shadow: 0 4px 14px rgba(0,0,0,0.12); + + &:hover { + transform: translateY(-3px); + box-shadow: 0 8px 24px rgba(0,0,0,0.18); + } + + &::after { + content: ''; + position: absolute; + right: -20px; + top: -20px; + width: 100px; + height: 100px; + border-radius: 50%; + background: rgba(255,255,255,0.1); + pointer-events: none; + } +} + +.ds-kpi-ico { + font-size: 28px; + opacity: 0.9; + margin-top: 2px; + flex-shrink: 0; +} + +.ds-kpi-body { flex: 1; } + +.ds-kpi-val { + font-size: 28px; + font-weight: 700; + line-height: 1.1; + letter-spacing: -0.5px; +} + +.ds-kpi-lbl { + font-size: 12px; + font-weight: 500; + opacity: 0.85; + margin-top: 4px; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.ds-kpi-ring { + font-size: 18px; + opacity: 0.5; + align-self: center; + flex-shrink: 0; +} + +// ── KPI color variants ───────────────────────────────────────────────────── +.ds-purple { background: linear-gradient(135deg, #875A7B, #5e3d57); } +.ds-blue { background: linear-gradient(135deg, #3498DB, #1A6FA8); } +.ds-green { background: linear-gradient(135deg, #27AE60, #1A7A43); } +.ds-amber { background: linear-gradient(135deg, #E67E22, #B8610E); } +.ds-teal { background: linear-gradient(135deg, #16A085, #0B6B5A); } +.ds-coral { background: linear-gradient(135deg, #E74C3C, #B83228); } + +// ── Layout rows ──────────────────────────────────────────────────────────── +.ds-row { + display: flex; + gap: 20px; + + @media (max-width: 900px) { flex-direction: column; } +} + +.ds-grow-1 { flex: 1; } +.ds-grow-2 { flex: 2; } + +// ── Card ─────────────────────────────────────────────────────────────────── +.ds-card { + background: #fff; + border-radius: 16px; + box-shadow: 0 2px 12px rgba(0,0,0,0.06); + padding: 0; + overflow: hidden; + display: flex; + flex-direction: column; + min-width: 0; +} + +.ds-card-hd { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px 12px; + border-bottom: 1px solid #f0f0f0; +} + +.ds-card-title { + font-size: 15px; + font-weight: 600; + color: #2d2d2d; + + i { + color: #875A7B; + margin-right: 6px; + } +} + +.ds-pill { + background: #f0e8f0; + color: #875A7B; + font-size: 11px; + font-weight: 600; + border-radius: 20px; + padding: 3px 10px; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.ds-pill-live { + background: #e8f5e9; + color: #27AE60; +} + +// ── Chart containers ─────────────────────────────────────────────────────── +.ds-chart-box { + flex: 1; + position: relative; + padding: 16px 20px 20px; + min-height: 220px; + + canvas { + width: 100% !important; + height: 100% !important; + } +} + +.ds-chart-tall { min-height: 260px; } + +.ds-empty-chart { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: #bbb; + font-size: 14px; + font-style: italic; + min-height: 180px; +} + +// ── Sessions table ───────────────────────────────────────────────────────── +.ds-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + thead tr { background: #fafafa; } + + th { + text-align: left; + padding: 10px 16px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #888; + border-bottom: 1px solid #f0f0f0; + } + + td { + padding: 11px 16px; + border-bottom: 1px solid #f8f8f8; + color: #444; + } + + tbody tr:last-child td { border-bottom: none; } + tbody tr:hover td { background: #fdf7fc; } +} + +.ds-td-bold { font-weight: 600; color: #2d2d2d !important; } + +.ds-table-empty { + text-align: center; + color: #bbb; + font-style: italic; + padding: 32px 16px !important; + + i { margin-right: 6px; } +} + +// ── Session status badges ────────────────────────────────────────────────── +.ds-status { + display: inline-block; + border-radius: 20px; + padding: 3px 10px; + font-size: 11px; + font-weight: 600; + text-transform: capitalize; + letter-spacing: 0.2px; +} + +.ds-status-scheduled { background: #e3f2fd; color: #1565C0; } +.ds-status-completed { background: #e8f5e9; color: #1B5E20; } +.ds-status-cancelled { background: #fce4ec; color: #880E4F; } diff --git a/addons/dance_reports/static/src/dashboard/dance_dashboard.xml b/addons/dance_reports/static/src/dashboard/dance_dashboard.xml new file mode 100644 index 0000000..4e0e1c6 --- /dev/null +++ b/addons/dance_reports/static/src/dashboard/dance_dashboard.xml @@ -0,0 +1,255 @@ + + + +
+ + +
+ + +
+ + +
+ Avatar +