This commit is contained in:
gsinghpal
2026-04-29 03:35:33 -04:00
parent 6ac6d24da6
commit a2fe1fcbcc
61 changed files with 4655 additions and 667 deletions

View File

@@ -0,0 +1,179 @@
<h2>Hybrid: C's columns + A's card style</h2>
<p class="subtitle">Status columns, but each project is a full card with progress bar, %, hours, and task count.</p>
<style>
.preview-page { background:#f3f4f6; padding:18px; border-radius:8px; min-height:340px; font-size:12px;}
.preview-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:14px; color:#6b7280;}
.preview-head .crumbs { display:flex; gap:6px; align-items:center; color:#374151; font-weight:500;}
.preview-head .controls { display:flex; gap:6px;}
.ctrl { background:white; border:1px solid #d8dadd; padding:3px 10px; border-radius:6px; font-size:11px;}
.pill { display:inline-block; padding:2px 8px; border-radius:999px; font-size:10px; font-weight:600;}
.pill.green { background:#dcfce7; color:#166534;}
.pill.blue { background:#dbeafe; color:#1d4ed8;}
.pill.amber { background:#fef3c7; color:#92400e;}
.pill.gray { background:#e5e7eb; color:#374151;}
.cols { display:grid; grid-template-columns: 1fr 1fr 1fr; gap:14px;}
.col { background:transparent; }
.col-head { display:flex; justify-content:space-between; align-items:center; padding:0 4px 8px; border-bottom:2px solid #e5e7eb; margin-bottom:10px;}
.col-head .label { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:#374151; display:flex; align-items:center; gap:6px;}
.dot { width:8px; height:8px; border-radius:99px;}
.dot.green { background:#22c55e;}
.dot.amber { background:#f59e0b;}
.dot.gray { background:#9ca3af;}
.col-count { font-size:10px; font-weight:700; color:#6b7280; background:#e5e7eb; padding:2px 7px; border-radius:99px;}
/* card pulled from option A */
.pcard { background:white; border:1px solid #d8dadd; border-radius:10px; padding:11px; margin-bottom:8px; box-shadow:0 1px 0 rgba(15,23,42,.02);}
.pcard:hover { border-color:#93c5fd; box-shadow:0 2px 8px rgba(59,130,246,.08);}
.pcard .ptitle { font-weight:600; color:#0f172a; margin-bottom:4px; font-size:12px; line-height:1.3;}
.pcard .pmeta-row { display:flex; justify-content:space-between; align-items:center; color:#6b7280; font-size:10px; margin-bottom:6px;}
.pcard .pbar { height:5px; background:#e5e7eb; border-radius:99px; overflow:hidden; margin-bottom:7px;}
.pcard .pbar > span { display:block; height:100%; background:#22c55e;}
.pcard .pbar.amber > span { background:#f59e0b;}
.pcard .pfooter { display:flex; justify-content:space-between; align-items:center; font-size:10px; color:#6b7280;}
.pcard .pfooter .stats { display:flex; gap:5px;}
.empty { color:#9ca3af; font-size:11px; text-align:center; padding:24px 0; background:white; border:1px dashed #d8dadd; border-radius:10px;}
</style>
<div class="preview-page">
<div class="preview-head">
<span class="crumbs">🏠 / Projects</span>
<div class="controls">
<span class="ctrl">🔎 Search…</span>
<span class="ctrl">Group: Status ▾</span>
<span class="ctrl">Sort: Name ▾</span>
</div>
</div>
<div class="cols">
<!-- ACTIVE -->
<div class="col">
<div class="col-head">
<span class="label"><span class="dot green"></span> Active</span>
<span class="col-count">2</span>
</div>
<div class="pcard">
<div class="ptitle">S29824</div>
<div class="pmeta-row">
<span>Westin Healthcare</span>
<span>62%</span>
</div>
<div class="pbar"><span style="width:62%"></span></div>
<div class="pfooter">
<span class="stats"><span class="pill blue">50 tasks</span><span class="pill green">31 done</span></span>
<span>42.5 / 65h</span>
</div>
</div>
<div class="pcard">
<div class="ptitle">S29824 - Internal</div>
<div class="pmeta-row">
<span>Internal QA</span>
<span>0%</span>
</div>
<div class="pbar amber"><span style="width:8%"></span></div>
<div class="pfooter">
<span class="stats"><span class="pill blue">1 task</span></span>
<span></span>
</div>
</div>
</div>
<!-- IDLE -->
<div class="col">
<div class="col-head">
<span class="label"><span class="dot amber"></span> Idle</span>
<span class="col-count">3</span>
</div>
<div class="pcard">
<div class="ptitle">Customer Care</div>
<div class="pmeta-row">
<span>No tasks yet</span>
<span></span>
</div>
<div class="pbar"><span style="width:0%"></span></div>
<div class="pfooter">
<span class="stats"><span class="pill gray">0 tasks</span></span>
<span></span>
</div>
</div>
<div class="pcard">
<div class="ptitle">Field Service</div>
<div class="pmeta-row">
<span>No tasks yet</span>
<span></span>
</div>
<div class="pbar"><span style="width:0%"></span></div>
<div class="pfooter">
<span class="stats"><span class="pill gray">0 tasks</span></span>
<span></span>
</div>
</div>
<div class="pcard">
<div class="ptitle">Internal</div>
<div class="pmeta-row">
<span>No tasks yet</span>
<span></span>
</div>
<div class="pbar"><span style="width:0%"></span></div>
<div class="pfooter">
<span class="stats"><span class="pill gray">0 tasks</span></span>
<span></span>
</div>
</div>
</div>
<!-- DONE -->
<div class="col">
<div class="col-head">
<span class="label"><span class="dot gray"></span> Done</span>
<span class="col-count">0</span>
</div>
<div class="empty">Completed projects will appear here.</div>
</div>
</div>
</div>
<div class="section" style="margin-top:22px">
<h3>Open question: how do we decide which column?</h3>
<p class="subtitle">Same card design either way; this just determines column placement.</p>
<div class="options" style="margin-top:12px">
<div class="option" data-choice="grouping-activity" onclick="toggleSelect(this)">
<div class="letter">1</div>
<div class="content">
<h3>By activity (computed)</h3>
<p><b>Active</b> = at least one open task. <b>Idle</b> = no open tasks but project is still open. <b>Done</b> = project is archived/closed. No new fields needed.</p>
</div>
</div>
<div class="option" data-choice="grouping-status" onclick="toggleSelect(this)">
<div class="letter">2</div>
<div class="content">
<h3>By project status field</h3>
<p>Use Odoo's <code>last_update_status</code> (On Track / At Risk / Off Track / On Hold / Done). Five columns is too many for a portal — we'd collapse into 3 buckets.</p>
</div>
</div>
<div class="option" data-choice="grouping-stage" onclick="toggleSelect(this)">
<div class="letter">3</div>
<div class="content">
<h3>By a custom field on project</h3>
<p>Add <code>x_fc_portal_status</code> with three values you control (e.g. Active/Idle/Done). Most flexible, but someone has to maintain it.</p>
</div>
</div>
</div>
</div>
<p class="subtitle" style="margin-top:14px">My recommendation: <b>Option 1 — by activity</b>. Zero new fields, columns reflect reality automatically, and it matches what the screenshot already shows ("S29824 has 50 tasks, others have 0").</p>

View File

@@ -0,0 +1,151 @@
<h2>Pick a layout direction for /my/projects</h2>
<p class="subtitle">Click a card to select. We'll iterate on the chosen direction next.</p>
<style>
.preview-page { background: #f3f4f6; padding: 14px; border-radius: 6px; min-height: 240px; font-size: 12px; }
.preview-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; color:#6b7280;}
.preview-head .crumbs { display:flex; gap:6px; align-items:center;}
.pill { display:inline-block; padding:2px 8px; border-radius:999px; font-size:10px; font-weight:600;}
.pill.green { background:#dcfce7; color:#166534;}
.pill.blue { background:#dbeafe; color:#1d4ed8;}
.pill.amber { background:#fef3c7; color:#92400e;}
.pill.gray { background:#e5e7eb; color:#374151;}
/* Option A: card grid */
.a-grid { display:grid; grid-template-columns: 1fr 1fr; gap:8px;}
.a-card { background:white; border:1px solid #d8dadd; border-radius:8px; padding:10px; }
.a-card .a-title { font-weight:600; color:#111827; margin-bottom:4px;}
.a-card .a-meta { display:flex; justify-content:space-between; align-items:center; color:#6b7280; font-size:10px;}
.a-bar { height:4px; background:#e5e7eb; border-radius:99px; margin-top:6px; overflow:hidden;}
.a-bar > span { display:block; height:100%; background:#22c55e;}
/* Option B: enhanced rows */
.b-row { display:flex; align-items:center; padding:9px 12px; background:white; border:1px solid #d8dadd; border-radius:6px; margin-bottom:4px;}
.b-row .icon { width:28px; height:28px; border-radius:6px; background:#dbeafe; display:flex;align-items:center;justify-content:center; color:#1d4ed8; font-weight:700; margin-right:10px; font-size:11px;}
.b-row .name { flex-grow:1; font-weight:600; color:#111827;}
.b-row .stats { display:flex; gap:6px;}
.b-row .barwrap { width:80px; height:4px; background:#e5e7eb; border-radius:99px; margin-right:8px;}
.b-row .barwrap > span { display:block; height:100%; background:#22c55e;}
/* Option C: kanban columns */
.c-cols { display:grid; grid-template-columns: 1fr 1fr 1fr; gap:8px;}
.c-col { background:#fafafa; border:1px solid #e5e7eb; border-radius:8px; padding:8px;}
.c-col h4 { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; color:#6b7280; margin:0 0 8px;}
.c-card { background:white; border:1px solid #d8dadd; border-radius:6px; padding:8px; margin-bottom:6px; font-size:11px;}
.c-card .ct { font-weight:600; color:#111827; margin-bottom:2px;}
.c-card .cm { color:#6b7280; font-size:10px;}
</style>
<div class="cards">
<div class="card" data-choice="a" onclick="toggleSelect(this)">
<div class="card-image">
<div class="preview-page">
<div class="preview-head">
<span class="crumbs">🏠 / Projects</span>
<span>Sort: Name ▾</span>
</div>
<div class="a-grid">
<div class="a-card">
<div class="a-title">S29824</div>
<div class="a-meta"><span class="pill blue">50 tasks</span><span>62%</span></div>
<div class="a-bar"><span style="width:62%"></span></div>
</div>
<div class="a-card">
<div class="a-title">Customer Care</div>
<div class="a-meta"><span class="pill gray">0 tasks</span><span></span></div>
<div class="a-bar"><span style="width:0%"></span></div>
</div>
<div class="a-card">
<div class="a-title">Field Service</div>
<div class="a-meta"><span class="pill gray">0 tasks</span><span></span></div>
<div class="a-bar"><span style="width:0%"></span></div>
</div>
<div class="a-card">
<div class="a-title">S29824 - Internal</div>
<div class="a-meta"><span class="pill blue">1 task</span><span>0%</span></div>
<div class="a-bar"><span style="width:0%"></span></div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>A — Card grid</h3>
<p>Each project is a card with name, task count, % complete, and a tiny progress bar. 2-up on desktop, 1-up on mobile. Most "modern dashboard" feel.</p>
</div>
</div>
<div class="card" data-choice="b" onclick="toggleSelect(this)">
<div class="card-image">
<div class="preview-page">
<div class="preview-head">
<span class="crumbs">🏠 / Projects</span>
<span>Sort: Name ▾</span>
</div>
<div class="b-row">
<div class="icon">S2</div>
<div class="name">S29824</div>
<div class="barwrap"><span style="width:62%"></span></div>
<div class="stats"><span class="pill green">62%</span><span class="pill blue">50</span></div>
</div>
<div class="b-row">
<div class="icon" style="background:#fef3c7;color:#92400e">CC</div>
<div class="name">Customer Care</div>
<div class="barwrap"><span style="width:0%"></span></div>
<div class="stats"><span class="pill gray">0%</span><span class="pill gray">0</span></div>
</div>
<div class="b-row">
<div class="icon" style="background:#dcfce7;color:#166534">FS</div>
<div class="name">Field Service</div>
<div class="barwrap"><span style="width:0%"></span></div>
<div class="stats"><span class="pill gray">0%</span><span class="pill gray">0</span></div>
</div>
<div class="b-row">
<div class="icon">S2</div>
<div class="name">S29824 - Internal</div>
<div class="barwrap"><span style="width:0%"></span></div>
<div class="stats"><span class="pill amber">0%</span><span class="pill blue">1</span></div>
</div>
</div>
</div>
<div class="card-body">
<h3>B — Enhanced rows</h3>
<p>Same vertical list, but each row gets an avatar/initials chip, inline progress bar, and badge stats. Closest to the current page; quickest to scan with many projects.</p>
</div>
</div>
<div class="card" data-choice="c" onclick="toggleSelect(this)">
<div class="card-image">
<div class="preview-page">
<div class="preview-head">
<span class="crumbs">🏠 / Projects</span>
<span>Group: Status ▾</span>
</div>
<div class="c-cols">
<div class="c-col">
<h4>● Active</h4>
<div class="c-card"><div class="ct">S29824</div><div class="cm">50 tasks · 62%</div></div>
<div class="c-card"><div class="ct">S29824 - Internal</div><div class="cm">1 task · 0%</div></div>
</div>
<div class="c-col">
<h4>● Idle</h4>
<div class="c-card"><div class="ct">Customer Care</div><div class="cm">0 tasks</div></div>
<div class="c-card"><div class="ct">Field Service</div><div class="cm">0 tasks</div></div>
<div class="c-card"><div class="ct">Internal</div><div class="cm">0 tasks</div></div>
</div>
<div class="c-col">
<h4>● Done</h4>
<div style="color:#9ca3af;font-size:10px;text-align:center;padding:16px 0">Nothing here yet</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>C — Status columns (kanban-ish)</h3>
<p>Projects grouped into columns by activity (Active / Idle / Done). Visually striking but unusual for a customer portal where users mainly browse to drill into one project.</p>
</div>
</div>
</div>
<p class="subtitle" style="margin-top:18px">My recommendation: <b>A — Card grid</b>. It's the most "modern" feel you asked for, scales gracefully to a few or many projects, and gives room for the data we already compute (task counts, % done, hours). B is a strong runner-up if you have lots of projects (20+); C feels overengineered for a portal landing page.</p>

View File

@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Implementing in terminal — check back here only if I push another mockup.</p>
</div>

View File

@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1777430092232}

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Project Portal',
'version': '19.0.3.0.0',
'version': '19.0.4.1.1',
'category': 'Project',
'summary': 'Customer portal hierarchy + task creation, plus surfaces logged timesheets on the portal',
'description': """
@@ -17,6 +17,12 @@
'views/portal_templates.xml',
'views/wizard_views.xml',
],
'assets': {
'web.assets_frontend': [
'fusion_project_portal/static/src/scss/portal_projects.scss',
'fusion_project_portal/static/src/js/portal_search_live.js',
],
},
'installable': True,
'application': False,
'license': 'LGPL-3',

View File

@@ -1,3 +1,5 @@
from collections import defaultdict
from odoo import http, _
from odoo.exceptions import AccessError, MissingError
from odoo.http import request
@@ -14,6 +16,176 @@ class FusionProjectCustomerPortal(ProjectCustomerPortal):
}
return values
def _fp_project_card_data(self, projects):
"""Return {project_id: dict} with the rich stats the redesigned card needs.
Aggregations are done in two grouped reads (tasks + timesheets) so the
page stays cheap regardless of how many projects/tasks the user owns.
"""
if not projects:
return {}
Task = request.env['project.task'].sudo()
AAL = request.env['account.analytic.line'].sudo()
done_states = ('1_done', '03_approved')
has_alloc = 'allocated_hours' in Task._fields
has_effective = 'effective_hours' in Task._fields
task_data = defaultdict(lambda: {
'open': 0, 'done': 0, 'total': 0,
'alloc': 0.0, 'last_activity': False,
'assignees': set(),
'task_partners': set(),
})
task_fields = ['project_id', 'state', 'write_date', 'user_ids', 'partner_id']
if has_alloc:
task_fields.append('allocated_hours')
tasks = Task.search_read(
[('project_id', 'in', projects.ids), ('is_template', '=', False)],
task_fields,
)
for t in tasks:
pid = t['project_id'][0] if t.get('project_id') else False
if not pid:
continue
d = task_data[pid]
d['total'] += 1
if t['state'] in done_states:
d['done'] += 1
else:
d['open'] += 1
if has_alloc and t.get('allocated_hours'):
d['alloc'] += t['allocated_hours']
wd = t.get('write_date')
if wd and (not d['last_activity'] or wd > d['last_activity']):
d['last_activity'] = wd
for uid in t.get('user_ids') or []:
d['assignees'].add(uid)
if t.get('partner_id'):
d['task_partners'].add((t['partner_id'][0], t['partner_id'][1]))
# Timesheet hours per project (effective hours come from timesheets).
spent_by_project = defaultdict(float)
if has_effective:
ts_groups = AAL.read_group(
[('project_id', 'in', projects.ids)],
['project_id', 'unit_amount:sum'],
['project_id'],
)
for g in ts_groups:
if g.get('project_id'):
spent_by_project[g['project_id'][0]] = g.get('unit_amount', 0.0) or 0.0
# Resolve assignee names + avatars in one batch.
all_user_ids = set()
for d in task_data.values():
all_user_ids |= d['assignees']
users_by_id = {}
if all_user_ids:
for u in request.env['res.users'].sudo().browse(list(all_user_ids)):
users_by_id[u.id] = {
'id': u.id,
'name': u.name,
'initials': ''.join([p[:1].upper() for p in (u.name or '?').split()[:2]]) or '?',
}
result = {}
for project in projects:
d = task_data.get(project.id, {
'open': 0, 'done': 0, 'total': 0,
'alloc': 0.0, 'last_activity': False,
'assignees': set(), 'task_partners': set(),
})
spent = spent_by_project.get(project.id, 0.0)
pct = round(100.0 * d['done'] / d['total'], 0) if d['total'] else 0
if not project.active:
bucket = 'done'
elif d['open'] > 0:
bucket = 'active'
else:
bucket = 'idle'
assignees = [users_by_id[uid] for uid in d['assignees'] if uid in users_by_id]
assignees.sort(key=lambda u: u['name'])
# Customer label: prefer the project's customer; fall back to task
# customers when the project has none. If multiple distinct task
# customers, show a count rather than picking one arbitrarily.
if project.partner_id:
partner_name = project.partner_id.display_name
elif len(d['task_partners']) == 1:
partner_name = next(iter(d['task_partners']))[1]
elif len(d['task_partners']) > 1:
partner_name = _('%s customers') % len(d['task_partners'])
else:
partner_name = ''
result[project.id] = {
'open_count': d['open'],
'done_count': d['done'],
'total_count': d['total'],
'pct': pct,
'alloc_hours': d['alloc'],
'spent_hours': spent,
'last_activity': d['last_activity'] or project.write_date,
'assignees': assignees,
'bucket': bucket,
'partner_name': partner_name,
}
return result
def _prepare_searchbar_sortings(self):
sortings = super()._prepare_searchbar_sortings()
# Add an "activity" SQL sort. We don't add task_count / pct here because
# those are non-stored compute fields and would break the SQL ORDER BY.
# The redesigned page sorts those client-side.
sortings['activity'] = {'label': _('Last Activity'), 'order': 'write_date desc'}
return sortings
@http.route(['/my/projects', '/my/projects/page/<int:page>'],
type='http', auth='user', website=True)
def portal_my_projects(self, page=1, date_begin=None, date_end=None, sortby=None, **kw):
from odoo.addons.portal.controllers.portal import pager as portal_pager
Project = request.env['project.project']
values = self._prepare_portal_layout_values()
domain = self._prepare_project_domain()
searchbar_sortings = self._prepare_searchbar_sortings()
if not sortby or sortby not in searchbar_sortings:
sortby = 'name'
order = searchbar_sortings[sortby]['order']
if date_begin and date_end:
domain += [('create_date', '>', date_begin), ('create_date', '<=', date_end)]
project_count = Project.search_count(domain)
# Bigger page size than core's default — the redesign is meant to scan
# everything you have at a glance, not paginate two-at-a-time.
per_page = max(self._items_per_page, 40)
pager = portal_pager(
url='/my/projects',
url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby},
total=project_count, page=page, step=per_page,
)
projects = Project.search(domain, order=order, limit=per_page, offset=pager['offset'])
request.session['my_projects_history'] = projects.ids[:100]
values.update({
'date': date_begin,
'date_end': date_end,
'projects': projects,
'page_name': 'project',
'default_url': '/my/projects',
'pager': pager,
'searchbar_sortings': searchbar_sortings,
'sortby': sortby,
'fp_project_cards': self._fp_project_card_data(projects),
'fp_groupby': (kw.get('groupby') or 'status').lower(),
})
return request.render('fusion_project_portal.portal_my_projects', values)
def _project_get_page_view_values(self, project, access_token, page=1, date_begin=None,
date_end=None, sortby=None, search=None,
search_in='content', groupby=None, **kwargs):
@@ -81,10 +253,37 @@ class FusionProjectCustomerPortal(ProjectCustomerPortal):
walk(c.id, depth + 1)
walk(task.id, 0)
done_states = ('1_done', '03_approved')
total = len(descendants)
done = sum(1 for d in descendants if d.state in done_states)
allocated_field = 'allocated_hours' if 'allocated_hours' in Task._fields else (
'planned_hours' if 'planned_hours' in Task._fields else None
)
has_effective = 'effective_hours' in Task._fields
alloc_by_id = {}
spent_by_id = {}
for d in descendants:
alloc_by_id[d.id] = float(getattr(d, allocated_field, 0.0) or 0.0) if allocated_field else 0.0
spent_by_id[d.id] = float(d.effective_hours or 0.0) if has_effective else 0.0
total_alloc = sum(alloc_by_id.values())
total_spent = sum(spent_by_id.values())
values['fp_task_descendants'] = descendants
values['fp_task_depth_inside'] = depth_map
values['fp_can_create_task'] = self._fp_can_create_task(task.project_id)
values['fp_can_change_state'] = self._fp_can_change_state(task)
values['fp_priority_options'] = request.env['project.task']._fields['priority']._description_selection(request.env)
values['fp_done_states'] = done_states
values['fp_descendant_total'] = total
values['fp_descendant_done'] = done
values['fp_descendant_pct'] = round(100.0 * done / total, 1) if total else 0.0
values['fp_descendant_alloc_hours'] = total_alloc
values['fp_descendant_spent_hours'] = total_spent
values['fp_alloc_by_id'] = alloc_by_id
values['fp_spent_by_id'] = spent_by_id
timesheets = request.env['account.analytic.line'].sudo().search(
[('task_id', '=', task.id)],
order='date desc, id desc',
@@ -119,6 +318,43 @@ class FusionProjectCustomerPortal(ProjectCustomerPortal):
], limit=1)
return bool(follower)
def _fp_can_change_state(self, task):
"""Permissive gate for the customer status actions on a task page.
A portal user who can VIEW the task should be able to flip its state
(Request Changes / Approve / Mark Done). _fp_can_create_task is too
strict — it only passes the project's customer or a project follower.
Customers are often added to a single task as the task partner or as a
task follower, never on the project.
"""
user = request.env.user
if not task or not task.exists():
return False
if not user or user.id == request.env.ref('base.public_user').id:
return False
if user._is_internal():
return True
# Project-level checks (covers most customers)
if self._fp_can_create_task(task.project_id):
return True
partner = user.partner_id
if not partner:
return False
# Task-level partner / commercial-partner family
if task.partner_id and (
partner == task.partner_id
or partner.parent_id == task.partner_id
or partner.commercial_partner_id == task.partner_id.commercial_partner_id
):
return True
# Task followers
follower = request.env['mail.followers'].sudo().search_count([
('res_model', '=', 'project.task'),
('res_id', '=', task.id),
('partner_id', '=', partner.id),
], limit=1)
return bool(follower)
@http.route(
['/my/projects/<int:project_id>/task/<int:task_id>/state'],
type='http', auth='user', website=True, methods=['POST'],
@@ -130,9 +366,6 @@ class FusionProjectCustomerPortal(ProjectCustomerPortal):
except (AccessError, MissingError):
return request.redirect('/my')
if not self._fp_can_create_task(project_sudo):
return request.redirect(f'/my/projects/{project_id}/task/{task_id}')
task = request.env['project.task'].sudo().search([
('id', '=', task_id),
('project_id', '=', project_id),
@@ -140,6 +373,9 @@ class FusionProjectCustomerPortal(ProjectCustomerPortal):
if not task:
return request.redirect(f'/my/projects/{project_id}')
if not self._fp_can_change_state(task):
return request.redirect(f'/my/projects/{project_id}/task/{task_id}')
new_state = post.get('state')
allowed = ['01_in_progress', '02_changes_requested', '03_approved', '1_done']
if new_state not in allowed:

View File

@@ -0,0 +1,392 @@
/** @odoo-module **/
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
export class PortalSearchLive extends Interaction {
static selector = ".o_portal_wrap";
dynamicContent = {
".fp_subtask_search": {
"t-on-input": this.debounced(this.onSubtaskInput, 80),
"t-on-keydown": this.onSubtaskKeydown,
},
".o_portal_search_panel input[name='search']": {
"t-on-input": this.debounced(this.onGlobalInput, 120),
},
};
setup() {
this._lastQ = new WeakMap();
}
start() {
// Apply once on load (handles server-rendered ?search=... and the empty initial state)
for (const input of this.el.querySelectorAll(".fp_subtask_search")) {
this.filterCard(input);
}
const g = this.el.querySelector(".o_portal_search_panel input[name='search']");
if (g && g.value) {
this.filterTables(g);
}
}
onSubtaskInput(ev) {
this.filterCard(ev.target);
}
onSubtaskKeydown(ev) {
if (ev.key === "Enter") {
ev.preventDefault();
this.filterCard(ev.target);
} else if (ev.key === "Escape") {
ev.target.value = "";
this.filterCard(ev.target);
}
}
onGlobalInput(ev) {
this.filterTables(ev.target);
}
filterCard(input) {
const card = input.closest(".card");
if (!card) {
return;
}
const list = card.querySelector("ul.list-group");
if (!list) {
return;
}
const q = (input.value || "").trim().toLowerCase();
if (this._lastQ.get(input) === q) {
return;
}
this._lastQ.set(input, q);
let visible = 0;
for (const li of list.children) {
if (li.querySelector("form")) {
// Skip inline "+ subtask" forms; show/hide them with their parent row.
continue;
}
if (!q) {
li.style.display = "";
li.classList.remove("d-none");
visible += 1;
continue;
}
const text = (li.textContent || "").toLowerCase();
const match = text.indexOf(q) !== -1;
li.style.display = match ? "" : "none";
li.classList.toggle("d-none", !match);
if (match) {
visible += 1;
}
}
// Toggle a "no matches" empty hint
let hint = card.querySelector(".fp_subtask_no_match");
if (q && visible === 0) {
if (!hint) {
hint = document.createElement("div");
hint.className = "fp_subtask_no_match card-body text-muted small";
hint.textContent = "No sub-tasks match your search.";
list.insertAdjacentElement("afterend", hint);
}
hint.style.display = "";
} else if (hint) {
hint.style.display = "none";
}
}
filterTables(input) {
const q = (input.value || "").trim().toLowerCase();
const tables = this.el.querySelectorAll("table.table, table.o_portal_my_doc_table");
tables.forEach((table) => {
table.querySelectorAll("tbody").forEach((tbody) => {
let visibleTaskRows = 0;
let groupHeaderRow = null;
tbody.querySelectorAll("tr").forEach((row) => {
const isGroupHeader =
row.getAttribute("name") === "grouped_tasks_groupby_columns" ||
row.classList.contains("table-light");
if (isGroupHeader) {
if (groupHeaderRow !== null) {
groupHeaderRow.style.display = visibleTaskRows ? "" : "none";
}
groupHeaderRow = row;
visibleTaskRows = 0;
return;
}
if (!q) {
row.style.display = "";
visibleTaskRows += 1;
return;
}
const text = (row.textContent || "").toLowerCase();
const match = text.indexOf(q) !== -1;
row.style.display = match ? "" : "none";
if (match) {
visibleTaskRows += 1;
}
});
if (groupHeaderRow !== null) {
groupHeaderRow.style.display = visibleTaskRows ? "" : "none";
}
});
});
}
}
registry.category("public.interactions").add("fusion_project_portal.portal_search_live", PortalSearchLive);
// ---------------------------------------------------------------------------
// /my/projects: client-side search + sort + group on the redesigned card grid.
// All projects are server-rendered into the page; this just toggles visibility
// and re-orders existing DOM nodes — no RPC.
// ---------------------------------------------------------------------------
const SORT_KEYS = {
name: (el) => (el.dataset.name || "").toLowerCase(),
pct: (el) => -parseFloat(el.dataset.pct || "0"),
tasks: (el) => -parseInt(el.dataset.tasks || "0", 10),
activity: (el) => {
const v = el.dataset.activity || "";
// sort newest first; missing values go last
return v ? `0${v.split("").reverse().join("")}` : "z";
},
};
const SORT_LABELS = {
name: "Name",
pct: "% Complete",
tasks: "Task Count",
activity: "Last Activity",
};
export class FusionProjectsPage extends Interaction {
static selector = ".fp_projects_page";
dynamicContent = {
".fp_projects_search": {
"t-on-input": this.debounced(this.onSearchInput, 80),
"t-on-keydown": this.onSearchKeydown,
},
".fp_projects_group_picker .btn": {
"t-on-click.prevent.withTarget": this.onGroupClick,
},
".fp_projects_sort_picker .dropdown-item": {
"t-on-click.prevent.withTarget": this.onSortClick,
},
};
setup() {
this._sortKey = "name";
this._group = this.el.dataset.defaultGroup || "status";
this._cards = Array.from(this.el.querySelectorAll(".fp_project_card"));
}
start() {
this._applyGroup(this._group, /*initial=*/ true);
this._applySort();
}
onSearchInput(ev) {
this._applyFilter((ev.target.value || "").trim().toLowerCase());
}
onSearchKeydown(ev) {
if (ev.key === "Escape") {
ev.preventDefault();
ev.target.value = "";
this._applyFilter("");
}
}
onGroupClick(ev, currentTargetEl) {
const group = currentTargetEl.dataset.group;
if (!group || group === this._group) {
return;
}
this.el.querySelectorAll(".fp_projects_group_picker .btn").forEach((b) =>
b.classList.toggle("active", b === currentTargetEl)
);
this._applyGroup(group);
this._applySort();
// Re-apply the active search after re-grouping
const searchEl = this.el.querySelector(".fp_projects_search");
this._applyFilter((searchEl && searchEl.value || "").trim().toLowerCase());
}
onSortClick(ev, currentTargetEl) {
const key = currentTargetEl.dataset.sort;
if (!key || !(key in SORT_KEYS)) {
return;
}
this._sortKey = key;
const labelEl = this.el.querySelector(".fp_sort_label");
if (labelEl) {
labelEl.textContent = SORT_LABELS[key];
}
this.el.querySelectorAll(".fp_projects_sort_picker .dropdown-item").forEach((item) =>
item.classList.toggle("active", item === currentTargetEl)
);
this._applySort();
}
_applyFilter(q) {
let visibleTotal = 0;
const perColumn = new Map();
for (const card of this._cards) {
const matches =
!q ||
(card.dataset.name || "").toLowerCase().includes(q) ||
(card.dataset.customer || "").toLowerCase().includes(q);
card.classList.toggle("d-none", !matches);
if (matches) {
visibleTotal += 1;
const col = card.closest(".fp_projects_col");
const key = col ? (col.dataset.bucket || col.dataset.group || "") : "";
perColumn.set(key, (perColumn.get(key) || 0) + 1);
}
}
// Update column counts and toggle column-empty fallback
for (const col of this.el.querySelectorAll(".fp_projects_col")) {
const key = col.dataset.bucket || col.dataset.group || "";
const n = perColumn.get(key) || 0;
const countEl = col.querySelector(".fp_col_count");
if (countEl) {
countEl.textContent = String(n);
}
const empty = col.querySelector(".fp_col_empty");
if (empty) {
empty.classList.toggle("d-none", n > 0);
}
}
const noMatch = this.el.querySelector(".fp_projects_no_match");
if (noMatch) {
noMatch.classList.toggle("d-none", visibleTotal !== 0 || !q);
}
}
_applyGroup(group, initial = false) {
this._group = group;
this.el.dataset.currentGroup = group;
const cols = this.el.querySelector(".fp_projects_cols");
if (!cols) {
return;
}
if (group === "status") {
// Restore the original 3 status columns rendered server-side
this._restoreStatusColumns(cols);
return;
}
if (group === "none") {
this._renderFlat(cols);
return;
}
if (group === "customer") {
this._renderByCustomer(cols);
return;
}
}
_restoreStatusColumns(cols) {
// Cache the original markup once so we can flip back without RPC
if (!this._originalCols) {
this._originalCols = cols.innerHTML;
} else {
cols.innerHTML = this._originalCols;
this._cards = Array.from(this.el.querySelectorAll(".fp_project_card"));
}
}
_renderFlat(cols) {
if (!this._originalCols) {
this._originalCols = cols.innerHTML;
}
const cards = Array.from(this.el.querySelectorAll(".fp_project_card"));
cols.innerHTML = "";
const col = this._buildColumn("All projects", "gray", cards.length);
col.dataset.group = "all";
const body = col.querySelector(".fp_projects_col_body");
for (const card of cards) {
body.appendChild(card);
}
cols.appendChild(col);
this._cards = cards;
}
_renderByCustomer(cols) {
if (!this._originalCols) {
this._originalCols = cols.innerHTML;
}
const cards = Array.from(this.el.querySelectorAll(".fp_project_card"));
const groups = new Map();
for (const card of cards) {
const key = (card.dataset.customer || "Unassigned").trim() || "Unassigned";
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key).push(card);
}
const orderedKeys = Array.from(groups.keys()).sort((a, b) => {
if (a === "Unassigned") return 1;
if (b === "Unassigned") return -1;
return a.localeCompare(b);
});
cols.innerHTML = "";
for (const key of orderedKeys) {
const items = groups.get(key);
const col = this._buildColumn(key, "gray", items.length);
col.dataset.group = key;
const body = col.querySelector(".fp_projects_col_body");
for (const card of items) {
body.appendChild(card);
}
cols.appendChild(col);
}
this._cards = cards;
}
_buildColumn(label, dot, count) {
const col = document.createElement("div");
col.className = "fp_projects_col";
col.innerHTML = `
<div class="fp_projects_col_head">
<span class="fp_col_label"><span class="fp_dot fp_dot_${dot}"></span>${this._escape(label)}</span>
<span class="fp_col_count">${count}</span>
</div>
<div class="fp_projects_col_body"></div>
`;
return col;
}
_escape(s) {
const div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
}
_applySort() {
const fn = SORT_KEYS[this._sortKey] || SORT_KEYS.name;
for (const col of this.el.querySelectorAll(".fp_projects_col")) {
const body = col.querySelector(".fp_projects_col_body");
if (!body) continue;
const cards = Array.from(body.querySelectorAll(".fp_project_card"));
cards.sort((a, b) => {
const av = fn(a);
const bv = fn(b);
if (av < bv) return -1;
if (av > bv) return 1;
return 0;
});
for (const c of cards) {
body.appendChild(c);
}
}
}
}
registry.category("public.interactions").add("fusion_project_portal.projects_page", FusionProjectsPage);

View File

@@ -0,0 +1,263 @@
// Fusion portal: redesigned /my/projects page
// Tokens are wrapped in CSS custom properties so a future portal dark-mode
// pass can flip the surface colors without rewriting any rules.
$fp-page-bg: var(--fp-page-bg, #f3f4f6);
$fp-card-bg: var(--fp-card-bg, #ffffff);
$fp-border: var(--fp-border, #d8dadd);
$fp-text: var(--fp-text, #0f172a);
$fp-muted: var(--fp-muted, #6b7280);
$fp-track: var(--fp-track, #e5e7eb);
$fp-blue-bg: var(--fp-blue-bg, #dbeafe);
$fp-blue-fg: var(--fp-blue-fg, #1d4ed8);
$fp-green-bg: var(--fp-green-bg, #dcfce7);
$fp-green-fg: var(--fp-green-fg, #166534);
$fp-amber-bg: var(--fp-amber-bg, #fef3c7);
$fp-amber-fg: var(--fp-amber-fg, #92400e);
$fp-cyan-bg: var(--fp-cyan-bg, #cffafe);
$fp-cyan-fg: var(--fp-cyan-fg, #155e75);
$fp-gray-bg: var(--fp-gray-bg, #e5e7eb);
$fp-gray-fg: var(--fp-gray-fg, #374151);
$fp-bar-high: var(--fp-bar-high, #22c55e);
$fp-bar-mid: var(--fp-bar-mid, #3b82f6);
$fp-bar-low: var(--fp-bar-low, #f59e0b);
$fp-radius: 10px;
$fp-radius-sm: 6px;
.fp_projects_page {
.fp_projects_header {
h3 {
color: $fp-text;
}
.fp_projects_search_wrap {
min-width: 220px;
max-width: 280px;
}
}
// 3-column status grid
.fp_projects_cols {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
@media (max-width: 992px) {
grid-template-columns: 1fr;
gap: 18px;
}
}
// Flat / customer-grouped variants reuse a single column
&[data-current-group="none"] .fp_projects_cols,
&[data-current-group="customer"] .fp_projects_cols {
grid-template-columns: 1fr;
}
.fp_projects_col {
min-width: 0;
.fp_projects_col_head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 4px 8px;
border-bottom: 2px solid $fp-track;
margin-bottom: 10px;
.fp_col_label {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: $fp-gray-fg;
}
.fp_col_count {
font-size: 11px;
font-weight: 700;
color: $fp-muted;
background: $fp-gray-bg;
padding: 2px 8px;
border-radius: 999px;
}
}
.fp_projects_col_body {
display: flex;
flex-direction: column;
gap: 8px;
}
.fp_col_empty {
color: #9ca3af;
font-size: 12px;
text-align: center;
padding: 22px 8px;
background: $fp-card-bg;
border: 1px dashed $fp-border;
border-radius: $fp-radius;
}
}
.fp_dot {
width: 9px;
height: 9px;
border-radius: 999px;
display: inline-block;
&.fp_dot_green { background: #22c55e; }
&.fp_dot_amber { background: #f59e0b; }
&.fp_dot_gray { background: #9ca3af; }
}
// Card
.fp_project_card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 13px 11px;
background: $fp-card-bg;
border: 1px solid $fp-border;
border-radius: $fp-radius;
text-decoration: none;
color: $fp-text;
transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
&:hover {
border-color: #93c5fd;
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.10);
transform: translateY(-1px);
text-decoration: none;
color: $fp-text;
}
.fp_card_top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
.fp_card_title {
font-weight: 600;
font-size: 14px;
line-height: 1.25;
color: $fp-text;
word-break: break-word;
}
.fp_card_pct {
font-weight: 700;
font-size: 12px;
white-space: nowrap;
&.fp_pct_high { color: #15803d; }
&.fp_pct_mid { color: #1d4ed8; }
&.fp_pct_low { color: #b45309; }
&.fp_card_pct_muted { color: #9ca3af; font-weight: 500; }
}
}
.fp_card_sub {
font-size: 11px;
color: $fp-muted;
min-height: 1em;
}
.fp_card_bar {
height: 5px;
background: $fp-track;
border-radius: 999px;
overflow: hidden;
> span {
display: block;
height: 100%;
background: $fp-bar-mid;
transition: width 200ms ease;
}
&.fp_bar_high > span { background: $fp-bar-high; }
&.fp_bar_mid > span { background: $fp-bar-mid; }
&.fp_bar_low > span { background: $fp-bar-low; }
}
.fp_card_stats {
display: flex;
flex-wrap: wrap;
gap: 5px;
align-items: center;
.fp_chip {
display: inline-flex;
align-items: center;
font-size: 10.5px;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
line-height: 1.4;
white-space: nowrap;
&.fp_chip_blue { background: $fp-blue-bg; color: $fp-blue-fg; }
&.fp_chip_green { background: $fp-green-bg; color: $fp-green-fg; }
&.fp_chip_amber { background: $fp-amber-bg; color: $fp-amber-fg; }
&.fp_chip_cyan { background: $fp-cyan-bg; color: $fp-cyan-fg; }
&.fp_chip_gray { background: $fp-gray-bg; color: $fp-gray-fg; }
}
}
.fp_card_footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 2px;
.fp_avatars {
display: inline-flex;
.fp_avatar {
position: relative;
width: 22px;
height: 22px;
border-radius: 999px;
overflow: hidden;
background: #e0e7ff;
color: #3730a3;
font-size: 9.5px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: -6px;
border: 2px solid $fp-card-bg;
&:first-child { margin-left: 0; }
img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.fp_avatar_text {
// hidden when image loads; shown when image fails (handled in JS via classList)
display: none;
}
&.fp_avatar_initials .fp_avatar_text { display: inline; }
&.fp_avatar_initials img { display: none; }
&.fp_avatar_more {
background: $fp-gray-bg;
color: $fp-gray-fg;
}
}
}
}
}
// Empty page state
.fp_projects_empty {
background: $fp-card-bg;
border: 1px dashed $fp-border;
border-radius: $fp-radius;
}
}

View File

@@ -1,13 +1,194 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Redesigned /my/projects page: status columns + rich cards -->
<template id="portal_my_projects" name="Fusion: My Projects (cards)">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<t t-set="_active_ids" t-value="[p.id for p in projects if (fp_project_cards or {}).get(p.id, {}).get('bucket') == 'active']"/>
<t t-set="_idle_ids" t-value="[p.id for p in projects if (fp_project_cards or {}).get(p.id, {}).get('bucket') == 'idle']"/>
<t t-set="_done_ids" t-value="[p.id for p in projects if (fp_project_cards or {}).get(p.id, {}).get('bucket') == 'done']"/>
<div class="fp_projects_page" t-att-data-default-group="fp_groupby or 'status'">
<!-- Page header / control bar -->
<div class="fp_projects_header d-flex flex-wrap align-items-center gap-2 mb-3">
<h3 class="mb-0 me-2">
<i class="fa fa-folder-open-o me-2"/>Projects
<span class="badge bg-secondary ms-2"><t t-esc="len(projects)"/></span>
</h3>
<div class="ms-auto d-flex flex-wrap align-items-center gap-2">
<div class="input-group input-group-sm fp_projects_search_wrap">
<span class="input-group-text bg-white"><i class="fa fa-search"/></span>
<input type="search"
class="form-control form-control-sm fp_projects_search"
placeholder="Search projects..."/>
</div>
<div class="btn-group btn-group-sm fp_projects_group_picker" role="group" aria-label="Group by">
<button type="button" class="btn btn-outline-secondary active" data-group="status">By Status</button>
<button type="button" class="btn btn-outline-secondary" data-group="customer">By Customer</button>
<button type="button" class="btn btn-outline-secondary" data-group="none">Flat</button>
</div>
<div class="dropdown fp_projects_sort_picker">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Sort: <span class="fp_sort_label">Name</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item active" href="#" data-sort="name">Name</a></li>
<li><a class="dropdown-item" href="#" data-sort="pct">% Complete</a></li>
<li><a class="dropdown-item" href="#" data-sort="tasks">Task Count</a></li>
<li><a class="dropdown-item" href="#" data-sort="activity">Last Activity</a></li>
</ul>
</div>
</div>
</div>
<!-- Empty: no projects at all -->
<t t-if="not projects">
<div class="fp_projects_empty text-center py-5">
<div class="fp_empty_glyph mb-3"><i class="fa fa-folder-open-o fa-3x text-muted"/></div>
<h5 class="text-muted mb-1">You don't have any projects yet</h5>
<p class="text-muted small mb-0">Once a project is shared with you, it will appear here.</p>
</div>
</t>
<!-- Columns + cards -->
<t t-if="projects">
<div class="fp_projects_cols">
<t t-call="fusion_project_portal.fp_project_column">
<t t-set="bucket_key" t-value="'active'"/>
<t t-set="bucket_label" t-value="'Active'"/>
<t t-set="bucket_dot" t-value="'green'"/>
<t t-set="bucket_empty" t-value="'No active projects.'"/>
<t t-set="bucket_ids" t-value="_active_ids"/>
</t>
<t t-call="fusion_project_portal.fp_project_column">
<t t-set="bucket_key" t-value="'idle'"/>
<t t-set="bucket_label" t-value="'Idle'"/>
<t t-set="bucket_dot" t-value="'amber'"/>
<t t-set="bucket_empty" t-value="'No idle projects.'"/>
<t t-set="bucket_ids" t-value="_idle_ids"/>
</t>
<t t-call="fusion_project_portal.fp_project_column">
<t t-set="bucket_key" t-value="'done'"/>
<t t-set="bucket_label" t-value="'Done'"/>
<t t-set="bucket_dot" t-value="'gray'"/>
<t t-set="bucket_empty" t-value="'Completed projects will appear here.'"/>
<t t-set="bucket_ids" t-value="_done_ids"/>
</t>
</div>
<div class="fp_projects_no_match d-none text-center py-4 text-muted small">
No projects match your search.
</div>
</t>
<div t-if="pager and pager.get('page_count', 1) > 1" class="mt-3">
<t t-call="portal.pager"/>
</div>
</div>
</t>
</template>
<!-- Single column with its cards -->
<template id="fp_project_column" name="Fusion: project status column">
<div class="fp_projects_col" t-att-data-bucket="bucket_key">
<div class="fp_projects_col_head">
<span class="fp_col_label">
<span t-attf-class="fp_dot fp_dot_{{ bucket_dot }}"/>
<t t-esc="bucket_label"/>
</span>
<span class="fp_col_count" t-esc="len(bucket_ids or [])"/>
</div>
<div class="fp_projects_col_body">
<t t-if="not bucket_ids">
<div class="fp_col_empty"><t t-esc="bucket_empty"/></div>
</t>
<t t-foreach="projects" t-as="project">
<t t-if="project.id in (bucket_ids or [])">
<t t-call="fusion_project_portal.fp_project_card"/>
</t>
</t>
</div>
</div>
</template>
<!-- Rich project card (used inside columns and flat list) -->
<template id="fp_project_card" name="Fusion: project card">
<t t-set="_d" t-value="(fp_project_cards or {}).get(project.id, {})"/>
<t t-set="_pct" t-value="_d.get('pct') or 0"/>
<t t-set="_bucket" t-value="_d.get('bucket') or 'idle'"/>
<t t-set="_total" t-value="_d.get('total_count') or 0"/>
<t t-set="_done" t-value="_d.get('done_count') or 0"/>
<t t-set="_open" t-value="_d.get('open_count') or 0"/>
<t t-set="_alloc" t-value="_d.get('alloc_hours') or 0.0"/>
<t t-set="_spent" t-value="_d.get('spent_hours') or 0.0"/>
<t t-set="_assignees" t-value="_d.get('assignees') or []"/>
<t t-set="_customer" t-value="_d.get('partner_name') or ''"/>
<t t-set="_last" t-value="_d.get('last_activity')"/>
<a t-attf-href="/my/projects/{{ project.id }}"
class="fp_project_card"
t-att-data-bucket="_bucket"
t-att-data-name="project.name"
t-att-data-customer="_customer"
t-att-data-pct="_pct"
t-att-data-tasks="_total"
t-att-data-activity="(_last and _last.isoformat()) or ''">
<div class="fp_card_top">
<span class="fp_card_title"><t t-esc="project.name"/></span>
<span t-if="_pct" t-attf-class="fp_card_pct fp_pct_{{ 'high' if _pct &gt;= 80 else ('mid' if _pct &gt;= 40 else 'low') }}">
<t t-esc="str(int(_pct)) + '%'"/>
</span>
<span t-if="not _pct" class="fp_card_pct fp_card_pct_muted"></span>
</div>
<div class="fp_card_sub">
<t t-if="_customer"><i class="fa fa-user-o me-1 text-muted"/><t t-esc="_customer"/></t>
<t t-else=""><span class="text-muted">No customer set</span></t>
</div>
<div t-attf-class="fp_card_bar fp_bar_{{ 'high' if _pct &gt;= 80 else ('mid' if _pct &gt;= 40 else 'low') }}">
<span t-attf-style="width: {{ _pct }}%;"/>
</div>
<div class="fp_card_stats">
<span t-if="_total" class="fp_chip fp_chip_blue">
<i class="fa fa-list-ul me-1"/><t t-esc="_total"/> task<t t-if="_total != 1">s</t>
</span>
<span t-if="not _total" class="fp_chip fp_chip_gray">No tasks</span>
<span t-if="_done" class="fp_chip fp_chip_green">
<i class="fa fa-check me-1"/><t t-esc="_done"/> done
</span>
<span t-if="_alloc or _spent" class="fp_chip fp_chip_cyan ms-auto">
<i class="fa fa-clock-o me-1"/>
<t t-esc="'%.1fh' % _spent"/>
<t t-if="_alloc"> / <t t-esc="'%.1fh' % _alloc"/></t>
</span>
</div>
<div class="fp_card_footer">
<div class="fp_avatars">
<t t-foreach="_assignees[:4]" t-as="u">
<span class="fp_avatar" t-att-title="u['name']">
<img t-attf-src="/web/image/res.users/{{ u['id'] }}/avatar_128"
t-att-alt="u['name']"
onerror="this.style.display='none';this.parentElement.classList.add('fp_avatar_initials')"/>
<span class="fp_avatar_text"><t t-esc="u['initials']"/></span>
</span>
</t>
<span t-if="len(_assignees) &gt; 4" class="fp_avatar fp_avatar_more">+<t t-esc="len(_assignees) - 4"/></span>
</div>
<span t-if="_last" class="fp_card_activity small text-muted">
<i class="fa fa-history me-1"/><t t-esc="_last.strftime('%b %-d')" t-translation="off"/>
</span>
</div>
</a>
</template>
<!-- Status actions: Request Changes / Approve / Mark Done from the portal task page -->
<template id="portal_my_task_inherit_state_actions"
inherit_id="project.portal_my_task"
name="Fusion: Status actions on portal task page"
priority="55">
<xpath expr="//div[@id='task_chat']" position="before">
<div t-if="fp_can_create_task" class="card mt-4 mb-4 fp_state_card">
<div t-if="fp_can_change_state" class="card mt-4 mb-4 fp_state_card">
<div class="card-header bg-light d-flex align-items-center">
<h5 class="mb-0"><i class="fa fa-flag me-2"/> Status</h5>
<span class="ms-auto badge"
@@ -163,16 +344,35 @@
name="Fusion: Sub-tasks list inside parent task page">
<xpath expr="//div[@id='task_chat']" position="before">
<div class="card mt-4 mb-4 fp_subtasks_card">
<div class="card-header bg-light d-flex align-items-center">
<div class="card-header bg-light d-flex align-items-center flex-wrap gap-2">
<h5 class="mb-0">
<i class="fa fa-sitemap me-2"/>
Sub-tasks
<span class="badge bg-secondary ms-2">
<t t-esc="len(fp_task_descendants or [])"/>
</span>
<span t-if="fp_descendant_total" class="badge bg-success ms-2"
t-attf-title="{{ fp_descendant_done }} of {{ fp_descendant_total }} sub-tasks done">
<i class="fa fa-check-circle me-1"/>
<t t-esc="'%.0f' % (fp_descendant_pct or 0)"/>%
</span>
<span t-if="fp_descendant_alloc_hours or fp_descendant_spent_hours"
class="badge bg-info text-dark ms-2"
title="Spent / Allocated hours across sub-tasks">
<i class="fa fa-clock-o me-1"/>
<t t-esc="'%.1fh' % (fp_descendant_spent_hours or 0)"/>
<span t-if="fp_descendant_alloc_hours">
/ <t t-esc="'%.1fh' % fp_descendant_alloc_hours"/>
</span>
</span>
</h5>
<input t-if="fp_task_descendants"
type="search"
class="form-control form-control-sm fp_subtask_search ms-auto"
style="max-width: 260px;"
placeholder="Search sub-tasks..."/>
<button t-if="fp_can_create_task"
class="btn btn-sm btn-primary ms-auto"
class="btn btn-sm btn-primary"
type="button"
data-bs-toggle="collapse"
t-attf-data-bs-target="#fp_form_root_{{ task.id }}"
@@ -196,7 +396,7 @@
<span t-attf-style="display:inline-block;width:{{ _d * 1.4 }}rem;"/>
<i t-if="_d" class="fa fa-level-up fa-rotate-90 me-2 text-muted small"/>
<a t-attf-href="/my/projects/{{ task.project_id.id }}/task/{{ sub.id }}"
class="flex-grow-1">
t-attf-class="flex-grow-1 #{'text-decoration-line-through text-muted' if sub.state in (fp_done_states or ()) else ''}">
<span t-esc="sub.name"/>
</a>
<button t-if="fp_can_create_task"
@@ -208,7 +408,23 @@
title="Add sub-task here">
<i class="fa fa-plus"/>
</button>
<span t-if="sub.stage_id" class="badge bg-light text-dark border ms-2">
<t t-set="_fp_alloc" t-value="(fp_alloc_by_id or {}).get(sub.id, 0.0)"/>
<t t-set="_fp_spent" t-value="(fp_spent_by_id or {}).get(sub.id, 0.0)"/>
<span t-if="_fp_alloc or _fp_spent" class="badge bg-info text-dark ms-2 small"
title="Spent / Allocated">
<i class="fa fa-clock-o me-1"/>
<t t-esc="'%.1fh' % (_fp_spent or 0)"/>
<t t-if="_fp_alloc"> / <t t-esc="'%.1fh' % _fp_alloc"/></t>
</span>
<span t-if="sub.state in (fp_done_states or ())"
class="badge bg-success ms-2">
<i class="fa fa-check me-1"/> Done
</span>
<span t-elif="sub.state == '02_changes_requested'"
class="badge bg-warning text-dark ms-2">
<i class="fa fa-exclamation-circle me-1"/> Changes
</span>
<span t-elif="sub.stage_id" class="badge bg-light text-dark border ms-2">
<t t-esc="sub.stage_id.name"/>
</span>
</li>