From 20d22c1f043e53dc58f82a5abcbdd2842ada32a4 Mon Sep 17 00:00:00 2001 From: Pranay Date: Tue, 25 Nov 2025 16:45:09 +0530 Subject: [PATCH] Project timesheet updates --- .../static/src/css/ats.css | 450 ++++ .../static/src/css/colors.css | 103 + .../static/src/css/content.css | 1195 ++++++++++ .../static/src/js/candidates.js | 630 ++++++ .../static/src/js/job_requests.js | 2005 +++++++++++++++++ .../hr_recruitment_web_app/views/jd.xml | 1089 +++++++++ .../__manifest__.py | 3 + .../data/data.xml | 136 ++ .../models/__init__.py | 10 +- .../models/project_architecture_design.py | 29 + .../models/project_code_commit.py | 17 + .../models/project_costings.py | 46 + .../models/project_resource_cost.py | 71 + .../models/project_risk.py | 28 + .../models/project_sprint.py | 30 + .../models/project_stages.py | 696 ++++++ .../models/task_documents.py | 44 + .../security/ir.model.access.csv | 30 + .../view/project.xml | 12 - .../view/project_stages.xml | 540 +++++ .../wizards/task_reject_reason_wizard.py | 22 + .../wizards/task_reject_reason_wizard.xml | 23 + odoo/addons/base/security/base_groups.xml | 22 +- 23 files changed, 7207 insertions(+), 24 deletions(-) create mode 100644 addons_extensions/hr_recruitment_web_app/static/src/css/ats.css create mode 100644 addons_extensions/hr_recruitment_web_app/static/src/css/colors.css create mode 100644 addons_extensions/hr_recruitment_web_app/static/src/css/content.css create mode 100644 addons_extensions/hr_recruitment_web_app/static/src/js/candidates.js create mode 100644 addons_extensions/hr_recruitment_web_app/static/src/js/job_requests.js create mode 100644 addons_extensions/hr_recruitment_web_app/views/jd.xml create mode 100644 addons_extensions/project_task_timesheet_extended/models/project_architecture_design.py create mode 100644 addons_extensions/project_task_timesheet_extended/models/project_code_commit.py create mode 100644 addons_extensions/project_task_timesheet_extended/models/project_costings.py create mode 100644 addons_extensions/project_task_timesheet_extended/models/project_resource_cost.py create mode 100644 addons_extensions/project_task_timesheet_extended/models/project_risk.py create mode 100644 addons_extensions/project_task_timesheet_extended/models/project_sprint.py create mode 100644 addons_extensions/project_task_timesheet_extended/models/project_stages.py create mode 100644 addons_extensions/project_task_timesheet_extended/models/task_documents.py create mode 100644 addons_extensions/project_task_timesheet_extended/view/project_stages.xml diff --git a/addons_extensions/hr_recruitment_web_app/static/src/css/ats.css b/addons_extensions/hr_recruitment_web_app/static/src/css/ats.css new file mode 100644 index 000000000..75c9b03c5 --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/static/src/css/ats.css @@ -0,0 +1,450 @@ +@import url('colors.css'); + +/* ===== ATS Custom Layout Reset ===== */ +/* ===== Theme Toggle Styles ===== */ +.theme-toggle-container { + padding: 1rem; + margin-top: auto; /* Push to bottom */ + border-top: 1px solid var(--border-color); +} + +.theme-toggle { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.75rem 1rem; + border-radius: 8px; + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s ease; +} + +.theme-toggle:hover { + background-color: var(--primary-light); + color: var(--primary-color); +} + +.light-icon { + display: none; + color: #fbbf24; /* Amber color for sun */ +} + +.dark-icon { + color: #cbd5e1; /* Light gray for moon */ +} + +[data-theme="dark"] .light-icon { + display: block; +} + +[data-theme="dark"] .dark-icon { + display: none; +} + +.theme-text { + transition: opacity 0.2s ease; +} + +/* Collapsed state styles */ +.sidebar.collapsed .theme-text { + display: none; +} + +.sidebar.collapsed .theme-toggle { + justify-content: center; + padding: 0.75rem 0; +} + +.sidebar.collapsed .theme-toggle i { + margin-right: 0; + font-size: 1.25rem; +} + +body.ats-app { + margin: 0; + padding: 0; + font-family: "Segoe UI", sans-serif; + background-color: var(--body-bg); + color: var(--text-primary); + height: 100vh; + overflow: hidden; + display: flex; + flex-direction: column; + line-height: 1.5; +} + +/* ===== Main Layout ===== */ +.ats-app .layout-container { + display: flex; + flex: 1; + overflow: hidden; +} + +/* ===== Header ===== */ +.ats-app .main-header { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 1rem; + font-size: 1.8rem; + font-weight: 600; + padding: 1rem; + position: relative; /* For absolute positioning of toggle button */ +} + +/* Logo and title alignment */ +.ats-app .main-header img { + height: 40px; + width: auto; +} + +.ats-app .main-header span { + display: flex; + align-items: center; + height: 40px; /* Match logo height */ +} + +/* ===== Layout Container ===== */ +.ats-app .layout-container { + display: flex; + height: 100%; /* Adjust for header height */ + overflow: hidden; +} + +/* ===== Sidebar ===== */ +.ats-app .sidebar { + background-color: var(--sidebar-bg); + color: var(--sidebar-text); + padding: 1rem 1rem 2rem; + box-shadow: 2px 0 10px rgba(0, 0, 0, 0.05); + transition: all 0.4s ease-in-out; + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* Sidebar States */ +.ats-app .sidebar.expanded { + width: 240px; + min-width: 240px; +} + +.ats-app .sidebar.collapsed { + width: 60px; + min-width: 60px; + padding: 1rem 0.4rem 2rem; +} + +.ats-app .sidebar.collapsed .menu-item-text, +.ats-app .sidebar.collapsed .list-title, +.ats-app .sidebar.collapsed .main-header span { + opacity: 0; + width: 0; + height: 0; + overflow: hidden; + position: absolute; +} + +/* ===== Sidebar Menu ===== */ +.ats-app .menu-list { + list-style: none; + padding-top: 1rem; + margin: 2rem 0; + flex-grow: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.ats-app .list-title { + color: var(--text-secondary); + font-size: 0.9rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05rem; + margin: 1.5rem 1.5rem 0.5rem; + transition: all 0.3s ease; +} + +.ats-app .menu-list a { + display: flex; + align-items: center; + gap: 1rem; + color: var(--text-secondary); + text-decoration: none; + padding: 0.7rem 1rem; + border-radius: var(--radius-md); + transition: all 0.2s ease; + font-size: 0.95rem; +} + +.ats-app .menu-list a i { + font-size: 1.25rem; + min-width: 24px; + text-align: center; +} + +.ats-app .menu-list a:hover { + background-color: #DBEDFE; + color: #0061FF; + transform: translateX(6px); + box-shadow: 3px 3px 10px rgba(0, 0, 0, 0.2); + font-weight: 600; + letter-spacing: 0.5px; +} + +.ats-app .menu-list a.active { + background-color: #DBEDFE; + color: #0061FF; + font-weight: 500; +} + +.ats-app .menu-list a.active::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + background-color: #DBEDFE; + border-radius: var(--radius-sm) 0 0 var(--radius-sm); +} + +/* Toggle Button (Improved) */ +.ats-app .toggle-btn { + position: absolute; + top: 1.5rem; + right: -12px; + background-color: var(--surface-color); + color: var(--primary-color); + border: 1px solid var(--border-color); + padding: 0.5rem; + cursor: pointer; + border-radius: 50%; + box-shadow: var(--shadow-sm); + z-index: 20; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + transition: all 0.3s ease; +} + +.ats-app .toggle-btn:hover { + background-color: var(--primary-color); + color: white; + transform: translateX(2px); +} + +.ats-app .sidebar.collapsed .toggle-btn { + right: -12px; + transform: rotate(180deg); +} + +.ats-app .sidebar.collapsed .toggle-btn:hover { + transform: rotate(180deg) translateX(2px); +} + +/* ===== Main Content Area ===== */ +.ats-app .content-area { + flex: 1; + padding: 2rem; + background-color: var(--content-bg); + overflow-y: auto; + box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.02); + border-left: 1px solid #dcdde1; + min-width: 0; +} + +/* ===== Responsive Design ===== */ +@media (max-width: 1024px) { + .ats-app .layout-container { + flex-direction: column; + } + + .ats-app .sidebar { + width: 100%; + height: auto; + padding: 0; + border-right: none; + border-bottom: 1px solid var(--border-color); + } + + .ats-app .sidebar.expanded { + height: auto; + } + + .ats-app .sidebar.collapsed { + height: 60px; + overflow: hidden; + } + + .ats-app .menu-list { + flex-direction: row; + flex-wrap: wrap; + margin: 0; + padding: 0.5rem; + gap: 0.25rem; + } + + .ats-app .menu-list a { + flex-direction: column; + padding: 0.75rem; + gap: 0.25rem; + font-size: 0.75rem; + margin: 0; + } + + .ats-app .menu-list a i { + font-size: 1.1rem; + } + + .ats-app .menu-list a.active::before { + width: 100%; + height: 3px; + top: auto; + bottom: 0; + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + } + + .ats-app .list-title { + display: none; + } + + .ats-app .toggle-btn { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + top: auto; + left: auto; + background-color: var(--primary-color); + color: white; + box-shadow: var(--shadow-lg); + width: 48px; + height: 48px; + font-size: 1.25rem; + } + + .ats-app .content-area { + padding: 1.25rem; + } +} + +/* Animation for smoother transitions */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: translateY(0); } +} + +.ats-app .content-area > * { + animation: fadeIn 0.3s ease-out; +} + +/* Menu list styles */ +.menu-list { + list-style: none; + padding: 0; + margin: 0; +} + +.menu-list li { + margin-bottom: 4px; +} + +.main-menus { + padding-left: 1 rem; +} + +.menu-item { + display: flex; + align-items: center; + padding-left: 12rem; + padding: 12px 16px; + color: var(--text-primary); + text-decoration: none; + border-radius: 6px; + transition: all 0.2s ease; +} + +.menu-item:hover { + background-color: var(--primary-light); + color: var(--primary-color); +} + +.menu-item.active { + background-color: var(--primary-light); + color: var(--primary-color); + font-weight: 500; +} + +.menu-icon { + font-size: 1.25rem; + min-width: 24px; + margin-right: 12px; +} + +.menu-item-text { + transition: opacity 0.2s ease; +} + +/* Collapsed state styles */ +.sidebar.collapsed .menu-item { + justify-content: center; + padding: 12px 0; +} + +.sidebar.collapsed .menu-icon { + margin-right: 0; + font-size: 1.4rem; +} + +.sidebar.collapsed .menu-item-text { + display: none; +} + +/* Active state indicator for collapsed */ +.sidebar.collapsed .menu-item.active::after { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 4px; + height: 24px; + background-color: var(--primary-color); + border-radius: 0 4px 4px 0; +} + +/* Mobile responsive styles */ +@media (max-width: 768px) { + .sidebar.collapsed .menu-list { + display: flex; + justify-content: space-around; + } + + .sidebar.collapsed .menu-item { + flex-direction: column; + padding: 8px 4px; + font-size: 0.75rem; + } + + .sidebar.collapsed .menu-icon { + font-size: 1.2rem; + margin-bottom: 4px; + } + + .sidebar.collapsed .menu-item.active::after { + left: 50%; + top: auto; + bottom: 0; + transform: translateX(-50%); + width: 24px; + height: 3px; + } +} diff --git a/addons_extensions/hr_recruitment_web_app/static/src/css/colors.css b/addons_extensions/hr_recruitment_web_app/static/src/css/colors.css new file mode 100644 index 000000000..676eeaaff --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/static/src/css/colors.css @@ -0,0 +1,103 @@ + + /* Background Colors */ + --body-bg: #f5f6fa; + --sidebar-bg: #FAFCFF; + --create-model-bg: #FAFCFF; + --content-bg: #E3E9EF; + --active-search-bg: #ffffff; + --active-search-hover-bg: #f0f0f0; + --add-btn-bg: #3498db; + --add-btn-hover-bg: #2980b9; + --side-panel-bg: #FAFCFF; + --side-panel-item-hover: #f0f8ff; + --side-panel-item-selected: #d7eaff; + + /* Text Colors */ + --text-primary: #2f3542; + --text-secondary: #4B5865; + --text-muted: #6c757d; + --sidebar-text: #0F1419; + --active-search-text: #333; + --add-btn-color: #ffffff; + + /* Border Colors */ + --border-color: #dcdde1; + --border-light: #e0e0e0; + + /* Shadow Colors */ + --shadow-color: rgba(0, 0, 0, 0.1); + --shadow-dark: rgba(0, 0, 0, 0.2); + + /* Status Colors */ + --status-new: #3498db; + --status-interview: #f39c12; + --status-hired: #2ecc71; + --status-rejected: #e74c3c; +} + +[data-theme="dark"] { + /* Primary Colors - Professional Blue Tones */ + --primary-blue: #4A90E2; + --primary-blue-dark: #357ABD; + --primary-blue-light: #5BA0F2; + + /* Secondary Colors - Subtle & Professional */ + --secondary-purple: #8E44AD; + --secondary-green: #27AE60; + --secondary-red: #C0392B; + --secondary-yellow: #F39C12; + + /* Grayscale - Sophisticated Dark Grays */ + --white: #1E1E1E; + --gray-100: #2A2A2A; + --gray-200: #333333; + --gray-300: #3D3D3D; + --gray-400: #474747; + --gray-500: #525252; + --gray-600: #959595; + --gray-700: #B0B0B0; + --gray-800: #CACACA; + --gray-900: #E5E5E5; + --black: #F5F5F5; + + /* Semantic Colors - Balanced Visibility */ + --success: #27AE60; + --info: #3498DB; + --warning: #F39C12; + --danger: #E74C3C; + + /* Background Colors - Layered Depth */ + --body-bg: #1A1A1A; + --sidebar-bg: #232323; + --create-model-bg: #232323; + --content-bg: #2A2A2A; + --active-search-bg: #2A2A2A; + --active-search-hover-bg: #333333; + --add-btn-bg: #4A90E2; + --add-btn-hover-bg: #357ABD; + --side-panel-bg: #232323; + --side-panel-item-hover: #2A2A2A; + --side-panel-item-selected: #2D4158; + + /* Text Colors - High Contrast */ + --text-primary: #E5E5E5; + --text-secondary: #B0B0B0; + --text-muted: #959595; + --sidebar-text: #E0E0E0; + --active-search-text: #F5F5F5; + --add-btn-color: #FFFFFF; + + /* Border Colors - Subtle Definition */ + --border-color: #3D3D3D; + --border-light: #333333; + + /* Shadow Colors - Soft Depth */ + --shadow-color: rgba(0, 0, 0, 0.25); + --shadow-dark: rgba(0, 0, 0, 0.4); + + /* Status Colors - Professional Hierarchy */ + --status-new: #4A90E2; + --status-interview: #F39C12; + --status-hired: #27AE60; + --status-rejected: #E74C3C; +} \ No newline at end of file diff --git a/addons_extensions/hr_recruitment_web_app/static/src/css/content.css b/addons_extensions/hr_recruitment_web_app/static/src/css/content.css new file mode 100644 index 000000000..db02ad0ae --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/static/src/css/content.css @@ -0,0 +1,1195 @@ +@import url('colors.css'); + +/* Main Container */ +#ats-details-container { + overflow-y: auto; + max-height: 100%; + position: relative; + background-color: var(--content-bg); +} + +/* Grid Layout */ +.ats-grid { + display: grid; + grid-template-columns: repeat(12, 1fr); + grid-auto-rows: minmax(100px, auto); + gap: 20px; + padding: 20px; + font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + color: var(--text-primary); + background-color: var(--content-bg); + max-width: 100%; + margin: 0 auto; + transition: all 0.3s ease; + overflow: hidden; +} + +/* Card Base */ +.ats-grid .ats-card { + overflow: hidden; + background-color: var(--sidebar-bg); + border-radius: 12px; + padding: 25px; + margin-bottom: 25px; + box-shadow: 0 4px 20px var(--shadow-color); + transition: all 0.3s ease; + border: 1px solid var(--border-light); +} + +/* Hover Effect */ +.ats-grid .ats-card:hover { + box-shadow: 0 8px 30px var(--shadow-dark); + transform: translateY(-5px); +} + +/* Width Span Utilities */ +.ats-grid .span-1 { grid-column: span 1; } +.ats-grid .span-2 { grid-column: span 2; } +.ats-grid .span-3 { grid-column: span 3; } +.ats-grid .span-4 { grid-column: span 4; } +.ats-grid .span-5 { grid-column: span 5; } +.ats-grid .span-6 { grid-column: span 6; } +.ats-grid .span-7 { grid-column: span 7; } +.ats-grid .span-8 { grid-column: span 8; } +.ats-grid .span-9 { grid-column: span 9; } +.ats-grid .span-10 { grid-column: span 10; } +.ats-grid .span-11 { grid-column: span 11; } +.ats-grid .span-12 { grid-column: span 12; } + +/* Navigation Sidebar */ +.ats-grid #ats-overview { + background-color: var(--gray-800); + color: var(--white); + padding: 20px; + border-radius: 12px; + box-shadow: 0 8px 24px var(--shadow-dark); + transition: all 0.3s ease; + font-family: 'Segoe UI', Tahoma, sans-serif; + width: 220px; + border: 1px solid var(--gray-700); +} + +.ats-grid #ats-overview h3, +.ats-grid #ats-overview .section-title { + border-bottom: 1px solid var(--gray-700); + margin-bottom: 12px; + padding-bottom: 8px; + font-weight: 600; + color: var(--gray-200); +} + +.ats-grid .overview-nav { + padding: 10px 0; +} + +.ats-grid .nav-list { + list-style: none; + padding: 0; + margin: 0; +} + +.ats-grid .nav-list li { + margin-bottom: 8px; + position: relative; + padding-left: 0px; +} + +.ats-grid .nav-link { + display: flex; + align-items: center; + padding-top: 5px; + padding-bottom: 5px; + color: var(--gray-300); + text-decoration: none; + border-radius: 6px; + transition: all 0.3s ease; + font-size: 14px; +} + +.ats-grid .nav-link:hover { + background-color: var(--gray-700); + color: var(--white); + transform: translateX(6px); + box-shadow: 3px 3px 10px var(--shadow-dark); + font-weight: 600; + letter-spacing: 0.5px; +} + +.ats-grid .nav-link i { + width: 20px; + text-align: center; +} + +/* Header Section */ +.ats-grid .ats-title { + font-size: 28px; + font-weight: 600; + margin-bottom: 15px; + color: var(--text-primary); +} + +.ats-grid .ats-meta { + display: flex; + flex-wrap: wrap; + gap: 15px; + margin-bottom: 20px; + color: var(--text-muted); +} + +.ats-grid .meta-item { + display: flex; + align-items: center; + font-size: 14px; +} + +/* Status Bar */ +.ats-grid .status-bar { + background-color: var(--gray-100); + padding: 15px; + border-radius: 8px; + margin-bottom: 20px; +} + +.ats-grid .recruitment-status { + margin-bottom: 10px; +} + +.ats-grid .status-label { + font-weight: 600; + color: var(--text-secondary); +} + +.ats-grid .status-value { + font-weight: 500; + color: var(--text-primary); +} + +.ats-grid .recruitment-progress .progress { + height: 20px; + border-radius: 5px; + background-color: var(--gray-200); +} + +.ats-grid .progress-bar { + background-color: var(--primary-blue); +} + +/* Section Titles */ +.ats-grid .section-title { + margin: 0; + padding-bottom: 20px; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + border-bottom: 2px solid var(--gray-200); + transition: all 0.3s ease; +} + +.ats-grid .ats-card:hover .section-title { + border-color: var(--gray-300); +} + +.ats-grid .section-title i { + color: var(--text-muted); + transition: all 0.3s ease; +} + + +.ats-grid .section-title small { + margin-left: auto; /* This pushes the small element to the right */ + font-weight: normal; + font-size: 0.85em; + color: var(--text-muted); +} + +/* Detail Grid */ +.ats-grid .detail-grid { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + margin-top: 0px; +} + +.ats-grid .detail-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.ats-grid .detail-row:hover { + background-color: var(--gray-100); + border-radius: 3px; +} + +.ats-grid .detail-label { + font-weight: 500; + color: var(--text-secondary); + min-width: 120px; + font-size: 15px; + flex-shrink: 0; +} + +.ats-grid .span-6 .detail-label { + min-width: 160px; +} + +.ats-grid .detail-value { + color: var(--text-primary); + font-weight: 400; + flex: 1; + font-size: 13px; + word-break: break-word; + overflow-wrap: break-word; +} + +/* Skills and Location Badges */ +.ats-grid .skills-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ats-grid .skill-badge, +.ats-grid .location-badge { + background-color: var(--gray-100); + color: var(--primary-blue); + padding: 6px 12px; + border-radius: 20px; + transition: all 0.3s ease; +} + +.ats-grid .skill-badge:hover, +.ats-grid .location-badge:hover { + background-color: var(--gray-200); + transform: translateY(-3px); + box-shadow: 0 4px 8px rgba(15, 81, 50, 0.1); +} + +/* Team Members */ +.ats-grid .team-member { + margin-bottom: 20px; +} + +.ats-grid .member-header { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 10px; + display: flex; + align-items: center; +} + +.ats-grid .member-title { + margin-left: 5px; +} + +.ats-grid .recruiter-photo { + width: 60px; + height: 60px; + object-fit: cover; + border: 2px solid var(--gray-200); +} + +.ats-grid .member-name { + font-weight: 600; + color: var(--text-primary); +} + +.ats-grid .member-email { + font-size: 13px; + color: var(--text-muted); +} + +/* Description Content */ +.ats-grid .description-content { + line-height: 1.6; + color: var(--text-primary); +} + +.ats-grid .description-content p { + margin-bottom: 15px; +} + +.ats-grid .description-content ul, +.ats-grid .description-content ol { + padding-left: 20px; + margin-bottom: 15px; +} + +/* Responsive fallback */ +@media (max-width: 768px) { + .ats-grid { + grid-template-columns: repeat(2, 1fr); + } + .ats-grid .ats-card { + grid-column: span 2 !important; + } +} + +@media print { + #ats-details-container { + max-height: none; + overflow-y: visible; + } +} + + +/* Smart Button Container */ +.smart-button-container { + position: fixed; + top: 20%; + right: 1%; + z-index: 1000; + display: flex; + gap: 10px; + flex-direction: column; + align-items: flex-end; +} + +.smart-button { + background-color: var(--primary-blue); + color: var(--white); + border: none; + border-radius: 8px; + padding: 10px 16px; + font-weight: 500; + font-size: 14px; + box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3); + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 8px; + min-width: 180px; + justify-content: center; +} + +.smart-button:hover { + background-color: var(--primary-blue-dark); + transform: translateY(-3px); + box-shadow: 0 6px 16px rgba(52, 152, 219, 0.4); +} + +.smart-button:active { + transform: translateY(1px); + box-shadow: 0 2px 8px rgba(52, 152, 219, 0.3); +} + +.smart-button i { + transition: transform 0.3s ease; +} + +.smart-button:hover i { + transform: rotate(15deg); +} + +/* Published Ribbon */ +.status-ribbon { + position: absolute; + top: 35px; + right: -30px; + transform: rotate(45deg); + padding: 8px 40px; + font-size: 14px; + font-weight: 600; + color: var(--white); + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); + z-index: 100; + transition: all 0.3s ease; + text-transform: uppercase; + letter-spacing: 1px; +} + +.status-ribbon.ribbon-published { + background: linear-gradient(45deg, var(--success), #2ecc71); +} + +.status-ribbon.ribbon-not-published { + background: linear-gradient(45deg, var(--danger), #e74c3c); +} + +.status-ribbon:hover { + transform: rotate(45deg) scale(1.05); + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); +} + +/* Professional Typography */ +.ats-grid { + font-family: 'Inter', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + letter-spacing: 0.01em; +} + +.ats-grid .ats-title { + font-size: 28px; + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1.2; + margin-bottom: 16px; + color: var(--text-primary); +} + +.ats-grid .section-title { + font-size: 18px; + font-weight: 600; + letter-spacing: -0.01em; + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 2px solid var(--gray-200); +} +.ats-grid .fa{ + padding-right:5px; +} +.ats-list { + background-color:linear-gradient(90deg,rgba(2, 0, 36, 1) 0%, rgba(9, 9, 121, 1) 35%, rgba(0, 212, 255, 1) 100%); + +} +.ats-grid .detail-label { + font-weight: 600; + color: var(--text-secondary); + font-size: 15px; +} + +.ats-grid .detail-value { + font-weight: 500; + color: var(--text-primary); + font-size: 15px; +} + +.ats-grid .meta-item { + font-size: 14px; + font-weight: 500; + color: var(--text-secondary); +} + +.ats-grid .status-label { + font-weight: 600; + color: var(--text-secondary); + font-size: 14px; +} + +.ats-grid .status-value { + font-weight: 500; + color: var(--text-primary); + font-size: 14px; +} + +.ats-grid .skill-badge, +.ats-grid .location-badge { + font-size: 13px; + font-weight: 500; +} + +/* Smooth animations for all interactive elements */ +.ats-grid .ats-card { + transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +.ats-grid .detail-row { + transition: background-color 0.2s ease; +} + +.ats-grid .nav-link { + transition: all 0.3s ease; +} + +.ats-grid .skill-badge, +.ats-grid .location-badge { + transition: all 0.3s ease; +} + +/* Professional shadows and borders */ +.ats-grid .ats-card { + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + border: 1px solid rgba(0, 0, 0, 0.05); +} + +.ats-grid .ats-card:hover { + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12); + border-color: rgba(0, 0, 0, 0.08); +} + +/* Close button styling */ +.close-detail { + position: absolute; + top: 15px; + left: 15px; + width: 36px; + height: 36px; + border-radius: 50%; + background-color: var(--white); + border: none; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + z-index: 100; + cursor: pointer; + transition: all 0.3s ease; +} + +.close-detail:hover { + background-color: var(--gray-100); + transform: rotate(90deg); +} + +.close-detail span { + font-size: 20px; + color: var(--text-secondary); + line-height: 1; +} + + +/* ===== Work Experience Section ===== */ +.ats-grid .experience-list { + display: flex; + flex-direction: column; + gap: 20px; + margin-top: 15px; +} + +.ats-grid .experience-item { + background-color: var(--white); + border-radius: 10px; + padding: 20px; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08); + border-left: 4px solid var(--primary-blue); + transition: all 0.3s ease; + position: relative; +} + +.ats-grid .experience-item:hover { + transform: translateY(-3px); + box-shadow: 0 6px 15px rgba(0, 0, 0, 0.12); +} + +.ats-grid .exp-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 12px; +} + +.ats-grid .exp-header h5 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.ats-grid .exp-company { + font-size: 16px; + color: var(--primary-blue); + font-weight: 500; + background-color: rgba(52, 152, 219, 0.1); + padding: 4px 12px; + border-radius: 20px; +} + +.ats-grid .exp-duration { + display: flex; + align-items: center; + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.ats-grid .exp-duration i { + color: var(--primary-blue); + margin-right: 8px; +} + +.ats-grid .exp-ctc { + display: flex; + align-items: center; + font-size: 14px; + color: var(--text-primary); + font-weight: 500; +} + +.ats-grid .exp-ctc i { + color: var(--success); + margin-right: 8px; +} + +/* ===== Education Section ===== */ +.ats-grid .education-list { + display: flex; + flex-direction: column; + gap: 20px; + margin-top: 15px; +} + +.ats-grid .education-item { + background-color: var(--white); + border-radius: 10px; + padding: 20px; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08); + border-left: 4px solid var(--success); + transition: all 0.3s ease; + position: relative; +} + +.ats-grid .education-item:hover { + transform: translateY(-3px); + box-shadow: 0 6px 15px rgba(0, 0, 0, 0.12); +} + +.ats-grid .edu-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 12px; +} + +.ats-grid .edu-header h5 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.ats-grid .edu-type { + font-size: 14px; + color: var(--white); + font-weight: 500; + background-color: var(--success); + padding: 4px 12px; + border-radius: 20px; +} + +.ats-grid .edu-university { + font-size: 15px; + color: var(--text-secondary); + margin-bottom: 8px; + font-style: italic; +} + +.ats-grid .edu-duration { + display: flex; + align-items: center; + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.ats-grid .edu-duration i { + color: var(--success); + margin-right: 8px; +} + +.ats-grid .edu-marks { + display: flex; + align-items: center; + font-size: 14px; + color: var(--text-primary); + font-weight: 500; +} + +.ats-grid .edu-marks i { + color: var(--warning); + margin-right: 8px; +} + +/* ===== Skills Section ===== */ +.ats-grid .skills-container { + display: flex; + flex-direction: column; + gap: 15px; + margin-top: 15px; +} + +.ats-grid .skill-item { + background-color: var(--white); + border-radius: 10px; + padding: 15px 20px; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08); + transition: all 0.3s ease; +} + +.ats-grid .skill-item:hover { + transform: translateY(-3px); + box-shadow: 0 6px 15px rgba(0, 0, 0, 0.12); +} + +.ats-grid .skill-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.ats-grid .skill-name { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.ats-grid .skill-type { + font-size: 13px; + color: var(--white); + background-color: var(--primary-blue); + padding: 3px 10px; + border-radius: 15px; +} + +.ats-grid .skill-level { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ats-grid .skill-level-name { + font-size: 13px; + color: var(--text-secondary); + text-align: right; +} + +.ats-grid .progress { + height: 8px; + border-radius: 4px; + background-color: var(--gray-200); + overflow: hidden; +} + +.ats-grid .progress-bar { + height: 100%; + border-radius: 4px; + background: linear-gradient(90deg, var(--primary-blue), #3498db); + transition: width 1s ease-in-out; +} + +/* ===== Attachments Section ===== */ +.ats-grid .attachments-list { + display: flex; + flex-direction: column; + gap: 15px; + margin-top: 15px; +} + +.ats-grid .attachment-card { + background-color: var(--white); + border-radius: 10px; + padding: 15px 20px; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08); + transition: all 0.3s ease; +} + +.ats-grid .attachment-card:hover { + transform: translateY(-3px); + box-shadow: 0 6px 15px rgba(0, 0, 0, 0.12); +} + +.ats-grid .attachment-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.ats-grid .attachment-title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + word-break: break-word; + max-width: 70%; +} + +.ats-grid .attachment-badge { + font-size: 12px; + color: var(--white); + padding: 3px 10px; + border-radius: 15px; +} + +.ats-grid .attachment-badge.bg-success { + background-color: var(--success); +} + +.ats-grid .attachment-badge.bg-danger { + background-color: var(--danger); +} + +.ats-grid .attachment-badge.bg-secondary { + background-color: var(--gray-500); +} + +.ats-grid .attachment-actions { + display: flex; + gap: 10px; +} + +.ats-grid .document-action-btn { + display: flex; + align-items: center; + gap: 5px; + font-size: 13px; + padding: 6px 12px; + border-radius: 6px; + transition: all 0.2s ease; +} + +.ats-grid .document-action-btn:hover { + transform: translateY(-2px); +} + +.ats-grid .preview-btn { + background-color: var(--primary-blue); + color: var(--white); + border: 1px solid var(--primary-blue); +} + +.ats-grid .preview-btn:hover { + background-color: var(--white); + color: var(--primary-blue); +} + +.ats-grid .download-btn { + background-color: var(--success); + color: var(--white); + border: 1px solid var(--success); +} + +.ats-grid .download-btn:hover { + background-color: var(--white); + color: var(--success); +} + +/* ===== Timeline Section ===== */ +.ats-grid .timeline { + position: relative; + margin-top: 20px; + padding-left: 30px; +} + +.ats-grid .timeline::before { + content: ''; + position: absolute; + top: 0; + left: 8px; + height: 100%; + width: 2px; + background-color: var(--gray-300); +} + +.ats-grid .timeline-item { + position: relative; + margin-bottom: 25px; +} + +.ats-grid .timeline-point { + position: absolute; + left: -30px; + width: 16px; + height: 16px; + border-radius: 50%; + background-color: var(--primary-blue); + border: 3px solid var(--white); + box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.3); +} + +.ats-grid .timeline-point.point-interview { + background-color: var(--warning); + box-shadow: 0 0 0 3px rgba(243, 156, 18, 0.3); +} + +.ats-grid .timeline-content { + background-color: var(--white); + border-radius: 8px; + padding: 15px 20px; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08); + transition: all 0.3s ease; +} + +.ats-grid .timeline-content:hover { + transform: translateY(-3px); + box-shadow: 0 6px 15px rgba(0, 0, 0, 0.12); +} + +.ats-grid .timeline-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.ats-grid .timeline-header h5 { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.ats-grid .timeline-date { + font-size: 13px; + color: var(--text-secondary); +} + +.ats-grid .timeline-body { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.5; +} + +.ats-grid .timeline-body p { + margin: 0; +} + +/* ===== Alert Messages ===== */ +.ats-grid .alert { + border-radius: 8px; + padding: 15px 20px; + border: none; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08); +} + +.ats-grid .alert-info { + background-color: rgba(23, 162, 184, 0.1); + color: var(--info); + border-left: 4px solid var(--info); +} + +/* ===== Responsive Design ===== */ +@media (max-width: 768px) { + .ats-grid .exp-header, .ats-grid .edu-header { + flex-direction: column; + gap: 8px; + } + + .ats-grid .skill-info { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .ats-grid .attachment-header { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .ats-grid .attachment-title { + max-width: 100%; + } + + .ats-grid .timeline-header { + flex-direction: column; + align-items: flex-start; + gap: 5px; + } +} + +/* ===== Avatar Styling ===== */ +.ats-grid .avatar-wrapper { + position: relative; + width: 120px; + height: 120px; + margin-left: auto; +} + +.ats-grid .avatar-img { + width: 100%; + height: 100%; + object-fit: cover; + border: 3px solid var(--primary-blue); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.ats-grid .avatar-placeholder { + width: 100%; + height: 100%; + background: linear-gradient(135deg, var(--primary-blue), #3498db); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + border: 3px solid var(--primary-blue); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + position: relative; + overflow: hidden; +} + +.ats-grid .avatar-placeholder::before { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: linear-gradient( + to right, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0.1) 50%, + rgba(255, 255, 255, 0) 100% + ); + transform: rotate(30deg); +} + +.ats-grid .applicant-img-placeholder { + font-size: 48px; + font-weight: 700; + color: white; + text-transform: uppercase; + position: relative; + z-index: 1; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.ats-grid .avatar-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.3s ease; + cursor: pointer; +} + +.ats-grid .avatar-wrapper:hover .avatar-overlay { + opacity: 1; +} + +.ats-grid .avatar-overlay i { + color: white; + font-size: 24px; +} + + + +.ats-card .edit-card-btn { + position: absolute; + top: 10px; + right: 10px; + background-color: var(--primary-blue); + color: white; + border: none; + border-radius: 50%; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: opacity 0.3s ease, transform 0.2s ease; + z-index: 10; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.ats-card:hover .edit-card-btn{ + opacity: 1; +} + +.ats-card .edit-card-btn:hover { + transform: scale(1.1); + background-color: var(--primary-blue-dark); +} + +/* Edit Form Container */ +.edit-form-container { + padding: 20px; + background-color: var(--sidebar-bg); + border-radius: 12px; + box-shadow: 0 4px 20px var(--shadow-color); +} + +.edit-form-container .form-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 1px solid var(--border-light); +} + +.edit-form-container .form-actions { + display: flex; + gap: 10px; +} + +.edit-form-container .form-content { + margin-top: 15px; +} + +/* Form Styles in Edit Mode */ +.edit-form-container .form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 15px; +} + +.edit-form-container .form-group { + margin-bottom: 15px; +} + +.edit-form-container label { + display: block; + margin-bottom: 5px; + font-weight: 500; + color: var(--text-secondary); +} + +.edit-form-container .form-input, +.edit-form-container .form-select { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + background-color: var(--white); + color: var(--text-primary); + transition: border-color 0.2s ease; +} + +.edit-form-container .form-input:focus, +.edit-form-container .form-select:focus { + border-color: var(--primary-blue); + outline: none; + box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2); +} + +.edit-form-container .full-width { + grid-column: 1 / -1; +} + +/* Editor Styles */ +.edit-form-container .oe_editor { + min-height: 300px; + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 10px; + background-color: var(--white); +} + +/* Save/Cancel Button Styles */ +.edit-form-container .save-edit-btn, +.edit-form-container .cancel-edit-btn { + width: 36px; + height: 36px; + border-radius: 50%; + border: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform 0.2s ease; +} + +.edit-form-container .save-edit-btn { + background-color: var(--success); + color: white; +} + +.edit-form-container .save-edit-btn:hover , .edit-form-container .cancel-edit-btn:hover { + transform: scale(1.1); +} + +.edit-form-container .cancel-edit-btn { + background-color: var(--danger); + color: white; +} \ No newline at end of file diff --git a/addons_extensions/hr_recruitment_web_app/static/src/js/candidates.js b/addons_extensions/hr_recruitment_web_app/static/src/js/candidates.js new file mode 100644 index 000000000..4e432a74e --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/static/src/js/candidates.js @@ -0,0 +1,630 @@ +/** @odoo-module **/ +function initCandidatesPage() { + console.log("candidates Page Loaded"); + const candidateDetailArea = document.getElementById("candidates-detail"); + const container = document.querySelector('.ats-list-container'); // Added this line + const toggleBtn = document.getElementById("candidates-list-sidebar-toggle-btn"); // Added this line + const sidebar = document.getElementById("candidates-list-panel"); // Added this line + + document.querySelectorAll(".ats-item.candidates-item").forEach(item => { + item.addEventListener("click", function() { + document.querySelectorAll(".candidates-item.selected").forEach(el => el.classList.remove("selected")); + this.classList.add("selected"); + + // Show the detail panel and add necessary classes + candidateDetailArea.style.display = 'block'; // Added this line + container.classList.add('ats-selected'); // Added this line + sidebar.classList.remove('collapsed'); // Added this line + toggleBtn.style.display = 'flex'; // Added this line + + const candidateId = this.dataset.id; + console.log("Candidate ID:", candidateId); // Added for debugging + + // Show loading state + if (candidateDetailArea) { + candidateDetailArea.innerHTML = '

Loading candidate details...

'; + } + + fetch(`/myATS/candidate/detail/${candidateId}`, { + headers: { "X-Requested-With": "XMLHttpRequest" } + }) + .then(res => { + if (!res.ok) throw new Error('Network response was not ok'); + return res.text(); + }) + .then(html => { + console.log("Response received"); // Added for debugging + if (candidateDetailArea) { + candidateDetailArea.innerHTML = html; + initCandidateDetailEdit(); + + // Add close button functionality + const closeBtn = candidateDetailArea.querySelector('.close-detail'); + if (closeBtn) { + closeBtn.addEventListener('click', function() { + candidateDetailArea.style.display = 'none'; + container.classList.remove('ats-selected'); + document.querySelectorAll(".ats-item.candidates-item.selected").forEach(el => el.classList.remove("selected")); + }); + } + } + }) + .catch(error => { + console.error('Error loading candidate details:', error); + if (candidateDetailArea) { + candidateDetailArea.innerHTML = '
Error loading candidate details. Please try again.
'; + } + }); + }); + }); + + // Search functionality + const search = document.getElementById("candidates-search"); + if (search) { + search.addEventListener("input", function() { + const query = this.value.toLowerCase(); + let visibleCount = 0; + document.querySelectorAll(".ats-item.candidates-item").forEach(item => { + const match = item.textContent.toLowerCase().includes(query); + item.style.display = match ? "" : "none"; + if (match) visibleCount++; + }); + const countElement = document.getElementById("active-records-count"); + if (countElement) { + countElement.textContent = visibleCount; + } + }); + } + + // Sidebar Toggle + if (toggleBtn && sidebar) { // Added this check + toggleBtn.addEventListener("click", function(e) { + e.stopPropagation(); + sidebar.classList.toggle("collapsed"); + }); + } + + // Rest of your code remains the same... + const createCandidate = document.getElementById('add-candidate-create-btn'); + const candidateModal = document.getElementById('candidate-form-modal'); + const form = document.getElementById('candidate-form'); + const closeModal = document.querySelectorAll('.candidate-form-close, .btn-cancel'); + const avatarUpload = document.getElementById('avatar-upload'); + const candidateImage = document.getElementById('candidate-image'); + const avatarUploadIcon = document.querySelector('.avatar-upload i'); + + if (avatarUpload && candidateImage) { + // Make the entire avatar clickable + candidateImage.parentElement.style.cursor = 'pointer'; + // Handle click on avatar + candidateImage.parentElement.addEventListener('click', function(e) { + if (e.target !== avatarUpload && e.target !== avatarUploadIcon) { + avatarUpload.click(); + } + }); + // Handle file selection + avatarUpload.addEventListener('change', function(e) { + const file = e.target.files[0]; + if (file && file.type.match('image.*')) { + const reader = new FileReader(); + reader.onload = function(e) { + candidateImage.src = e.target.result; + }; + reader.readAsDataURL(file); + } + }); + } + + // Resume Upload Elements + const resumeUpload = document.getElementById('resume-upload'); + const resumeDropzone = document.getElementById('resume-dropzone'); + const resumePreview = document.getElementById('resume-preview'); + const resumePlaceholder = document.querySelector('.resume-preview-placeholder'); + const resumeIframe = document.getElementById('resume-iframe'); + const resumeImage = document.getElementById('resume-image'); + const unsupportedFormat = document.getElementById('unsupported-format'); + const downloadResume = document.getElementById('download-resume'); + const uploadResumeBtn = document.getElementById('upload-applicant-resume'); + let currentResumeFile = null; + + const saveBtn = document.getElementById('save-candidate'); + if (saveBtn) { + saveBtn.addEventListener('click', function (e) { + e.preventDefault(); + createNewCandidate(form, candidateModal); + }); + } + + if (createCandidate) { + createCandidate.addEventListener('click', function(e) { + e.preventDefault(); + candidateModal.style.display = 'flex'; + setTimeout(() => { + candidateModal.classList.add('show'); + }, 10); + document.body.style.overflow = 'hidden'; + setTimeout(() => { + initSelect2(); + initResumeUploadHandlers(); + }, 100); + }); + } + + if (closeModal) { + closeModal.forEach(btn => { + btn.addEventListener('click', function() { + candidateModal.classList.remove('show'); + setTimeout(() => { + candidateModal.style.display = 'none'; + }, 300); + document.body.style.overflow = ''; + }); + }); + } + +// candidateModal.addEventListener('click', function(e) { +// if (e.target === candidateModal) { +// candidateModal.classList.remove('show'); +// setTimeout(() => { +// candidateModal.style.display = 'none'; +// }, 300); +// document.body.style.overflow = ''; +// } +// }); + + function formatUserOption(user) { + if (!user.id) return user.text; + var imageUrl = $(user.element).data('image') || '/web/static/img/placeholder.png'; + var $container = $( + '' + + '' + + '' + user.text + '' + + '' + ); + return $container; + } + + function formatUserSelection(user) { + if (!user.id) return user.text; + var imageUrl = $(user.element).data('image') || '/web/static/img/placeholder.png'; + var $container = $( + '' + + '' + + '' + user.text + '' + + '' + ); + return $container; + } + + function initSelect2() { + const candidateSkills = document.getElementById('candidate-skills'); + if (candidateSkills) { + $(candidateSkills).select2({ + placeholder: 'Select skills', + allowClear: true, + dropdownParent: $('.candidate-form-modal'), + width: '100%', + escapeMarkup: function(m) { return m; } + }); + } + + const managerSelect = document.getElementById('manager'); + if (managerSelect) { + $(managerSelect).select2({ + placeholder: 'Select Manager', + allowClear: true, + templateResult: formatUserOption, + templateSelection: formatUserSelection, + escapeMarkup: function(m) { return m; } + }); + } + } + + function initResumeUploadHandlers() { + // Create remove button + const removeResumeBtn = document.createElement('button'); + removeResumeBtn.innerHTML = ' Remove Resume'; + removeResumeBtn.className = 'btn btn-danger btn-sm mt-2'; + removeResumeBtn.style.display = 'none'; + resumePreview.appendChild(removeResumeBtn); + + // Handle remove resume + removeResumeBtn.addEventListener('click', function() { + resetResumePreview(); + }); + + function resetResumePreview() { + // Clear file input + resumeUpload.value = ''; + currentResumeFile = null; + // Reset preview + resumePlaceholder.style.display = 'flex'; + resumeIframe.style.display = 'none'; + resumeImage.style.display = 'none'; + unsupportedFormat.style.display = 'none'; + removeResumeBtn.style.display = 'none'; + // Reset iframe/src to prevent memory leaks + if (resumeIframe.src) { + URL.revokeObjectURL(resumeIframe.src); + resumeIframe.src = ''; + } + if (resumeImage.src) { + URL.revokeObjectURL(resumeImage.src); + resumeImage.src = ''; + } + if (downloadResume.href) { + URL.revokeObjectURL(downloadResume.href); + downloadResume.href = '#'; + } + } + + // Unified upload handler for both preview and parsing + uploadResumeBtn.addEventListener('click', function(e) { + e.preventDefault(); + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = '.pdf,.doc,.docx,.txt,.jpg,.jpeg,.png'; + fileInput.onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + + // First handle the preview + handleResumeFile(file); + + // Then try to parse the resume if it's a parseable type + if (file.type.match(/pdf|msword|openxmlformats|text/)) { + // Show loading state + const button = uploadResumeBtn; + const originalText = button.innerHTML; + button.innerHTML = ' Processing Resume...'; + button.disabled = true; + + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('type', 'candidate'); + + const response = await fetch('/resume/upload', { + method: 'POST', + body: formData, + credentials: 'same-origin' + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + populateCandidateForm(result); + } catch (error) { + console.error('Error parsing Resume:', error); + showNotification('Failed to parse Resume. Please try again or enter manually.', 'danger'); + } finally { + button.innerHTML = ' Upload Resume'; + button.disabled = false; + } + } + }; + fileInput.click(); + }); + + // Handle click on dropzone + resumeDropzone.addEventListener('click', function(e) { + if (e.target === this || e.target.classList.contains('upload-icon') || + e.target.tagName === 'H5' || e.target.tagName === 'P') { + resumeUpload.click(); + } + }); + + // Handle drag and drop + resumeDropzone.addEventListener('dragover', function(e) { + e.preventDefault(); + e.stopPropagation(); + this.classList.add('dragover'); + this.style.borderColor = '#3498db'; + this.style.backgroundColor = 'rgba(52, 152, 219, 0.1)'; + }); + + resumeDropzone.addEventListener('dragleave', function(e) { + e.preventDefault(); + e.stopPropagation(); + this.classList.remove('dragover'); + this.style.borderColor = ''; + this.style.backgroundColor = ''; + }); + + resumeDropzone.addEventListener('drop', function(e) { + e.preventDefault(); + e.stopPropagation(); + this.classList.remove('dragover'); + this.style.borderColor = ''; + this.style.backgroundColor = ''; + + if (e.dataTransfer.files.length) { + const file = e.dataTransfer.files[0]; + handleResumeFile(file); + } + }); + + // Handle file selection from the regular input + resumeUpload.addEventListener('change', function(e) { + if (this.files.length) { + handleResumeFile(this.files[0]); + } + }); + + function handleResumeFile(file) { + const validTypes = [ + 'application/pdf', + 'application/msword', + 'application/wps-office.docx', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'image/jpeg', + 'image/png', + 'text/plain' + ]; + + if (!validTypes.includes(file.type)) { + alert('Please upload a valid file type (PDF, Word, Image, or Text)'); + return; + } + + currentResumeFile = file; + + // Hide placeholder + resumePlaceholder.style.display = 'none'; + + // Set up download link + const fileURL = URL.createObjectURL(file); + downloadResume.href = fileURL; + downloadResume.download = file.name; + removeResumeBtn.style.display = 'block'; + + // Check file type and show appropriate preview + if (file.type === 'application/pdf') { + // PDF preview + resumeIframe.src = fileURL; + resumeIframe.style.display = 'block'; + resumeImage.style.display = 'none'; + unsupportedFormat.style.display = 'none'; + } else if (file.type.match('image.*')) { + // Image preview + resumeImage.src = fileURL; + resumeImage.style.display = 'block'; + resumeIframe.style.display = 'none'; + unsupportedFormat.style.display = 'none'; + } else { + // Unsupported format for preview + unsupportedFormat.style.display = 'flex'; + resumeIframe.style.display = 'none'; + resumeImage.style.display = 'none'; + } + + // Update the actual resume-upload input + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + resumeUpload.files = dataTransfer.files; + } + } + + function populateCandidateForm(resumeData) { + // Add CSS for visual feedback + const style = document.createElement('style'); + style.textContent = ` + .populated-field { + background-color: #f5f5f5 !important; + transition: background-color 0.3s ease; + } + .select2-populated .select2-selection--multiple { + background-color: #f5f5f5 !important; + } + .select2-populated .select2-selection__choice { + background-color: #e8f5e9 !important; + border-color: #c8e6c9 !important; + } + `; + document.head.appendChild(style); + + // Helper function to set value with visual feedback + function setValueWithFeedback(elementId, value) { + const element = document.getElementById(elementId); + if (element && value) { + element.value = value; + element.classList.add('populated-field'); + + // Special handling for Select2 if this element uses it + if ($(element).hasClass('select2-hidden-accessible')) { + $(element).next('.select2-container') + .find('.select2-selection') + .addClass('populated-field'); + } + return true; + } + return false; + } + + // Section 1: Basic Information + if (resumeData.personal_info) { + const personal = resumeData.personal_info; + + // Set values with visual feedback + setValueWithFeedback('partner-name', personal.name); + setValueWithFeedback('email', personal.email); + setValueWithFeedback('phone', personal.phone); + setValueWithFeedback('linkedin', personal.linkedin); + + // If any of these fields are Select2 elements, ensure they get styled + ['partner-name', 'email', 'phone', 'linkedin'].forEach(id => { + const el = document.getElementById(id); + if (el && el.value && $(el).hasClass('select2-hidden-accessible')) { + $(el).next('.select2-container') + .find('.select2-selection') + .addClass('populated-field'); + } + }); + } + + // Skills - Handle Select2 with background color change + if (resumeData.skills?.length) { + const skillValues = resumeData.skills.map(skill => skill.id); + $('#candidate-skills') + .val(skillValues) + .trigger('change') + .addClass('populated-field'); + + // Add class to Select2 container for styling + $('#candidate-skills').next('.select2-container') + .find('.select2-selection--multiple') + .addClass('select2-populated'); + } + + // Show notification + showNotification('Resume uploaded and fields populated successfully!', 'success'); + } + + function showNotification(message, type) { + // Check if notification container exists, create if not + let notificationContainer = document.getElementById('notification-container'); + if (!notificationContainer) { + notificationContainer = document.createElement('div'); + notificationContainer.id = 'notification-container'; + notificationContainer.style.position = 'fixed'; + notificationContainer.style.top = '20px'; + notificationContainer.style.right = '20px'; + notificationContainer.style.zIndex = '9999'; + document.body.appendChild(notificationContainer); + } + + // Create notification element + const notification = document.createElement('div'); + notification.className = `alert alert-${type} alert-dismissible fade show`; + notification.innerHTML = ` + ${message} + + `; + + // Add to container + notificationContainer.appendChild(notification); + + // Auto remove after 5 seconds + setTimeout(() => { + notification.classList.remove('show'); + setTimeout(() => { + notification.remove(); + }, 300); + }, 5000); + } +} + +function createNewCandidate(form, modal) { + if (!form.checkValidity()) { + form.reportValidity(); + return; + } + + const formData = new FormData(); + + // Basic Information + formData.append('sequence', document.getElementById('candidate-sequence').value); + formData.append('partner_name', document.getElementById('partner-name').value); + + // Add image file if selected + const avatarUpload = document.getElementById('avatar-upload'); + if (avatarUpload.files.length > 0) { + formData.append('image_1920', avatarUpload.files[0]); + } + + // Contact Information + formData.append('email', document.getElementById('email').value); + formData.append('phone', document.getElementById('phone').value); + formData.append('mobile', document.getElementById('alternate-phone').value); + formData.append('linkedin_profile', document.getElementById('linkedin').value); + formData.append('type_id', document.getElementById('type').value); + formData.append('user_id', document.getElementById('manager').value); + formData.append('availability', document.getElementById('availability').value); + + // Skills + const skillOptions = document.getElementById('candidate-skills').selectedOptions; + for (let i = 0; i < skillOptions.length; i++) { + formData.append('skill_ids', skillOptions[i].value); + } + + // Add resume file if selected + const resumeUpload = document.getElementById('resume-upload'); + if (resumeUpload.files.length > 0) { + formData.append('resume_file', resumeUpload.files[0]); + } + + // Additional fields + formData.append('active', 'true'); + formData.append('color', '0'); + + fetch('/myATS/candidate/create', { + method: 'POST', + body: formData, + headers: { + 'X-Requested-With': 'XMLHttpRequest', + } + }) + .then(async (response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || response.statusText); + } + return response.json(); + }) + .then(data => { + if (data.success) { + modal.classList.remove('show'); + document.body.style.overflow = ''; + form.reset(); + alert('Candidate created successfully!'); + + // Refresh the candidates list + fetch('/myATS/page/candidates', { + headers: { "X-Requested-With": "XMLHttpRequest" } + }) + .then(res => res.text()) + .then(html => { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const newCandidate = doc.querySelector('.candidates-list-left'); + const existingCandidatesList = document.querySelector('.candidates-list-left'); + if (newCandidate && existingCandidatesList) { + existingCandidatesList.innerHTML = newCandidate.innerHTML; + initCandidatesPage(); + } + }); + } else { + throw new Error(data.error || 'Failed to save Candidate'); + } + }) + .catch(error => { + console.error('Error:', error); + alert("Error saving changes: " + error.message); + }); +} + +function initCandidateDetailEdit() { + + // Improved smooth scroll navigation + document.querySelectorAll('.nav-link').forEach(link => { + link.addEventListener('click', function(e) { + e.preventDefault(); + const targetId = this.getAttribute('href'); + const targetElement = document.querySelector(targetId); + if (targetElement) { + scrollToTarget(targetElement); + // Highlight effect + targetElement.style.boxShadow = '0 0 0 3px rgba(13, 110, 253, 0.5)'; + targetElement.style.transition = 'box-shadow 0.3s ease'; + setTimeout(() => { + targetElement.style.boxShadow = 'none'; + }, 2000); + } + }); + }); +} + +// Initialize the page when DOM is ready +document.addEventListener('DOMContentLoaded', initCandidatesPage); \ No newline at end of file diff --git a/addons_extensions/hr_recruitment_web_app/static/src/js/job_requests.js b/addons_extensions/hr_recruitment_web_app/static/src/js/job_requests.js new file mode 100644 index 000000000..357407470 --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/static/src/js/job_requests.js @@ -0,0 +1,2005 @@ +// Define all your page initialization functions first +function initJobListPage() { + console.log("Job List Page JS Loaded"); + const jobDetailArea = document.getElementById("job-detail"); + const headerBadges = document.querySelectorAll(".stat-toggle"); + const container = document.querySelector('.ats-list-container'); + const sidebar = document.getElementById("job-list-panel"); + const toggleBtn = document.getElementById("job-list-sidebar-toggle-btn"); + let originalContent; + + // Initially hide the job detail panel + jobDetailArea.style.display = 'none'; + + // Badge click handler (unchanged) + headerBadges.forEach(badge => { + badge.addEventListener("click", function() { + this.classList.toggle("active"); + this.classList.toggle("crossed"); + const type = this.dataset.type; + document.querySelectorAll(`.job-badge[data-type="${type}"]`).forEach(jobBadge => { + jobBadge.style.display = this.classList.contains("active") ? "inline-block" : "none"; + }); + const activeJob = document.querySelector(".job-item.selected"); + if (!this.classList.contains("active")) { + // Handle inactive state + } else if (activeJob) { + // Handle active state + } + }); + }); + + // Job item click logic + document.querySelectorAll(".job-item").forEach(item => { + item.addEventListener("click", function() { + document.querySelectorAll(".job-item.selected").forEach(el => el.classList.remove("selected")); + this.classList.add("selected"); + + // Show job detail panel and adjust layout + jobDetailArea.style.display = 'block'; + container.classList.add('ats-selected'); + sidebar.classList.remove('collapsed'); + toggleBtn.style.display = 'flex'; + + const jobId = this.dataset.id; + fetch(`/myATS/job/detail/${jobId}`, { + headers: { "X-Requested-With": "XMLHttpRequest" } + }) + .then(res => res.text()) + .then(html => { + if (jobDetailArea) { + jobDetailArea.innerHTML = html; + initSmartButtons(); + +// initJobDetailEdit(); // Initialize edit functionality + initEditCardButtons(); + // Add close button functionality + const closeBtn = jobDetailArea.querySelector('.close-detail'); + if (closeBtn) { + closeBtn.addEventListener('click', function() { + jobDetailArea.style.display = 'none'; + container.classList.remove('ats-selected'); + document.querySelectorAll(".job-item.selected").forEach(el => el.classList.remove("selected")); + }); + } + } + }); + }); + }); + + // Search functionality (unchanged) + const search = document.getElementById("ats-search"); + if (search) { + search.addEventListener("input", function () { + const query = this.value.toLowerCase(); + let visibleCount = 0; + document.querySelectorAll(".ats-item").forEach(item => { + const match = item.textContent.toLowerCase().includes(query); + item.style.display = match ? "" : "none"; + if (match) visibleCount++; + }); + const countElement = document.getElementById("active-records-count"); + if (countElement) { + countElement.textContent = visibleCount; + } + }); + } + + // Sidebar Toggle + if (toggleBtn && sidebar) { + toggleBtn.addEventListener("click", function () { + sidebar.classList.toggle("collapsed"); + // Change the button icon based on state + if (sidebar.classList.contains("collapsed")) { + this.textContent = "☰"; + this.style.right = "-12px"; + this.style.transform = "translateY(-50%)"; + } else { + this.textContent = "☰"; + this.style.right = "-12px"; + } + }); + } + + initJDModal(); +} + +let editor = null; + +// Initialize edit functionality +function initEditCardButtons() { + document.querySelectorAll('.edit-card-btn').forEach(btn => { + btn.addEventListener('click', function() { + const cardId = this.dataset.card; + const card = document.getElementById(`ats-${cardId}`); + if (!card) return; + + // Store original content + originalContent = card.innerHTML; + + // Get the appropriate edit form template + let formTemplateId = ''; + let formTitle = ''; + switch(cardId) { + case 'basic-info': + formTemplateId = 'basic_info_edit_form'; + formTitle = 'Basic Information'; + break; + case 'requirements': + formTemplateId = 'requirements_edit_form'; + formTitle = 'Requirements'; + break; + case 'request-info': + formTemplateId = 'request_info_edit_form'; + formTitle = 'Request Information'; + break; + case 'team': + formTemplateId = 'team_edit_form'; + formTitle = 'Recruitment Team'; + break; + case 'description': + formTemplateId = 'description_edit_form'; + formTitle = 'Job Description'; + break; + default: + return; + } + + // Get the template content + const template = document.getElementById(formTemplateId); + if (!template) return; + + // Clone the template content + const formContent = template.content.cloneNode(true); + + // Create the edit form container + const editFormContainer = document.createElement('div'); + editFormContainer.className = 'edit-form-container'; + + // Create form header + const formHeader = document.createElement('div'); + formHeader.className = 'form-header'; + formHeader.innerHTML = ` +

Edit ${formTitle}

+
+ + +
+ `; + + // Create form content container + const formContentContainer = document.createElement('div'); + formContentContainer.className = 'form-content'; + formContentContainer.appendChild(formContent); + + // Assemble the edit form + editFormContainer.appendChild(formHeader); + editFormContainer.appendChild(formContentContainer); + + // Replace card content with edit form + card.innerHTML = ''; + card.appendChild(editFormContainer); + + // Store reference to the button for later use + const editButton = this; + + // Initialize Select2 and CKEditor first + setTimeout(() => { + initSelect2ForEditForm(card); + + // Initialize CKEditor for description + if (cardId === 'description') { + initDescriptionEditor(card); + } + + // Now populate the form fields after initialization + populateFormFields(card, editButton); + }, 100); + + // Add event listeners for save and cancel buttons + card.querySelector('.save-edit-btn').addEventListener('click', function() { + saveCardEdit(cardId, card, originalContent); + }); + + card.querySelector('.cancel-edit-btn').addEventListener('click', function() { + debugger; + card.innerHTML = originalContent; + // Re-initialize edit button + initEditCardButtons(); + originalContent = null; + }); + }); + }); +} + +// New function to populate form fields with data from button data attributes +// New function to populate form fields with data from button data attributes +function populateFormFields(card, button) { + const cardId = button.dataset.card; + switch(cardId) { + case 'basic-info': + // Populate basic info fields + const employmentType = card.querySelector('#edit-employment-type'); + if (employmentType && button.dataset.employmentType) { + employmentType.value = button.dataset.employmentType; + $(employmentType).trigger('change'); // Trigger change for Select2 + } + const budget = card.querySelector('#edit-budget'); + if (budget && button.dataset.budget) { + budget.value = button.dataset.budget; + } + const targetFrom = card.querySelector('#edit-target-from'); + if (targetFrom && button.dataset.targetFrom) { + targetFrom.value = button.dataset.targetFrom; + } + const targetTo = card.querySelector('#edit-target-to'); + if (targetTo && button.dataset.targetTo) { + targetTo.value = button.dataset.targetTo; + } + const noOfPositions = card.querySelector('#edit-no-of-positions'); + if (noOfPositions && button.dataset.noOfPositions) { + noOfPositions.value = button.dataset.noOfPositions; + } + const eligibleSubmissions = card.querySelector('#edit-eligible-submissions'); + if (eligibleSubmissions && button.dataset.eligibleSubmissions) { + eligibleSubmissions.value = button.dataset.eligibleSubmissions; + } + break; + case 'requirements': + // Populate requirements fields + const experience = card.querySelector('#edit-experience'); + if (experience && button.dataset.experience) { + experience.value = button.dataset.experience; + $(experience).trigger('change'); // Trigger change for Select2 + } + const primarySkills = card.querySelector('#edit-primary-skills'); + if (primarySkills && button.dataset.primarySkills) { + const skills = button.dataset.primarySkills.split(','); + $(primarySkills).val(skills).trigger('change'); // Set values and trigger change for Select2 + } + const secondarySkills = card.querySelector('#edit-secondary-skills'); + if (secondarySkills && button.dataset.secondarySkills) { + const skills = button.dataset.secondarySkills.split(','); + $(secondarySkills).val(skills).trigger('change'); // Set values and trigger change for Select2 + } + const locations = card.querySelector('#edit-locations'); + if (locations && button.dataset.locations) { + const locationIds = button.dataset.locations.split(','); + $(locations).val(locationIds).trigger('change'); // Set values and trigger change for Select2 + } + break; + case 'request-info': + // Populate request info fields + const requestedBy = card.querySelector('#edit-requested-by'); + if (requestedBy && button.dataset.requestedBy) { + requestedBy.value = button.dataset.requestedBy; + $(requestedBy).trigger('change'); // Trigger change for Select2 + } + const department = card.querySelector('#edit-department'); + if (department && button.dataset.department) { + department.value = button.dataset.department; + $(department).trigger('change'); // Trigger change for Select2 + } + const company = card.querySelector('#edit-company'); + if (company && button.dataset.company) { + company.value = button.dataset.company; + $(company).trigger('change'); // Trigger change for Select2 + } + break; + case 'team': + // Populate team fields + const primaryRecruiter = card.querySelector('#edit-primary-recruiter'); + if (primaryRecruiter && button.dataset.primaryRecruiter) { + primaryRecruiter.value = button.dataset.primaryRecruiter; + $(primaryRecruiter).trigger('change'); // Trigger change for Select2 + } + const secondaryRecruiters = card.querySelector('#edit-secondary-recruiters'); + if (secondaryRecruiters && button.dataset.secondaryRecruiters) { + const recruiters = button.dataset.secondaryRecruiters.split(','); + $(secondaryRecruiters).val(recruiters).trigger('change'); // Set values and trigger change for Select2 + } + break; + case 'description': + // Populate description field + const descriptionEditor = card.querySelector('#edit-description-editor'); + const descriptionTextarea = card.querySelector('#edit-description'); + if (descriptionEditor && descriptionTextarea && button.dataset.description) { + descriptionTextarea.value = button.dataset.description; + // Set content after CKEditor is initialized + setTimeout(() => { + if (descriptionEditor.editorInstance) { + descriptionEditor.editorInstance.setData(button.dataset.description); + } else { + descriptionEditor.innerHTML = button.dataset.description; + } + }, 200); + } + break; + } +} + +// Initialize Select2 for edit form fields +function initSelect2ForEditForm(card) { + // Initialize all select elements with Select2 + card.querySelectorAll('select').forEach(select => { + if (!select.classList.contains('select2-hidden-accessible')) { + $(select).select2({ + width: '100%', + dropdownParent: $(card), + placeholder: select.getAttribute('data-placeholder') || 'Select an option' + }); + } + }); +} + +// Initialize CKEditor for description field +function initDescriptionEditor(card) { + const editorElement = card.querySelector('#edit-description-editor'); + if (!editorElement) return; + + ClassicEditor + .create(editorElement, { + toolbar: { + items: [ + 'heading', '|', + 'bold', 'italic', 'link', 'bulletedList', 'numberedList', '|', + 'blockQuote', '|', + 'undo', 'redo' + ] + } + }) + .then(editor => { + // Store editor instance + editorElement.editorInstance = editor; + + // Update hidden textarea when editor content changes + editor.model.document.on('change:data', () => { + const textarea = card.querySelector('#edit-description'); + if (textarea) { + textarea.value = editor.getData(); + } + }); + }) + .catch(error => { + console.error('Error initializing CKEditor:', error); + }); +} + +// Save card edit +function saveCardEdit(cardId, card, originalContent) { + const jobId = document.querySelector('.job-detail-container').dataset.jobId; + if (!jobId) return; + + // Collect form data based on card type + let formData = { + id: jobId, + card_type: cardId + }; + + switch(cardId) { + case 'basic-info': + formData.employment_type = card.querySelector('#edit-employment-type').value; + formData.budget = card.querySelector('#edit-budget').value; + formData.target_from = card.querySelector('#edit-target-from').value; + formData.target_to = card.querySelector('#edit-target-to').value; + formData.no_of_positions = card.querySelector('#edit-no-of-positions').value; + formData.eligible_submissions = card.querySelector('#edit-eligible-submissions').value; + break; + + case 'requirements': + formData.experience = card.querySelector('#edit-experience').value; + formData.primary_skills = Array.from(card.querySelector('#edit-primary-skills').selectedOptions).map(option => option.value); + formData.secondary_skills = Array.from(card.querySelector('#edit-secondary-skills').selectedOptions).map(option => option.value); + formData.locations = Array.from(card.querySelector('#edit-locations').selectedOptions).map(option => option.value); + break; + + case 'request-info': + formData.requested_by = card.querySelector('#edit-requested-by').value; + formData.department = card.querySelector('#edit-department').value; + formData.company = card.querySelector('#edit-company').value; + break; + + case 'team': + formData.primary_recruiter = card.querySelector('#edit-primary-recruiter').value; + formData.secondary_recruiters = Array.from(card.querySelector('#edit-secondary-recruiters').selectedOptions).map(option => option.value); + break; + + case 'description': + const editorElement = card.querySelector('#edit-description-editor'); + if (editorElement.editorInstance) { + formData.description = editorElement.editorInstance.getData(); + } else { + formData.description = card.querySelector('#edit-description').value; + } + break; + } + + // Send data to server + fetch('/myATS/job/update_card', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: JSON.stringify(formData) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + // Refresh the job detail view + fetch(`/myATS/job/detail/${jobId}`, { + headers: { "X-Requested-With": "XMLHttpRequest" } + }) + .then(res => res.text()) + .then(html => { + const jobDetailArea = document.getElementById('job-detail'); + if (jobDetailArea) { + jobDetailArea.innerHTML = html; + initSmartButtons(); + + // Re-initialize all functionality +// initJobDetailEdit(); + initEditCardButtons(); + initSmartButtons(); + } + }); + } else { + alert('Error updating card: ' + (data.error || 'Unknown error')); + // Restore original content + card.innerHTML = originalContent; + initEditCardButtons(); + } + }) + .catch(error => { + console.error('Error:', error); + alert('Error updating card'); + // Restore original content + card.innerHTML = originalContent; + initEditCardButtons(); + }); +} + +function initJobDetailEdit() { + const editBtn = document.getElementById('edit-job-btn'); + const cancelBtn = document.getElementById('cancel-edit'); + const editForm = document.getElementById('job-edit-form'); + const viewSection = document.getElementById('job-detail-view'); + const editSection = document.getElementById('job-detail-edit'); + + initEditCardButtons(); + + if (editBtn) { + editBtn.addEventListener('click', function() { + viewSection.style.display = 'none'; + editSection.style.display = 'block'; + + // Initialize CKEditor + if (typeof CKEDITOR !== 'undefined') { + if (editor) { + editor.destroy(); + } + editor = CKEDITOR.replace('job-description-edit', { + toolbar: [ + { name: 'basicstyles', items: ['Bold', 'Italic', 'Underline', 'Strike', '-', 'RemoveFormat'] }, + { name: 'paragraph', items: ['NumberedList', 'BulletedList', '-', 'Outdent', 'Indent'] }, + { name: 'links', items: ['Link', 'Unlink'] }, + { name: 'document', items: ['Source'] } + ], + height: 300 + }); + } + }); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', function () { + const jobId = document.getElementById('job-edit-form').dataset.id; + + fetch(`/myATS/job/detail/${jobId}`, { + headers: { "X-Requested-With": "XMLHttpRequest" } + }) + .then(res => res.text()) + .then(html => { + const jobDetailArea = document.getElementById('job-detail'); + if (jobDetailArea) { + jobDetailArea.innerHTML = html; +// initJobDetailEdit(); // re-init the edit handlers + } + }); + + if (editor) { + editor.destroy(); + editor = null; + } + }); + } + + + if (editForm) { + editForm.addEventListener('submit', function(e) { + e.preventDefault(); + saveJobDetails(this); + }); + } + + // Improved smooth scroll navigation + document.querySelectorAll('.nav-link').forEach(link => { + link.addEventListener('click', function(e) { + e.preventDefault(); + const targetId = this.getAttribute('href'); + const targetElement = document.querySelector(targetId); + if (targetElement) { + scrollToTarget(targetElement); + // Highlight effect + targetElement.style.boxShadow = '0 0 0 3px rgba(13, 110, 253, 0.5)'; + targetElement.style.transition = 'box-shadow 0.3s ease'; + setTimeout(() => { + targetElement.style.boxShadow = 'none'; + }, 2000); + } + }); + }); + + initSmartButtons(); +} + +function saveJobDetails(form) { + // Get CKEditor data properly + let description = ''; + if (editor && CKEDITOR.instances['job-description-edit']) { + description = CKEDITOR.instances['job-description-edit'].getData(); + } else { + description = document.getElementById('job-description-edit')?.innerHTML || ''; + } + + const jobId = form.dataset.id; + const formData = { + 'id': jobId, + 'job_id': form.querySelector('#job-id-edit').value, + 'job_sequence': form.querySelector('#job-sequence-edit').value, + 'category': form.querySelector('#job-category-edit').value, + 'description': description, + }; + + fetch('/myATS/job/save', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: JSON.stringify(formData) + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) + .then(data => { + if (data.success) { + // Update view with new data + document.getElementById('job-name-view').textContent = formData.name; + document.getElementById('job-description-view').innerHTML = formData.description; + + const selectedOption = document.querySelector('#job-category-edit option:checked'); + if (selectedOption) { + document.getElementById('job-category-view').textContent = selectedOption.textContent; + } + + document.getElementById('job-detail-view').style.display = 'block'; + document.getElementById('job-detail-edit').style.display = 'none'; + + if (editor) { + editor.destroy(); + editor = null; + } + + alert("Changes saved successfully"); + fetch('/myATS/page/job_requests', { + headers: { "X-Requested-With": "XMLHttpRequest" } + }) + .then(res => res.text()) + .then(html => { + // Replace only the left panel to avoid wiping out the whole page + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const newJobList = doc.querySelector('.job-list-left'); + const existingJobList = document.querySelector('.job-list-left'); + + if (newJobList && existingJobList) { + existingJobList.innerHTML = newJobList.innerHTML; + initJobListPage(); // re-bind event listeners + } + }); + } else { + throw new Error(data.error || 'Unknown error occurred'); + } + }) + .catch(error => { + console.error('Error:', error); + alert("Error saving changes: " + error.message); + }); +} + +let wysiwyg = null; + + +function createJobDetails(form, modal) { + + if (!form.checkValidity()) { + form.reportValidity(); + return; + } + + // Get editor content + let description = ''; + if (wysiwyg) { + description = wysiwyg.getData(); + } else { + description = null +// description = document.getElementById('job-description').value || ''; + } + + // Collect form data + const formData = { + // Basic Information + sequence: document.getElementById('job-sequence').value, + position_id: document.getElementById('job-position').value, + category: document.getElementById('job-category').value, + priority: document.getElementById('job-priority').value, + work_type: document.querySelector('input[name="work-type"]:checked').value, + client_company_id: document.getElementById('client-company').value, + client_id: document.getElementById('client-id').value, + + // Employment Details + employment_type_id: document.getElementById('employment-type').value, + budget: document.getElementById('job-budget').value, + no_of_positions: document.getElementById('job-no-of-positions').value, + eligible_submissions: document.getElementById('job-eligible-submissions').value, + target_from: document.getElementById('target-from').value, + target_to: document.getElementById('target-to').value, + + // Skills & Requirements + primary_skill_ids: Array.from(document.getElementById('job-primary-skills').selectedOptions) + .map(option => option.value), + secondary_skill_ids: Array.from(document.getElementById('job-secondary-skills').selectedOptions) + .map(option => option.value), + experience_id: document.getElementById('job-experience').value, + + // Recruitment Team + primary_recruiter_id: document.getElementById('primary-recruiter').value, + secondary_recruiter_ids: Array.from(document.getElementById('secondary-recruiter').selectedOptions) + .map(option => option.value), + + // Additional Information + location_ids: Array.from(document.getElementById('job-locations').selectedOptions) + .map(option => option.value), + stage_ids: Array.from(document.getElementById('recruitment-stages').selectedOptions) + .map(option => option.value), + + // Description + description: description, + + // Attachments (if any) + attachment_ids: [] + }; + + fetch('/myATS/job/create', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json' + }, + body: JSON.stringify(formData) + }).then(async (response) => { + const contentType = response.headers.get("content-type"); + const responseData = contentType && contentType.includes("application/json") + ? await response.json() + : await response.text(); + + if (!response.ok) { + // For 500 errors, check if we have a JSON response with error details + if (response.status === 500 && typeof responseData === 'object') { + throw new Error(responseData.error || responseData.message || "Internal server error"); + } else if (typeof responseData === 'string') { + throw new Error(responseData); + } else { + throw new Error(response.statusText); + } + } + + return responseData; + }) + .then(data => { + if (data.success) { + modal.classList.remove('show'); + document.body.style.overflow = ''; + form.reset(); + + if (wysiwyg) { + wysiwyg.destroy() + .then(() => { + wysiwyg = null; + }) + .catch(error => { + console.error('Error destroying editor:', error); + }); + } + + alert('Job created successfully!'); + fetch('/myATS/page/job_requests', { + headers: { "X-Requested-With": "XMLHttpRequest" } + }) + .then(res => res.text()) + .then(html => { + // Replace only the left panel to avoid wiping out the whole page + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const newJobList = doc.querySelector('.job-list-left'); + const existingJobList = document.querySelector('.job-list-left'); + + if (newJobList && existingJobList) { + existingJobList.innerHTML = newJobList.innerHTML; + initJobListPage(); // re-bind event listeners + } + }); + } else { + throw new Error(data.error || 'Failed to save job'); + } + }) + .catch(error => { + console.error('Error:', error); + alert("Error saving changes: " + error.message); + }); +} + + +// Add this function to your existing JS +async function initJDModal() { + console.log('Initializing JD modal'); + const modal = document.getElementById('add-jd-modal'); + const addJdBtn = document.getElementById('add-jd-create-btn'); + const closeBtns = document.querySelectorAll('.jd-modal-close, .btn-cancel'); + const saveBtn = document.getElementById('save-jd'); + const form = document.getElementById('jd-form'); + const uploadBtn = document.getElementById('upload-jd') + + if (!modal) return; + + if (addJdBtn) { + addJdBtn.addEventListener('click', async function(e) { + e.preventDefault(); + modal.classList.add('show'); + document.body.style.overflow = 'hidden'; + + // Initialize WYSIWYG editor + const editorElement = document.getElementById('job-description-editor'); + +// editorElement.innerHTML = document.getElementById('job-description').value || ''; + + // Replace your current CKEditor initialization with this: + // Replace your CKEditor initialization with this: + if (editorElement && !wysiwyg) { + try { + await loadAssets([ + '/hr_recruitment_web_app/static/src/libs/ckeditor/build/ckeditor.js' + ]); + + ClassicEditor + .create(editorElement, { + toolbar: { + items: [ + 'heading', '|', + 'bold', 'italic', 'link', 'bulletedList', 'numberedList', '|', + 'blockQuote', 'uploadImage', '|', + 'undo', 'redo' + ] + }, + image: { + toolbar: [ + 'imageTextAlternative', + 'toggleImageCaption', + 'imageStyle:inline', + 'imageStyle:block', + 'imageStyle:side' + ], + upload: { + types: ['jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff'] + } + }, + simpleUpload: { + uploadUrl: '/web_editor/attachment/add', + withCredentials: true, + headers: { + 'X-CSRF-TOKEN': getCookie('csrftoken') || '', + 'X-Requested-With': 'XMLHttpRequest' + }, + additionalRequestData: { + 'res_model': 'hr.job', + 'res_id': 0, + 'callback': 'window.top' + } + }, + typing: { + undo: true + }, + keystrokes: [ + [ 32, 'insertText', ' ' ] // Handle spacebar + ] + }) + .then(editor => { + wysiwyg = editor; + const textarea = document.getElementById('job-description'); + + editor.model.document.on('change:data', () => { + document.getElementById('job-description').value = editor.getData(); + }); + + if (textarea) { + textarea.addEventListener('input', () => { + if (wysiwyg) { + wysiwyg.setData(textarea.value); + } + }); + } + }) + .catch(error => { + console.error('Error initializing CKEditor:', error); + }); + } catch (error) { + console.error('Error loading CKEditor:', error); + } + } + + setTimeout(initSelect2, 100); + }); + } + + // Close modal handlers + closeBtns.forEach(btn => { + btn.addEventListener('click', function(e) { + e.preventDefault(); + modal.classList.remove('show'); + document.body.style.overflow = ''; + form.reset(); + + if (wysiwyg) { + wysiwyg.destroy() + .then(() => { + wysiwyg = null; + }) + .catch(error => { + console.error('Error destroying editor:', error); + }); + } + }); + }); + + modal.addEventListener('click', function(e) { + if (e.target === modal) { + modal.classList.remove('show'); + document.body.style.overflow = ''; + form.reset(); + if (wysiwyg) { + wysiwyg.destroy() + .then(() => { + wysiwyg = null; + }) + .catch(error => { + console.error('Error destroying editor:', error); + }); + } + } + }); + + // Save JD + if (saveBtn) { + saveBtn.addEventListener('click', function (e) { + e.preventDefault(); + createJobDetails(form, modal); + }); + } + + if (uploadBtn) { + uploadBtn.addEventListener('click', function (e) { + e.preventDefault(); + // Create file input element + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = '.pdf,.doc,.docx,.txt'; + + fileInput.onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + + // Show loading state + const button = document.getElementById('upload-jd'); + const originalText = button.innerHTML; + button.innerHTML = ' Processing JD...'; + button.disabled = true; + + try { + const formData = new FormData(); + formData.append('file', file); + + // Call your Odoo endpoint + const response = await fetch('/jd/upload', { + method: 'POST', + body: formData, + credentials: 'same-origin' + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + // Map all fields from the parsed result to the form + populateJDForm(result); + + // Show notification + showNotification('JD uploaded and fields populated successfully!', 'success'); + + } catch (error) { + console.error('Error parsing JD:', error); + showNotification('Failed to parse JD. Please try again or enter manually.', 'danger'); + } finally { + button.innerHTML = originalText; + button.disabled = false; + } + }; + + fileInput.click(); + }); + } + + function populateJDForm(jdData) { + // Section 1: Basic Information + if (jdData.sequence) { + document.getElementById('job-sequence').value = jdData.sequence; + } + + // Job Position - try to match with existing options + if (jdData.job_title) { + const jobSelect = document.getElementById('job-position'); + const options = jobSelect.options; + let found = false; + + // First try exact match + for (let i = 0; i < options.length; i++) { + if (options[i].text.toLowerCase() === jdData.job_title.toLowerCase()) { + jobSelect.value = options[i].value; + $(jobSelect).trigger('change'); + found = true; + break; + } + } + + // If not found, try partial match + if (!found) { + for (let i = 0; i < options.length; i++) { + if (options[i].text.toLowerCase().includes(jdData.job_title.toLowerCase()) || + jdData.job_title.toLowerCase().includes(options[i].text.toLowerCase())) { + jobSelect.value = options[i].value; + $(jobSelect).trigger('change'); + break; + } + } + } + } + + // Set work type based on employment type + if (jdData.employment_type) { + const employmentType = jdData.employment_type.toLowerCase(); + if (employmentType.includes('client') || employmentType.includes('external')) { + document.getElementById('work-type-external').checked = true; + } else { + document.getElementById('work-type-internal').checked = true; + } + } + + // Section 2: Employment Details + if (jdData.target_start_date) { + document.getElementById('target-from').value = jdData.target_start_date; + } + if (jdData.target_end_date) { + document.getElementById('target-to').value = jdData.target_end_date; + } + + // Set employment type dropdown + if (jdData.employment_type) { + const empTypeSelect = document.getElementById('employment-type'); + const options = empTypeSelect.options; + for (let i = 0; i < options.length; i++) { + if (options[i].text.toLowerCase().includes(jdData.employment_type.toLowerCase())) { + empTypeSelect.value = options[i].value; + break; + } + } + } + + // Set experience dropdown + if (jdData.experience) { + document.getElementById('job-experience').value = jdData.experience.id; + $(document.getElementById('job-experience')).trigger('change'); + } + + // Section 3: Skills & Requirements + if (jdData.primary_skills && jdData.primary_skills.length) { + const skillValues = jdData.primary_skills.map(skill => skill.id); + $('#job-primary-skills').val(skillValues).trigger('change'); + } + + if (jdData.secondary_skills && jdData.secondary_skills.length) { + const skillValues = jdData.secondary_skills.map(skill => skill.id); + $('#job-secondary-skills').val(skillValues).trigger('change'); + } + + // Section 5: Additional Information + if (jdData.locations && jdData.locations.length) { + const locationValues = jdData.locations.map(loc => loc.id); + $('#job-locations').val(locationValues).trigger('change'); + } + + if (jdData.description) { + setTimeout(() => { + const editor = document.getElementById('job-description-editor'); + const textarea = document.getElementById('job-description'); + + if (!editor || !textarea) { + console.error('Editor or textarea element not found!'); + return; + } + + const lines = jdData.description.split('\n').map(l => l.trim()).filter(l => l); + let formatted = ''; + let inBulletList = false; + + lines.forEach(line => { + const numberedMatch = line.match(/^(\d+\.\s+)(.*)/); + const bulletMatch = line.match(/^([•\-○]\s*)(.*)/); + const titleMatch = line.match(/^(.+?):\s*(.*)/); + + if (numberedMatch) { + // Close bullet list if previously opened + if (inBulletList) { + formatted += ''; + inBulletList = false; + } + formatted += `

${numberedMatch[1]}${numberedMatch[2]}

`; + } else if (bulletMatch) { + if (!inBulletList) { + formatted += ''; + inBulletList = false; + } + formatted += `

${titleMatch[1]}: ${titleMatch[2]}

`; + } else { + // Treat as bullet if short or point-like + if (line.length < 150 && /^[A-Za-z0-9].*/.test(line)) { + if (!inBulletList) { + formatted += ''; + inBulletList = false; + } + formatted += `

${line}

`; + } + } + }); + + // Close any open '; + } + + editor.innerHTML = formatted; + textarea.value = formatted; + textarea.dispatchEvent(new Event('input', { bubbles: true })); + + console.log('Formatted:', formatted); + }, 300); + } + + } + + function showNotification(message, type) { + // Using Odoo's notification system + if (typeof Notification !== 'undefined') { + Notification.create({ + title: type === 'success' ? 'Success' : 'Error', + message: message, + type: type, + sticky: false + }); + } else { + // Fallback to alert + alert(`${type.toUpperCase()}: ${message}`); + } + } + + function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; + } + + // Helper function to load WYSIWYG assets + function loadAssets(assets) { + return Promise.all(assets.map(asset => { + return new Promise((resolve, reject) => { + const isCss = asset.endsWith('.css'); + const element = isCss + ? document.createElement('link') + : document.createElement('script'); + + if (isCss) { + element.rel = 'stylesheet'; + element.href = asset; + element.type = 'text/css'; + } else { + element.src = asset; + element.type = 'text/javascript'; + } + + element.onload = resolve; + element.onerror = () => reject(new Error(`Failed to load ${asset}`)); + document.head.appendChild(element); + }); + })); + } + + + // Populate categories (example) +// const categorySelect = document.getElementById('job-category'); +// if (categorySelect) { +// // You might want to fetch these from your backend +// const categories = ['IT', 'Marketing', 'Finance', 'HR', 'Operations']; +// categories.forEach(cat => { +// const option = document.createElement('option'); +// option.value = cat.toLowerCase(); +// option.textContent = cat; +// categorySelect.appendChild(option); +// }); +// } + + const workTypeRadios = document.querySelectorAll("input[name='work-type']"); + const companySelect = document.getElementById("client-company"); + const clientSelect = document.getElementById("client-id"); + + let clientToCompanyMap = {}; // New map + + function initSelect2() { + // Check if Select2 is already initialized + const primarySkillsSelect = document.getElementById('job-primary-skills'); + if (primarySkillsSelect) { + $(primarySkillsSelect).select2({ + placeholder: 'Select skills', + allowClear: true, + dropdownParent: $('.new-jd-container'), + width: '100%', + escapeMarkup: function(m) { return m; } + }); + } + const jobPosition = document.getElementById('job-position'); + if (jobPosition) { + $(jobPosition).select2({ + placeholder: 'Select Job Position', + dropdownParent: $('.new-jd-container'), + width: '100%', + escapeMarkup: function(m) { return m; } + }); + } + + const clientCompany = document.getElementById('client-company'); + if (clientCompany) { + $(clientCompany).select2({ + placeholder: 'Select Client Company', + dropdownParent: $('.new-jd-container'), + width: '100%', + escapeMarkup: function(m) { return m; } + }); + } + + const clientId = document.getElementById('client-id'); + if (clientId) { + $(clientId).select2({ + placeholder: 'Select client', + dropdownParent: $('.new-jd-container'), + width: '100%', + escapeMarkup: function(m) { return m; } + }); + } + + const secondarySkillSelect = document.getElementById('job-secondary-skills'); + if (secondarySkillSelect) { + $(secondarySkillSelect).select2({ + placeholder: 'Select skills', + allowClear: true, + dropdownParent: $('.new-jd-container'), + width: '100%', + escapeMarkup: function(m) { return m; } + }); + } + + const primaryRecruiterSelect = document.getElementById('primary-recruiter'); + if (primaryRecruiterSelect) { + $(primaryRecruiterSelect).select2({ + placeholder: 'Select Primary Recruiters', + allowClear: true, + templateResult: formatUserOption, + templateSelection: formatUserSelection, + escapeMarkup: function(m) { return m; } + }); + } + + const secondaryRecruiterSelect = document.getElementById('secondary-recruiter'); + if (secondaryRecruiterSelect) { + $(secondaryRecruiterSelect).select2({ + placeholder: 'Select Secondary Recruiters', + allowClear: true, + templateResult: formatUserOption, + templateSelection: formatUserSelection, + dropdownParent: $('.new-jd-container'), + width: '100%', + escapeMarkup: function(m) { return m; } + }); + } + + const jobLocationsSelect = document.getElementById('job-locations'); + if (jobLocationsSelect) { + $(jobLocationsSelect).select2({ + placeholder: 'Select Job Locations', + allowClear: true, + dropdownParent: $('.new-jd-container'), + width: '100%', + escapeMarkup: function(m) { return m; } + }); + } + + const RecruitmentStagesSelect = document.getElementById('recruitment-stages'); + if (RecruitmentStagesSelect) { + $(RecruitmentStagesSelect).select2({ + placeholder: 'Select Recruitment Stages', + allowClear: true, + dropdownParent: $('.new-jd-container'), + width: '100%', + escapeMarkup: function(m) { return m; } + }); + } + + initSkillsDragAndDrop(); + } + + function setPrimarySkills(skills, remove = false) { + const $select = $('#job-primary-skills'); + if ($select.hasClass("select2-hidden-accessible")) { + let current = $select.val() || []; + if (remove) { + // Remove the specified skills + current = current.filter(id => !skills.includes(id)); + } else { + // Add the specified skills (avoid duplicates) + current = Array.from(new Set(current.concat(skills))); + } + $select.val(current).trigger('change'); + } else { + setTimeout(() => setPrimarySkills(skills, remove), 100); + } + } + + function setSecondarySkills(skills, remove = false) { + const $select = $('#job-secondary-skills'); + if ($select.hasClass("select2-hidden-accessible")) { + let current = $select.val() || []; + if (remove) { + // Remove the specified skills + current = current.filter(id => !skills.includes(id)); + } else { + // Add the specified skills (avoid duplicates) + current = Array.from(new Set(current.concat(skills))); + } + $select.val(current).trigger('change'); + } else { + setTimeout(() => setSecondarySkills(skills, remove), 100); + } + } + + function isSkillInOptions(skillId, selectId) { + return $(`#${selectId} option[value="${skillId}"]`).length > 0; + } + function isSkillSelected(skillId, selectId) { + const selector = `#${selectId}`; + const selectedValues = $(selector).val() || []; + return selectedValues.includes(skillId.toString()); + } + // Add this new function + function initSkillsDragAndDrop() { + const skillRequirementsDiv = document.getElementById('skill_requirements'); + const primarySkills = document.getElementById('job-primary-skills'); + const secondarySkills = document.getElementById('job-secondary-skills'); + + if (!skillRequirementsDiv || !primarySkills || !secondarySkills) return; + + // Make options draggable + [primarySkills, secondarySkills].forEach(select => { + // Set draggable attribute on all options + Array.from(select.options).forEach(option => { + option.setAttribute('draggable', 'true'); + }); + + select.addEventListener('dragstart', function(e) { + if (e.target.tagName === 'OPTION') { + console.log('Drag started for option:', e.target.value); + e.dataTransfer.setData('text/plain', e.target.value); + e.dataTransfer.effectAllowed = 'move'; + e.target.classList.add('dragging'); + } + }); + + select.addEventListener('dragend', function(e) { + if (e.target.tagName === 'OPTION') { + console.log('Drag ended for option:', e.target.value); + e.target.classList.remove('dragging'); + } + }); + }); + + // Set up drop zones for both selects + [primarySkills, secondarySkills].forEach(select => { + const select2Container = $(`#${select.id}`).next('.select2-container'); + const select2Selection = select2Container.find('.select2-selection--multiple'); + console.log(select2Container); + console.log(select2Selection); + // Handle dragover for the Select2 container + select2Selection.on('dragover', function(e) { + e.preventDefault(); + e.originalEvent.dataTransfer.dropEffect = 'move'; + $(this).addClass('drag-over'); + console.log('Dragging over select:', select.id); + return false; + }); + + // Handle dragleave for the Select2 container + select2Selection.on('dragleave', function(e) { + console.log("drag leave feature"); + $(this).removeClass('drag-over'); + return false; + }); + + // Handle drop for the Select2 container + select2Selection.on('drop', function(e) { + console.log("dropping the data"); + e.preventDefault(); + e.stopPropagation(); + $(this).removeClass('drag-over'); + + const skillId = e.originalEvent.dataTransfer.getData('text/plain'); + const option = document.querySelector(`option[value="${skillId}"]`); + console.log('Moving option from', option.parentElement.id, 'to', select.id); + + console.log('Dropped skill ID:', skillId); + + if (!isSkillInOptions(skillId, select.id)) { + console.log('Skill does not exists in the selected dropdown'); + return; + } + + + // If the option is already in this select, do nothing + if (isSkillSelected(skillId, select.id)) { + console.log('Skill is already selected.'); + return; + } + + + // Remove from current select + const oldSelect = option.parentElement; + if (select.id == 'job-primary-skills'){ + setSecondarySkills(skillId, remove=true) + setPrimarySkills(skillId) + } else { + setPrimarySkills(skillId, remove=true) + setSecondarySkills(skillId) + } + + + + // Trigger Select2 updates + $(oldSelect).trigger('change'); + $(select).trigger('change'); + + console.log('Option moved successfully'); + return false; + }); + }); + + // Prevent default behavior for the parent container + skillRequirementsDiv.addEventListener('dragover', function(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'none'; + return false; + }); + + skillRequirementsDiv.addEventListener('drop', function(e) { + console.log("drop feature"); + e.preventDefault(); + return false; + }); + + // Make Select2 choices draggable + $(document).on('mouseenter', '.select2-selection__choice', function() { + this.setAttribute('draggable', 'true'); + this.addEventListener('dragstart', function(e) { + console.log("dragstarted"); + const value = $(this).attr('title') || $(this).text().trim(); + console.log('Dragging choice:', value); + const option = $(primarySkills).find(`option:contains('${value}')`)[0] || + $(secondarySkills).find(`option:contains('${value}')`)[0]; + if (option) { + console.log('Found matching option:', option.value); + e.dataTransfer.setData('text/plain', option.value); + e.dataTransfer.effectAllowed = 'move'; + } else { + console.log('No matching option found for:', value); + } + }); + }); + } + + function formatUserOption(user) { + if (!user.id) return user.text; + var imageUrl = $(user.element).data('image') || '/web/static/img/placeholder.png'; + var $container = $( + '' + + '' + + '' + user.text + '' + + '' + ); + return $container; + } + + function formatUserSelection(user) { + if (!user.id) return user.text; + var imageUrl = $(user.element).data('image') || '/web/static/img/placeholder.png'; + var $container = $( + '' + + '' + + '' + user.text + '' + + '' + ); + return $container; + } + + function fetchCompanies(workType) { + fetch('/get_client_companies', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: workType }) + }) + .then(res => res.json()) + .then(data => { + const companies = data.result; + if (!Array.isArray(companies)) { + console.error("Expected an array, got:", companies); + return; + } + + companySelect.innerHTML = ''; + companies.forEach(company => { + companySelect.innerHTML += ``; + }); + + clientSelect.innerHTML = ''; + clientToCompanyMap = {}; // Reset map + }) + .catch(err => { + console.error("Error fetching companies:", err); + }); + } + + function fetchClients(companyId, workType) { + fetch('/get_clients_by_company', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ company_id: companyId, type: workType }) + }) + .then(res => res.json()) + .then(data => { + const clients = data.result; + if (!Array.isArray(clients)) { + console.error("Expected an array, got:", clients); + return; + } + + clientSelect.innerHTML = ''; + clientToCompanyMap = {}; // Reset map + + clients.forEach(client => { + clientSelect.innerHTML += ``; + clientToCompanyMap[client.id] = client.company_id; // Assuming `company_id` comes from backend + }); + }); + } + + + // Initial fetch on page load + const initialType = document.querySelector("input[name='work-type']:checked").value; + fetchCompanies(initialType); + fetchClients(null, initialType); + + + // Change on radio click + workTypeRadios.forEach(radio => { + console.log("radio clicked"); + radio.addEventListener("change", () => { + fetchCompanies(radio.value); + fetchClients(companyId = null, workType = radio.value); + + }); + }); + + companySelect.addEventListener("change", () => { + let selectedCompanyId = null; + if (companySelect.value){ + selectedCompanyId = companySelect.value; + } + clientSelect.innerHTML = ''; + const selectedType = document.querySelector("input[name='work-type']:checked").value; + fetchClients(selectedCompanyId, selectedType); + }); + + // On client selection + clientSelect.addEventListener("change", () => { + const selectedClientId = clientSelect.value; + + if (selectedClientId && clientToCompanyMap[selectedClientId]) { + const companyId = clientToCompanyMap[selectedClientId]; + companySelect.value = companyId; + } + }); +} + +// Track open popups +const openPopups = []; + +function initSmartButtons() { + // Matching Candidates button + document.getElementById('matching-jd-candidates')?.addEventListener('click', function() { + togglePopup('matchingCandidates', this.dataset.popupTitle || 'Matching Candidates'); + }); + + // Applicants button + document.getElementById('jd-applicants')?.addEventListener('click', function() { + togglePopup('applicants', this.dataset.popupTitle || 'Applicants'); + }); + + // Close buttons (using event delegation) + document.addEventListener('click', function(e) { + if (e.target.classList.contains('popup-close')) { + const popup = e.target.closest('.popup-container'); + closePopup(popup.id.replace('Popup', '')); + } + }); +} + +function togglePopup(type, title) { + const popupId = `${type}Popup`; + let popup = document.getElementById(popupId); + + if (!popup) { + // Create popup if it doesn't exist + popup = document.createElement('div'); + popup.id = popupId; + popup.className = 'popup-container bottom-right'; + popup.innerHTML = document.getElementById(`${type}_popup`).innerHTML; + document.body.appendChild(popup); + } + + if (popup.classList.contains('visible')) { + closePopup(type); + } else { + openPopup(type, title); + } +} + +function openPopup(type, title) { + const popupId = `${type}Popup`; + const popup = document.getElementById(popupId); + + if (!popup) return; + + // Update title + const titleEl = popup.querySelector('h5'); + if (titleEl) titleEl.textContent = title; + + // Show loading state + const loadingEl = popup.querySelector('.loading-spinner'); + const contentEl = popup.querySelector('.popup-content'); + + if (loadingEl) { + loadingEl.style.display = 'flex'; + loadingEl.innerHTML = '
'; + } + if (contentEl) { + contentEl.style.display = 'none'; + contentEl.innerHTML = ''; + } + + // Show popup + popup.classList.add('visible'); + + // Add to open popups array if not already there + if (!openPopups.includes(popupId)) { + openPopups.push(popupId); + } + + // Position popups + positionPopups(); + // Fetch data + const jobId = document.querySelector('.job-detail-container')?.dataset?.jobId; + if (!jobId) { + console.error('No job ID found'); + return; + } + + const endpoint = type === 'matchingCandidates' + ? `/myATS/job/matching_candidates/${jobId}` + : `/myATS/job/applicants/${jobId}`; + + fetch(endpoint, { + headers: { "X-Requested-With": "XMLHttpRequest" } + }) + .then(response => { + if (!response.ok) throw new Error('Network response was not ok'); + return response.text(); + }) + .then(html => { + if (loadingEl) loadingEl.style.display = 'none'; + if (contentEl) { + contentEl.innerHTML = html; + contentEl.style.display = 'block'; + initMatchingCandidates(); + } + }) + .catch(error => { + console.error('Error loading popup content:', error); + if (loadingEl) { + loadingEl.innerHTML = ` +
+ Failed to load data. Please try again. +
+ `; + } + }); +} + +function closePopup(type) { + const popupId = `${type}Popup`; + const popup = document.getElementById(popupId); + + if (popup) { + popup.classList.remove('visible'); + + // Remove from open popups array + const index = openPopups.indexOf(popupId); + if (index > -1) { + openPopups.splice(index, 1); + } + + // Reposition remaining popups + positionPopups(); + } +} + +function positionPopups() { + // Reset all popup positions first + document.querySelectorAll('.popup-container').forEach(popup => { + popup.style.right = ''; + popup.style.bottom = ''; + }); + + // Position each visible popup + openPopups.forEach((popupId, index) => { + const popup = document.getElementById(popupId); + if (popup) { + popup.style.right = `${20 + (index * 420)}px`; // 400px width + 20px gap + popup.style.bottom = '0'; + } + }); +} + +function initMatchingCandidates() { + // Handle candidate card clicks + document.addEventListener('click', function(e) { + const card = e.target.closest('.mc-card'); + if (card) { + debugger; + const candidateId = parseInt(card.dataset.id); + showCandidateDetails(card); + } + + // Close modal + if (e.target.classList.contains('mc-close-modal')) { + closeCandidateModal(); + } + }); + + // Close modal when clicking outside + window.addEventListener('click', function(e) { + const modal = document.getElementById('candidateDetailModal'); + if (e.target === modal) { + closeCandidateModal(); + } + }); +} + +function showCandidateDetails(card) { + const modalContent = document.getElementById('candidateDetailContent'); + + // Parse the applications data + let applications = []; + try { + applications = JSON.parse(card.dataset.applications.replace(/'/g, '"')); + } catch (e) { + console.error('Error parsing applications:', e); + } + + // Parse skills data + let primarySkills = []; + let secondarySkills = []; + try { + primarySkills = card.dataset.matchPrimarySkills ? + JSON.parse(card.dataset.matchPrimarySkills.replace(/'/g, '"')) : []; + secondarySkills = card.dataset.matchSecondarySkills ? + JSON.parse(card.dataset.matchSecondarySkills.replace(/'/g, '"')) : []; + } catch (e) { + console.error('Error parsing skills:', e); + } + + // Create the HTML structure + modalContent.innerHTML = ` +
+
+
+ ${card.dataset.image ? + `Candidate` : + `
+ ${card.dataset.candidate ? card.dataset.candidate.charAt(0).toUpperCase() : '?'} +
` + } +
+

+ ${card.dataset.candidate || 'Unnamed Candidate'} +

+
+ +
+

Contact Information

+
+ Email: + + ${card.dataset.email ? + `${card.dataset.email}` : + 'Not provided'} + +
+
+ Phone: + + ${card.dataset.phone ? + `${card.dataset.phone}` : + 'Not provided'} + +
+
+ Recruiter: + + ${card.dataset.manager || 'Unassigned'} + +
+
+ +
+

Skills Match

+
+
+
Primary Skills (${parseInt(card.dataset.primaryPercent) || 0}% Match)
+ ${primarySkills.length > 0 ? + `
    + ${primarySkills.map(skill => `
  • ${skill}
  • `).join('')} +
` : + '

No primary skills matched

'} +
+
+
Secondary Skills (${parseInt(card.dataset.secondaryPercent) || 0}% Match)
+ ${secondarySkills.length > 0 ? + `
    + ${secondarySkills.map(skill => `
  • ${skill}
  • `).join('')} +
` : + '

No secondary skills matched

'} +
+
+
+ +
+

Applications

+ ${applications.length > 0 ? + `
+ ${applications.map(app => ` +
+ ${app[0] || 'Untitled Application'} + ${app[1]} +
+ `).join('')} +
` : + '

No applications found

'} +
+
+ `; + + // Show modal + document.getElementById('candidateDetailModal').style.display = 'block'; +} + +function renderCandidateDetail(data) { + const detailContainer = document.getElementById('candidateDetailContent'); + // Here you would create the HTML structure based on your template + // and populate it with the data received from the server + detailContainer.innerHTML = ` +
+
+ +

${data.candidate.display_name || 'Unnamed Candidate'}

+
+ +
+ `; + // You would continue with all the other fields from your template +} + +function closeCandidateModal() { + document.getElementById('candidateDetailModal').style.display = 'none'; +} + +// +//// Add this function to initialize the popup functionality +//function initSmartButtons() { +// document.querySelectorAll('.smart-button').forEach(button => { +// console.log("event-exist"); +// button.addEventListener('click', function() { +// console.log("event-clicked"); +// const jobId = document.querySelector('.job-detail-container').getAttribute('data-job-id'); +// const popupType = this.dataset.popupType; +// const popupTitle = this.dataset.popupTitle; +// console.log(jobId); +// console.log(popupType); +// console.log(popupTitle); +// showSmartPopup(jobId, popupType, popupTitle); +// }); +// }); +//} +// +//function showSmartPopup(jobId, popupType, popupTitle) { +// // Create or get the canvas element +// debugger; +// let canvasId = popupType + 'Canvas'; +// let canvas = document.getElementById(canvasId); +// +// if (!canvas) { +// // Create the canvas from template if it doesn't exist +// const template = document.getElementById(popupType + '_popup'); +// if (template) { +// const clone = template.content.cloneNode(true); +// document.body.appendChild(clone); +// canvas = document.getElementById(canvasId); +// } +// } +// +// if (canvas) { +// // Initialize the offcanvas if not already initialized +// if (!canvas._offcanvas) { +// canvas._offcanvas = new bootstrap.Offcanvas(canvas); +// } +// +// // Show the offcanvas +// canvas._offcanvas.show(); +// +// // Load content +// loadPopupContent(jobId, popupType); +// } +//} +// +//function loadPopupContent(jobId, popupType) { +// console.log("loadPopupContent"); +// const canvas = document.getElementById(popupType + 'Canvas'); +// if (!canvas) return; +// +// const loadingSpinner = canvas.querySelector('.loading-spinner'); +// const contentArea = canvas.querySelector(`.${popupType.replace('_', '-')}-list`); +// +// // Show loading spinner +// if (loadingSpinner) loadingSpinner.style.display = 'block'; +// if (contentArea) contentArea.innerHTML = ''; +// +// // Determine the endpoint based on popup type +// let endpoint = ''; +// if (popupType === 'matchingCandidates') { +// endpoint = `/myATS/job/${jobId}/matching_candidates`; +// } else if (popupType === 'applicants') { +// endpoint = `/myATS/job/${jobId}/applicants`; +// } +// +// if (endpoint) { +// fetch(endpoint, { +// headers: { "X-Requested-With": "XMLHttpRequest" } +// }) +// .then(response => response.text()) +// .then(html => { +// if (contentArea) { +// contentArea.innerHTML = html; +// // Initialize any interactive elements in the popup if needed +// initPopupInteractiveElements(popupType); +// } +// }) +// .catch(error => { +// console.error(`Error loading ${popupType}:`, error); +// if (contentArea) { +// contentArea.innerHTML = ` +//
+// Failed to load ${popupType.replace('_', ' ')}. Please try again. +//
+// `; +// } +// }) +// .finally(() => { +// if (loadingSpinner) loadingSpinner.style.display = 'none'; +// }); +// } +//} +// +//function initPopupInteractiveElements(popupType) { +// // Initialize any interactive elements specific to each popup type +// if (popupType === 'matching_candidates') { +// // Add event listeners for matching candidates popup +// document.querySelectorAll('.match-candidate-action').forEach(button => { +// button.addEventListener('click', function() { +// const candidateId = this.dataset.candidateId; +// // Handle candidate action +// }); +// }); +// } else if (popupType === 'applicants') { +// // Add event listeners for applicants popup +// document.querySelectorAll('.applicant-action').forEach(button => { +// button.addEventListener('click', function() { +// const applicantId = this.dataset.applicantId; +// // Handle applicant action +// }); +// }); +// } +//} diff --git a/addons_extensions/hr_recruitment_web_app/views/jd.xml b/addons_extensions/hr_recruitment_web_app/views/jd.xml new file mode 100644 index 000000000..105b2a6c1 --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/views/jd.xml @@ -0,0 +1,1089 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons_extensions/project_task_timesheet_extended/__manifest__.py b/addons_extensions/project_task_timesheet_extended/__manifest__.py index 57c964663..bfc57f567 100644 --- a/addons_extensions/project_task_timesheet_extended/__manifest__.py +++ b/addons_extensions/project_task_timesheet_extended/__manifest__.py @@ -25,6 +25,8 @@ Key Features: 'base', 'analytic', 'project_gantt', + 'hr', + 'hr_contract', ], 'data': [ 'security/security.xml', @@ -35,6 +37,7 @@ Key Features: 'wizards/project_stage_update_wizard.xml', 'wizards/task_reject_reason_wizard.xml', 'view/teams.xml', + 'view/project_stages.xml', 'view/task_stages.xml', 'view/project.xml', 'view/project_task.xml', diff --git a/addons_extensions/project_task_timesheet_extended/data/data.xml b/addons_extensions/project_task_timesheet_extended/data/data.xml index 9bcc53817..8a8e9815e 100644 --- a/addons_extensions/project_task_timesheet_extended/data/data.xml +++ b/addons_extensions/project_task_timesheet_extended/data/data.xml @@ -12,6 +12,43 @@ + + Show/Hide Chatter + + + action + code + + if records: + action = records.action_show_project_chatter() + + + + + Show/Hide Chatter + + + action + code + + if records: + action = records.action_show_project_task_chatter() + + + + + Enable/Disable Stage Approvals + + + action + code + + if records: + action = records.action_assign_approval_flow() + + + + @@ -83,4 +120,103 @@ + + + Initial + project_sponsor + + 1 + + + + + Planning + project_manager + + 2 + + + + + Architecture & Design + project_manager + + 3 + + + + + Sprint Planning + project_manager + + 4 + + + + + Development + project_manager + + 5 + + + + + Testing & QA + project_manager + + 6 + + + + + Deployment + project_manager + + 7 + + + + + Maintenance & Support + project_manager + + 8 + + + + + Closer + project_sponsor + 9 + + + + + 10 + To Do + False + + + + 15 + In Progress + False + + + + 20 + Done + + False + + + + 25 + Cancelled + + project_manager + + + \ No newline at end of file diff --git a/addons_extensions/project_task_timesheet_extended/models/__init__.py b/addons_extensions/project_task_timesheet_extended/models/__init__.py index 7f9bdc21e..cb910db8e 100644 --- a/addons_extensions/project_task_timesheet_extended/models/__init__.py +++ b/addons_extensions/project_task_timesheet_extended/models/__init__.py @@ -1,7 +1,15 @@ from . import teams +from . import project_sprint +from . import task_documents +from . import project_architecture_design +from . import project_risk +from . import project_resource_cost +from . import project_costings +from . import project_code_commit +from . import project_stages from . import task_stages from . import project from . import project_task from . import timesheets # from . import project_task_gantt -from . import user_availability \ No newline at end of file +from . import user_availability diff --git a/addons_extensions/project_task_timesheet_extended/models/project_architecture_design.py b/addons_extensions/project_task_timesheet_extended/models/project_architecture_design.py new file mode 100644 index 000000000..7a455934b --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/models/project_architecture_design.py @@ -0,0 +1,29 @@ +from odoo import api, Command, fields, models + +class ProjectArchitectureDesign(models.Model): + _name = "project.architecture.design" + _description = "Architecture & Design" + + project_id = fields.Many2one("project.project", required=True) + + # System Architecture + architecture_diagram = fields.Binary(string="Architecture Diagram") + tech_stack = fields.Text(string="Tech Stack") + architecture_notes = fields.Text(string="Architecture Notes") + + # UI/UX + ui_wireframe = fields.Binary(string="UI Wireframe") + ux_flow = fields.Binary(string="UX Flow Diagram") + + # Database + er_diagram = fields.Binary(string="ER Diagram") + database_notes = fields.Text(string="Database Notes") + + # Review + design_status = fields.Selection([ + ('pending', 'Pending'), + ('approved', 'Approved'), + ('changes_required', 'Changes Required') + ], default="pending", string="Design Status") + + reviewer_comments = fields.Text(string="Reviewer Comments") diff --git a/addons_extensions/project_task_timesheet_extended/models/project_code_commit.py b/addons_extensions/project_task_timesheet_extended/models/project_code_commit.py new file mode 100644 index 000000000..9422c632a --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/models/project_code_commit.py @@ -0,0 +1,17 @@ +from odoo import models, fields + +class ProjectCommitStep(models.Model): + _name = 'project.commit.step' + _description = 'Commit Steps for Task' + _order = 'create_date desc' + + task_id = fields.Many2one('project.task', string="Task", ondelete='cascade') + + project_id = fields.Many2one('project.project',related='task_id.project_id') + commit_message = fields.Char(string="Commit Message", required=True) + commit_code = fields.Html(string="Commit Code") + branch_name = fields.Char(string="Branch") + files_changed = fields.Text(string="Files Changed") + commit_date = fields.Datetime(string="Commit Date", default=fields.Datetime.now) + notes = fields.Text(string="Notes") + sprint_id = fields.Many2one('project.sprint',related='task_id.sprint_id') diff --git a/addons_extensions/project_task_timesheet_extended/models/project_costings.py b/addons_extensions/project_task_timesheet_extended/models/project_costings.py new file mode 100644 index 000000000..27fff33cb --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/models/project_costings.py @@ -0,0 +1,46 @@ +from odoo import models, fields, api + +# --------------------------------------- +# MATERIAL COST +# --------------------------------------- +class ProjectMaterialCost(models.Model): + _name = "project.material.cost" + _description = "Material Cost for Project" + _order = "id desc" + + project_id = fields.Many2one("project.project", string="Project", required=True) + material_name = fields.Char(string="Material Name", required=True) + + qty = fields.Float(string="Quantity", default=1) + unit_cost = fields.Float(string="Unit Cost") + total_cost = fields.Float(string="Total Cost", compute="_compute_total_cost", store=True) + + remarks = fields.Text(string="Remarks") + + @api.depends("qty", "unit_cost") + def _compute_total_cost(self): + for rec in self: + rec.total_cost = rec.qty * rec.unit_cost if rec.qty and rec.unit_cost else 0 + + +# --------------------------------------- +# EQUIPMENT COST +# --------------------------------------- +class ProjectEquipmentCost(models.Model): + _name = "project.equipment.cost" + _description = "Equipment Cost for Project" + _order = "id desc" + + project_id = fields.Many2one("project.project", string="Project", required=True) + equipment_name = fields.Char(string="Equipment Name", required=True) + + duration_hours = fields.Float(string="Duration (Hours)", default=1) + hourly_rate = fields.Float(string="Hourly Rate") + total_cost = fields.Float(string="Total Cost", compute="_compute_total_cost", store=True) + + remarks = fields.Text(string="Remarks") + + @api.depends("duration_hours", "hourly_rate") + def _compute_total_cost(self): + for rec in self: + rec.total_cost = rec.duration_hours * rec.hourly_rate if rec.duration_hours and rec.hourly_rate else 0 diff --git a/addons_extensions/project_task_timesheet_extended/models/project_resource_cost.py b/addons_extensions/project_task_timesheet_extended/models/project_resource_cost.py new file mode 100644 index 000000000..96a4b9b95 --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/models/project_resource_cost.py @@ -0,0 +1,71 @@ +from odoo import models, fields, api +from datetime import date + +class ProjectResourceCost(models.Model): + _name = "project.resource.cost" + _description = "Resource Cost for Project" + + project_id = fields.Many2one("project.project", string="Project", required=True) + employee_id = fields.Many2one("hr.employee", string="Employee", required=True) + + monthly_salary = fields.Float(string="Monthly Salary") + daily_rate = fields.Float(string="Daily Rate") + + duration_days = fields.Integer(string="Estimated Working Days") + total_cost = fields.Float(string="Total Cost") + + start_date = fields.Date(string="Estimated Start Date") + end_date = fields.Date(string="Estimated End Date") + + # ------------------------------------------------------------- + # 1. Get project start & end dates when project is selected + # ------------------------------------------------------------- + @api.onchange('project_id') + def _onchange_project(self): + """Auto-fill start_date and end_date from project.""" + if self.project_id: + # If project has dates → use them + self.start_date = self.project_id.start_date or date.today() + self.end_date = self.project_id.end_date or self.start_date + + # ------------------------------------------------------------- + # 2. If employee selected → load salary + # ------------------------------------------------------------- + @api.onchange('employee_id') + def _onchange_employee(self): + if self.employee_id: + contract = self.employee_id.contract_id + if contract: + self.monthly_salary = contract.wage or 0.0 + self.daily_rate = (contract.wage / 30) if contract.wage else 0.0 + + # ------------------------------------------------------------- + # 3. Daily ↔ Monthly salary sync + # ------------------------------------------------------------- + @api.onchange('daily_rate') + def _onchange_daily_rate(self): + if self.daily_rate: + self.monthly_salary = self.daily_rate * 30 + + if self.duration_days and self.daily_rate: + self.total_cost = self.daily_rate * self.duration_days + + @api.onchange('monthly_salary') + def _onchange_monthly_salary(self): + if self.monthly_salary: + self.daily_rate = self.monthly_salary / 30 + + if self.duration_days and self.daily_rate: + self.total_cost = self.daily_rate * self.duration_days + + # ------------------------------------------------------------- + # 4. Date change → update duration and cost + # ------------------------------------------------------------- + @api.onchange('start_date', 'end_date') + def _onchange_dates(self): + if self.start_date and self.end_date: + duration = (self.end_date - self.start_date).days + 1 + self.duration_days = max(duration, 0) + + if self.duration_days and self.daily_rate: + self.total_cost = self.daily_rate * self.duration_days diff --git a/addons_extensions/project_task_timesheet_extended/models/project_risk.py b/addons_extensions/project_task_timesheet_extended/models/project_risk.py new file mode 100644 index 000000000..ff79e1b3b --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/models/project_risk.py @@ -0,0 +1,28 @@ +from odoo import models, fields + +class ProjectRisk(models.Model): + _name = "project.risk" + _description = "Project Risk Management" + _order = "id desc" + + project_id = fields.Many2one( + "project.project", + string="Project", + required=True, + ondelete="cascade" + ) + + risk_description = fields.Text(string="Risk Description", required=True) + probability = fields.Selection([ + ('low', "Low"), + ('medium', "Medium"), + ('high', "High") + ], string="Probability", required=True, default='medium') + + impact = fields.Selection([ + ('low', "Low"), + ('medium', "Medium"), + ('high', "High") + ], string="Impact", required=True, default='medium') + + mitigation_plan = fields.Text(string="Mitigation Plan") diff --git a/addons_extensions/project_task_timesheet_extended/models/project_sprint.py b/addons_extensions/project_task_timesheet_extended/models/project_sprint.py new file mode 100644 index 000000000..16140a366 --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/models/project_sprint.py @@ -0,0 +1,30 @@ +from odoo import models, fields, api, _ + +class ProjectSprint(models.Model): + _name = "project.sprint" + _description = "Project Sprint" + _order = "date_start desc" + + project_id = fields.Many2one( + "project.project", + required=True, + ondelete="cascade" + ) + + sprint_name = fields.Char(string="Sprint Name", required=True) + date_start = fields.Date(string="Start Date", required=True) + date_end = fields.Date(string="End Date", required=True) + + allocated_hours = fields.Float(string="Allocated Hours") + + status = fields.Selection( + [ + ("draft", "Draft"), + ("in_progress", "In Progress"), + ("done", "Completed"), + ], + default="draft", + string="Status" + ) + done_date = fields.Date(string="Done Date") + note = fields.Text(string="Note") diff --git a/addons_extensions/project_task_timesheet_extended/models/project_stages.py b/addons_extensions/project_task_timesheet_extended/models/project_stages.py new file mode 100644 index 000000000..9ec1f4eb9 --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/models/project_stages.py @@ -0,0 +1,696 @@ +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError +from markupsafe import Markup +from datetime import datetime, timedelta +import pytz + + +class ProjectStages(models.Model): + _inherit = "project.project.stage" + + approval_by = fields.Selection([('project_manager', 'Project Manager'), ('project_sponsor', 'Project Sponsor')]) + + +class projectStagesApprovalFlow(models.Model): + _name = 'project.stages.approval.flow' + + stage_id = fields.Many2one('project.project.stage') + stage_approval_by = fields.Selection(related='stage_id.approval_by') + approval_by = fields.Many2one("res.users", domain="[('id','in',approval_by_users)]") + assigned_to = fields.Many2one("res.users") + assigned_date = fields.Datetime() + submission_date = fields.Datetime() + project_id = fields.Many2one('project.project') + approval_by_users = fields.Many2many('res.users', compute="_compute_all_project_managers") + note = fields.Text() + manager_level_edit_access = fields.Boolean(compute="_compute_manager_level_edit_access") + + def _compute_manager_level_edit_access(self): + for rec in self: + if rec.approval_by == self.env.user or rec.project_id.user_id == self.env.user or self.env.user.has_group("project.group_project_manager"): + rec.manager_level_edit_access = True + else: + rec.manager_level_edit_access = False + def _compute_all_project_managers(self): + # Get the project manager Odoo group + group_pm = self.env.ref("project.group_project_manager") + + for rec in self: + # Search all users who belong to this group + pm_users = [] + + if rec.stage_approval_by == 'project_manager' and rec.project_id.user_id: + pm_users = rec.project_id.user_id.ids + elif rec.stage_approval_by == 'project_sponsor' and rec.project_id.project_sponsor: + pm_users = rec.project_id.project_sponsor.ids + else: + pm_users = self.env['res.users'].sudo().search([ + ('groups_id', 'in', group_pm.id) + ]).ids + + rec.approval_by_users = [(6, 0, pm_users)] + + +class ProjectProject(models.Model): + _inherit = 'project.project' + + project_stages = fields.One2many('project.stages.approval.flow', 'project_id') + assign_approval_flow = fields.Boolean(default=False) + project_sponsor = fields.Many2one('res.users') + show_project_chatter = fields.Boolean(default=False) + project_vision = fields.Text( + string="Project Vision", + help="Concise statement describing the project's ultimate goal and purpose" + ) + + # Requirement Documentation + description = fields.Html("Requirement Description") + requirement_file = fields.Binary("Requirement Document") + requirement_file_name = fields.Char("Requirement File Name") + + # Feasibility Assessment + feasibility_html = fields.Html("Feasibility Assessment") + feasibility_file = fields.Binary("Feasibility Document") + feasibility_file_name = fields.Char("Feasibility File Name") + + manager_level_edit_access = fields.Boolean(compute="_compute_has_manager_level_edit_access") + approval_status = fields.Selection([ + ('submitted', 'Submitted'), + ('reject', 'Rejected') + ]) + show_submission_button = fields.Boolean(compute="_compute_access_check") + show_approval_button = fields.Boolean(compute="_compute_access_check") + show_refuse_button = fields.Boolean(compute="_compute_access_check") + show_back_button = fields.Boolean(compute="_compute_access_check") + project_activity_log = fields.Html(string="Project Activity Log") + project_scope = fields.Html(string="Scope", default=lambda self: """ +

Scope Description



+

1. In Scope Items?


+

2. Out Scope Items?


+ """) + risk_ids = fields.One2many( + "project.risk", + "project_id", + string="Project Risks" + ) + # stage_id = fields.Many2one(domain="[('id','in',showable_stage_ids or [])]") + + showable_stage_ids = fields.Many2many('project.project.stage',compute='_compute_project_project_stages') + + # fields: + estimated_amount = fields.Float(string="Estimated Amount") + total_budget_amount = fields.Float(string="Total Budget Amount", compute="_compute_total_budget", store=True) + + # Manpower + resource_cost_ids = fields.One2many( + "project.resource.cost", + "project_id", + string="Resource Costs" + ) + + # Material + material_cost_ids = fields.One2many( + "project.material.cost", + "project_id", + string="Material Costs" + ) + + # Equipment + equipment_cost_ids = fields.One2many( + "project.equipment.cost", + "project_id", + string="Equipment Costs" + ) + + architecture_design_ids = fields.One2many( + "project.architecture.design", + "project_id", + string="Architecture & Design" + ) + + require_sprint = fields.Boolean( + string="Require Sprints?", + default=False, + help="Enable sprint-based planning for this project." + ) + + sprint_ids = fields.One2many( + "project.sprint", + "project_id", + string="Project Sprints" + ) + + commit_step_ids = fields.One2many( + 'project.commit.step', + 'project_id', + string="Commit Steps" + ) + + development_document_ids = fields.One2many( + "task.development.document", + "project_id", + string="Development Documents" + ) + + testing_document_ids = fields.One2many( + "task.testing.document", + "project_id", + string="Testing Documents" + ) + development_notes = fields.Html() + testing_notes = fields.Html() + + @api.depends('require_sprint') + def _compute_project_project_stages(self): + for rec in self: + stage_ids = self.env['project.project.stage'].sudo().search([('active', '=', True), ('company_id','in',[self.env.company.id,False])]).ids + + if not rec.require_sprint: + stage_ids = self.env['project.project.stage'].sudo().search([ + ('active', '=', True),('company_id','in',[self.env.company.id,False]), ('id', '!=', self.env.ref( + "project_task_timesheet_extended.project_project_stage_sprint_planning").id) + ]).ids + rec.showable_stage_ids = stage_ids + + @api.depends("resource_cost_ids.total_cost", "material_cost_ids.total_cost", "equipment_cost_ids.total_cost") + def _compute_total_budget(self): + for project in self: + project.total_budget_amount = ( + sum(project.resource_cost_ids.mapped("total_cost")) + + sum(project.material_cost_ids.mapped("total_cost")) + + sum(project.equipment_cost_ids.mapped("total_cost")) + ) + + # -------------------------------------------------------- + # Fetch Resource Data Button + # -------------------------------------------------------- + def action_fetch_resource_data(self): + """Fetch all members' employee records and create manpower cost lines with full auto-fill.""" + for project in self: + + # Project users = members + project manager + project lead + users = project.members_ids | project.user_id | project.project_lead + + # Fetch employees linked to those users + employees = self.env["hr.employee"].search([("user_id", "in", users.ids)]) + + for emp in employees: + + # Avoid duplicate manpower lines + existing = project.resource_cost_ids.filtered(lambda r: r.employee_id == emp) + if existing: + continue + + # Get active contract for salary details + contract = emp.contract_id + + monthly_salary = contract.wage if contract else 0.0 + daily_rate = (contract.wage / 30) if contract and contract.wage else 0.0 + + # Project Dates + start_date = project.date_start or fields.Date.today() + end_date = project.date or start_date + + # Duration + duration = (end_date - start_date).days + 1 if start_date and end_date else 0 + + # Total Cost + total_cost = daily_rate * duration if daily_rate else 0 + + # Create manpower line + self.env["project.resource.cost"].create({ + "project_id": project.id, + "employee_id": emp.id, + "monthly_salary": monthly_salary, + "daily_rate": daily_rate, + "start_date": start_date, + "end_date": end_date, + "duration_days": duration, + "total_cost": total_cost, + }) + + @api.depends("project_stages", "stage_id", "approval_status") + def _compute_access_check(self): + """Compute visibility of action buttons based on user permissions and project state""" + for project in self: + project.show_submission_button = False + project.show_approval_button = False + project.show_refuse_button = False + project.show_back_button = False + + if not project.assign_approval_flow: + continue + + user = self.env.user + project_manager = project.user_id + project_sponsor = project.project_sponsor + + # Current approval timeline for this stage + current_approval_timeline = project.project_stages.filtered( + lambda s: s.stage_id == project.stage_id + ) + + # Compute button visibility based on approval flow + if current_approval_timeline: + line = current_approval_timeline[0] + assigned_to = line.assigned_to + responsible_lead = line.approval_by + + # Submission button for assigned users + if (assigned_to == user and + project.approval_status != "submitted" and + assigned_to != responsible_lead): + project.show_submission_button = True + + # Approval/refusal buttons for responsible leads + if (project.approval_status == "submitted" and + responsible_lead == user): + project.show_approval_button = True + project.show_refuse_button = True + + # Direct approval when no assigned user + if not assigned_to and responsible_lead == user: + project.show_approval_button = True + + # Direct approval when assigned user is also responsible + if (assigned_to == responsible_lead and + user == assigned_to): + project.show_approval_button = True + else: + # Managers can approve without specific flow + if user.has_group("project.group_project_manager"): + project.show_approval_button = True + + # Managers get additional permissions + if user in [project_manager] or user.has_group("project.group_project_manager"): + project.show_back_button = True + if user.has_group("project.group_project_manager"): + project.show_approval_button = True + + # Stage-specific button visibility + if project.stage_id: + stages = self.env['project.project.stage'].search([('id','in',project.showable_stage_ids.ids), + ('id', '!=', self.env.ref("project.project_project_stage_3").id) + ]) + if stages: + first_stage = stages.sorted('sequence')[0] + last_stage = stages.sorted('sequence')[-1] + + if project.stage_id == first_stage: + project.show_back_button = False + if project.stage_id == last_stage: + project.show_submission_button = False + project.show_approval_button = False + project.show_refuse_button = False + + @api.depends("user_id") + def _compute_has_manager_level_edit_access(self): + """Determine if current user has manager-level edit permissions""" + for rec in self: + rec.manager_level_edit_access = ( + rec.user_id == self.env.user or + self.env.user.has_group("project.group_project_manager") + ) + + def action_show_project_chatter(self): + """Toggle visibility of project chatter""" + for project in self: + project.show_project_chatter = not project.show_project_chatter + + def action_assign_approval_flow(self): + """Configure approval flow for project stages""" + for project in self: + if not project.project_sponsor or not project.user_id: + raise ValidationError(_("Sponsor and Manager are required to assign Stage Approvals")) + + project.assign_approval_flow = not project.assign_approval_flow + + if project.assign_approval_flow: + # Clear existing records + project.project_stages.unlink() + + # Fetch all project stages + stages = self.env['project.project.stage'].sudo().search([('id','in',project.showable_stage_ids.ids)]) + + for stage in stages: + # Determine approval authority based on stage configuration + approval_by = ( + project.user_id.id if stage.sudo().approval_by == 'project_manager' else + project.project_sponsor.id if stage.sudo().approval_by == 'project_sponsor' else + False + ) + + self.env['project.stages.approval.flow'].sudo().create({ + 'project_id': project.id, + 'stage_id': stage.id, + 'approval_by': approval_by, + 'assigned_to': approval_by, + 'assigned_date': fields.Datetime.now() if stage == project.stage_id else False, + 'submission_date': False, + }) + + # Log approval flow assignment + self.sudo()._add_activity_log("Approval flow assigned by %s" % self.env.user.name) + self.sudo()._post_to_project_channel( + _("Approval flow configured for project %s") % project.name + ) + else: + project.sudo().project_stages.unlink() + self.sudo()._add_activity_log("Approval flow removed by %s" % self.env.user.name) + self.sudo()._post_to_project_channel( + _("Approval flow removed for project %s") % project.name + ) + + def submit_project_for_approval(self): + """Submit project for current stage approval""" + for project in self: + project.sudo().approval_status = "submitted" + current_stage = project.sudo().stage_id + current_approval_timeline = project.sudo().project_stages.filtered( + lambda s: s.stage_id == project.sudo().stage_id + ) + + if current_approval_timeline: + current_approval_timeline.sudo().submission_date = fields.Datetime.now() + + stage_line = project.sudo().project_stages.filtered(lambda s: s.stage_id == current_stage) + responsible_user = stage_line.sudo().approval_by if stage_line else False + + # Create activity log + activity_log = "%s : %s submitted for approval to %s" % ( + current_stage.sudo().name, + self.env.user.name, + responsible_user.sudo().name if responsible_user else "N/A" + ) + project.sudo()._add_activity_log(activity_log) + + # Post to project channel + if responsible_user: + channel_message = _("Project %s submitted for approval at stage %s. %s please review.") % ( + project.sudo().name, + current_stage.sudo().name, + project.sudo()._create_odoo_mention(responsible_user.partner_id) + ) + else: + channel_message = _("Project %s submitted for approval at stage %s") % ( + project.sudo().name, + current_stage.sudo().name + ) + project.sudo()._post_to_project_channel(channel_message) + + # Send notification + if responsible_user: + project.sudo().message_post( + body=activity_log, + partner_ids=[responsible_user.sudo().partner_id.id], + message_type='notification', + subtype_xmlid='mail.mt_comment' + ) + + def project_proceed_further(self): + """Advance project to next stage after approval""" + for project in self: + current_stage = project.stage_id + next_stage = self.env["project.project.stage"].search([ + ('sequence', '>', project.stage_id.sequence), + ('id', '!=', self.env.ref("project.project_project_stage_3").id), + ('id', 'in', project.showable_stage_ids.ids), + ], order="sequence asc", limit=1) + + current_approval_timeline = project.project_stages.filtered( + lambda s: s.stage_id == project.stage_id + ) + + if current_approval_timeline: + current_approval_timeline.submission_date = fields.Datetime.now() + if not current_approval_timeline.assigned_date: + current_approval_timeline.assigned_date = fields.Datetime.now() + + if next_stage: + next_approval_timeline = project.project_stages.filtered( + lambda s: s.stage_id == next_stage + ) + if next_approval_timeline and not next_approval_timeline.assigned_date: + next_approval_timeline.assigned_date = fields.Datetime.now() + + project.stage_id = next_stage + project.approval_status = "" + + # Create activity log + activity_log = "%s approved by %s → moved to %s" % ( + current_stage.name, + self.env.user.name, + next_stage.name + ) + project._add_activity_log(activity_log) + + # Post to project channel + next_user = next_approval_timeline.assigned_to if next_approval_timeline else False + if next_user: + channel_message = _("Project %s approved at stage %s and moved to %s. %s please proceed.") % ( + project.name, + current_stage.name, + next_stage.name, + project._create_odoo_mention(next_user.partner_id) + ) + else: + channel_message = _("Project %s approved at stage %s and moved to %s") % ( + project.name, + current_stage.name, + next_stage.name + ) + project._post_to_project_channel(channel_message) + + # Send notification + if next_user: + project.message_post( + body=activity_log, + partner_ids=[next_user.partner_id.id], + message_type='notification', + subtype_xmlid='mail.mt_comment' + ) + else: + # Last stage completed + project.approval_status = "" + activity_log = "%s fully approved and completed" % project.name + project._add_activity_log(activity_log) + project._post_to_project_channel( + _("Project %s completed and fully approved") % project.name + ) + project.message_post(body=activity_log) + + def reject_and_return(self, reason=None): + """Reject project at current stage with optional reason""" + for project in self: + reason = reason or "" + current_stage = project.stage_id + project.approval_status = "reject" + + # Create activity log + activity_log = "%s rejected by %s — %s" % ( + current_stage.name, + self.env.user.name, + reason + ) + project._add_activity_log(activity_log) + + # Update approval timeline + current_approval_timeline = project.project_stages.filtered( + lambda s: s.stage_id == project.stage_id + ) + if current_approval_timeline: + current_approval_timeline.note = f"Reject Reason: {reason}" + + # Post to project channel + channel_message = _("Project %s rejected at stage %s. Reason: %s") % ( + project.name, + current_stage.name, + reason + ) + project._post_to_project_channel(channel_message) + + # Send notification + project.message_post(body=activity_log) + + # Notify responsible users + if current_approval_timeline: + responsible_user = ( + current_approval_timeline.assigned_to or + current_approval_timeline.approval_by + ) + if responsible_user: + project.message_post( + body=_("Project %s has been rejected and returned to you") % project.name, + partner_ids=[responsible_user.partner_id.id], + message_type='notification', + subtype_xmlid='mail.mt_comment' + ) + + def project_back_button(self): + """Revert project to previous stage""" + for project in self: + prev_stage = self.env["project.project.stage"].search([ + ('sequence', '<', project.stage_id.sequence), + ('id', 'in', project.showable_stage_ids.ids) + ], order="sequence desc", limit=1) + + if not prev_stage: + raise ValidationError(_("No previous stage available.")) + + # Create activity log + activity_log = "%s reverted back to %s by %s" % ( + project.stage_id.name, + prev_stage.name, + self.env.user.name + ) + project._add_activity_log(activity_log) + + # Post to project channel + channel_message = _("Project %s reverted from %s back to %s") % ( + project.name, + project.stage_id.name, + prev_stage.name + ) + project._post_to_project_channel(channel_message) + + # Update stage + project.stage_id = prev_stage + project.message_post(body=activity_log) + + def action_open_reject_wizard(self): + """Open rejection wizard for projects""" + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Reject Project"), + "res_model": "project.reject.reason.wizard", + "view_mode": "form", + "target": "new", + "context": {"default_project_id": self.id}, + } + + # Activity Log Helper Methods + def _get_current_datetime_formatted(self): + """Get current datetime in 'DD-MMM-YYYY HH:MM AM/PM' format""" + now = fields.Datetime.context_timestamp(self, fields.datetime.now()) + formatted_date = now.strftime('%d-%b-%Y %I:%M %p').upper() + return formatted_date[1:] if formatted_date.startswith('0') else formatted_date + + def _add_activity_log(self, activity_text): + """Add formatted entry to project activity log""" + formatted_datetime = self._get_current_datetime_formatted() + for project in self: + log_entry = f"[{formatted_datetime}] {activity_text}" + if project.project_activity_log: + project.project_activity_log = Markup(project.project_activity_log) + Markup('
') + Markup(log_entry) + else: + project.project_activity_log = Markup(log_entry) + + def _post_to_project_channel(self, message_body, mention_partners=None): + """Post message to project's discuss channel with proper mentions""" + for project in self: + if not project.id: + continue + + # Get project channel + channel = ( + project.discuss_channel_id or + project.default_projects_channel_id + ) + + if channel: + formatted_message = self._format_message_with_odoo_mentions( + message_body, + mention_partners + ) + channel.message_post( + body=Markup(formatted_message), + message_type='comment', + subtype_xmlid='mail.mt_comment', + author_id=self.env.user.partner_id.id + ) + + def _format_message_with_odoo_mentions(self, message_body, mention_partners=None): + """Format message with proper Odoo @mentions""" + if not mention_partners: + return f'
{message_body}
' + + message_parts = ['
', message_body] + for partner in mention_partners: + if partner and partner.name: + mention_html = f'@{partner.name}' + message_parts.append(mention_html) + message_parts.append('
') + return ' '.join(message_parts) + + def _create_odoo_mention(self, partner): + """Create Odoo mention link for a partner""" + if not partner: + return "" + return f'@{partner.name}' + + +class ProjectTask(models.Model): + _inherit = 'project.task' + + + def _default_sprint_id(self): + """Return the current active (in-progress) sprint of the project.""" + if 'project_id' in self._context: + project_id = self._context.get('project_id') + sprint = self.env['project.sprint'].search([ + ('project_id', '=', project_id), + ('status', '=', 'in_progress') + ], limit=1) + return sprint.id + return False + + sprint_id = fields.Many2one( + "project.sprint", + string="Sprint", + default=_default_sprint_id + ) + + require_sprint = fields.Boolean( + related="project_id.require_sprint", + store=False + ) + + commit_step_ids = fields.One2many( + 'project.commit.step', + 'task_id', + string="Commit Steps" + ) + + show_task_chatter = fields.Boolean(default=False) + + development_document_ids = fields.One2many( + "task.development.document", + "task_id", + string="Development Documents" + ) + + testing_document_ids = fields.One2many( + "task.testing.document", + "task_id", + string="Testing Documents" + ) + + @api.onchange("project_id") + def _onchange_project_id_sprint_required(self): + for task in self: + if task.project_id and not task.project_id.require_sprint: + task.sprint_id = False + else: + if task.project_id and task.project_id.require_sprint: + sprint = self.env['project.sprint'].search([ + ('project_id', '=', task.project_id.id), + ('status', '=', 'in_progress') + ], limit=1) + task.sprint_id = sprint.id + + + def action_show_project_task_chatter(self): + """Toggle visibility of project chatter""" + for project in self: + project.show_task_chatter = not project.show_task_chatter \ No newline at end of file diff --git a/addons_extensions/project_task_timesheet_extended/models/task_documents.py b/addons_extensions/project_task_timesheet_extended/models/task_documents.py new file mode 100644 index 000000000..5f57d93c7 --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/models/task_documents.py @@ -0,0 +1,44 @@ +from odoo import models, fields + + +# --------------------------------------------------------- +# Development Documents +# --------------------------------------------------------- +class TaskDevelopmentDocument(models.Model): + _name = "task.development.document" + _description = "Development Documents" + _order = "create_date desc" + + task_id = fields.Many2one( + "project.task", + string="Task", + ondelete="cascade" + ) + + project_id = fields.Many2one("project.project",string="Project",ondelete="cascade") + + name = fields.Char(string="Document Name", required=True) + file = fields.Binary(string="Document File", required=True) + file_name = fields.Char(string="Filename") + notes = fields.Text(string="Notes") + + +# --------------------------------------------------------- +# Testing Documents +# --------------------------------------------------------- +class TaskTestingDocument(models.Model): + _name = "task.testing.document" + _description = "Testing Documents" + _order = "create_date desc" + + task_id = fields.Many2one( + "project.task", + string="Task", + ondelete="cascade" + ) + project_id = fields.Many2one("project.project",string="Project",ondelete="cascade") + + name = fields.Char(string="Document Name", required=True) + file = fields.Binary(string="Testing File", required=True) + file_name = fields.Char(string="Filename") + notes = fields.Text(string="Notes") diff --git a/addons_extensions/project_task_timesheet_extended/security/ir.model.access.csv b/addons_extensions/project_task_timesheet_extended/security/ir.model.access.csv index aaa5eea4b..4f6a086a0 100644 --- a/addons_extensions/project_task_timesheet_extended/security/ir.model.access.csv +++ b/addons_extensions/project_task_timesheet_extended/security/ir.model.access.csv @@ -3,12 +3,42 @@ internal_teams_admin,internal.teams.admin,model_internal_teams,project.group_pro internal_teams_manager,internal.teams.manager,model_internal_teams,project.group_project_user,1,1,1,0 internal_teams_user,internal.teams.user,model_internal_teams,base.group_user,1,0,0,0 +access_project_sprint_user,access.project.sprint.user,model_project_sprint,project.group_project_user,1,1,1,1 +access_project_sprint_manager,access.project.sprint.manager,model_project_sprint,project.group_project_manager,1,1,1,1 + + +access_project_architecture_design_user,access_project_architecture_design_user,model_project_architecture_design,project.group_project_user,1,1,1,1 +access_project_architecture_design_manager,access_project_architecture_design_manager,model_project_architecture_design,project.group_project_manager,1,1,1,1 + +access_project_risk_user,access_project_risk_user,model_project_risk,project.group_project_user,1,1,1,1 +access_project_risk_manager,access_project_risk_manager,model_project_risk,project.group_project_manager,1,1,1,1 + +access_project_resource_cost_user,project.resource.cost.user,model_project_resource_cost,project.group_project_user,1,1,1,1 +access_project_resource_cost_manager,project.resource.cost.manager,model_project_resource_cost,project.group_project_manager,1,1,1,1 + +access_project_material_cost_user,project.material.cost.user,model_project_material_cost,project.group_project_user,1,1,1,1 +access_project_material_cost_manager,project.material.cost.manager,model_project_material_cost,project.group_project_manager,1,1,1,1 + +access_project_equipment_cost_user,project.equipment.cost.user,model_project_equipment_cost,project.group_project_user,1,1,1,1 +access_project_equipment_cost_manager,project.equipment.cost.manager,model_project_equipment_cost,project.group_project_manager,1,1,1,1 + +access_project_commit_step_user,access_project_commit_step_user,model_project_commit_step,project.group_project_user,1,1,1,1 +access_project_commit_step_manager,access_project_commit_step_manager,model_project_commit_step,project.group_project_manager,1,1,1,1 + +access_task_development_document,access_task_development_document,model_task_development_document,base.group_user,1,1,1,1 +access_task_testing_document,access_task_testing_document,model_task_testing_document,base.group_user,1,1,1,1 + project_user_assign_wizard_manager,project.user.assign.wizard,model_project_user_assign_wizard,project_task_timesheet_extended.group_project_supervisor,1,1,1,1 project_user_assign_wizard_admin,project.user.assign.wizard.admin,model_project_user_assign_wizard,project.group_project_manager,1,1,1,1 project_user_assign_wizard_user,project.user.assign.wizard.user,model_project_user_assign_wizard,project.group_project_manager,1,0,0,0 +project_user_project_reject_reason_wizard,project.reject.reason.wizard.user,model_project_reject_reason_wizard,base.group_user,1,1,1,1 project_user_task_reject_reason_wizard,task.reject.reason.wizard.user,model_task_reject_reason_wizard,base.group_user,1,1,1,1 + +access_project_stages_approval_flow_admin,access.project.stages.approval.flow.admin,model_project_stages_approval_flow,project.group_project_manager,1,1,1,1 +access_project_stages_approval_flow_user,access.project.stages.approval.flow.user,model_project_stages_approval_flow,base.group_user,1,1,0,0 + project_internal_team_members_wizard,internal.team.members.wizard.manager,model_internal_team_members_wizard,base.group_user,1,1,1,1 project_project_stage_update_wizard,project.stage.update.wizard.manager,model_project_stage_update_wizard,base.group_user,1,1,1,1 diff --git a/addons_extensions/project_task_timesheet_extended/view/project.xml b/addons_extensions/project_task_timesheet_extended/view/project.xml index 8b0d60958..38ddf6185 100644 --- a/addons_extensions/project_task_timesheet_extended/view/project.xml +++ b/addons_extensions/project_task_timesheet_extended/view/project.xml @@ -82,16 +82,4 @@ - - project.invoice.inherit.form.view - project.project - - - - - - - - - \ No newline at end of file diff --git a/addons_extensions/project_task_timesheet_extended/view/project_stages.xml b/addons_extensions/project_task_timesheet_extended/view/project_stages.xml new file mode 100644 index 000000000..9b6ad8a53 --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/view/project_stages.xml @@ -0,0 +1,540 @@ + + + + project.project.stage.list.inherit + project.project.stage + + + + + + + + + + project.project.inherit.form.view + project.project + + + +