Author SHA1 Message Date
cedrick2711 f468217745 progression 50% pour evolution des joueurs
CI - Security, Lint & Tests / validate (push) Failing after 1m13s
2026-09-05 11:30:13 -04:00
cedrick2711 cf551867c9 changement d'un traduction pour la légende des disponibilité qui est ambigu
CI - Security, Lint & Tests / validate (push) Failing after 1m12s
2026-09-01 14:55:57 -04:00
23 changed files with 2237 additions and 10 deletions
+2
View File
@@ -412,6 +412,7 @@ def create_app(config=None):
from app.routes.tryouts import tryouts_bp
from app.routes.users import users_bp
from app.routes.admin import admin_bp
from app.routes.objectives import objectives_bp
app.register_blueprint(auth_bp)
app.register_blueprint(tryouts_bp)
@@ -422,6 +423,7 @@ def create_app(config=None):
app.register_blueprint(matches_bp)
app.register_blueprint(team_matches_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(objectives_bp)
# Register custom Jinja filters
app.jinja_env.filters['nl2br'] = nl2br
+15 -6
View File
@@ -415,14 +415,23 @@ def start() -> str:
# 3. Create the application database (idempotent).
_create_database()
# 4. Register cleanup.
# 4. Register cleanup (only in the *parent* process, not the reloader
# worker — the worker registers its own and tears the database down
# behind any sibling process that is still connected).
#
# The Werkzeug reloader sets WERKZEUG_RUN_MAIN=true in the worker
# process and leaves it unset (or false) in the watcher process that
# acts as the parent. We want the cleanup on the *actual process
# lifecycle*, i.e. when the watcher exits, not every time the
# reloader restarts the worker.
if not _started:
_started = True
atexit.register(_stop)
with contextlib.suppress(ValueError, OSError):
signal.signal(signal.SIGINT, _stop)
with contextlib.suppress(AttributeError, ValueError, OSError):
signal.signal(signal.SIGBREAK, _stop) # type: ignore[attr-defined]
if os.environ.get('WERKZEUG_RUN_MAIN', '').lower() != 'true':
atexit.register(_stop)
with contextlib.suppress(ValueError, OSError):
signal.signal(signal.SIGINT, _stop)
with contextlib.suppress(AttributeError, ValueError, OSError):
signal.signal(signal.SIGBREAK, _stop) # type: ignore[attr-defined]
logger.info('Local PostgreSQL ready — DATABASE_URL=%s', DATABASE_URL)
return DATABASE_URL
+21 -1
View File
@@ -21,6 +21,11 @@ from app.models._constants import (
PLATFORM_CODES,
TRN_URLS,
EVALUATION_CRITERIA,
OBJECTIVE_TYPES,
OBJECTIVE_STATUSES,
OBJECTIVE_PRIORITIES,
OBJECTIVE_CHECK_FREQUENCIES,
DEFAULT_OBJECTIVE_TEMPLATES,
)
# =========================================================================
@@ -96,4 +101,19 @@ from app.models.personal_note import PersonalNote
from app.models.one_on_one_request import OneOnOneRequest
from app.models.admin_settings import AppSettings
from app.models.backup_record import BackupRecord
from app.models.audit_log import AuditLog
from app.models.audit_log import AuditLog
# =========================================================================
# Layer 12: player evolution objectives
# =========================================================================
from app.models.objective import Objective # noqa: F401 — new table for create_all()
# =========================================================================
# Layer 13: player evolution entries (KPI measurements)
# =========================================================================
from app.models.objective_entry import ObjectiveEntry # noqa: F401
# =========================================================================
# Layer 14: objective comments (coach feedback, no existing-schema change)
# =========================================================================
from app.models.objective_comment import ObjectiveComment # noqa: F401
+73
View File
@@ -69,6 +69,79 @@ PLATFORM_CODES = {
PLATFORM_DEFAULTS = {'Apex Legends': 'pc', 'Rainbow Six Siege': 'ubi', 'Rocket League': 'epic'}
# Objective-related constants for the Player Evolution planning feature.
# Ordered lists of (value, human_label) pairs for form fields and display.
OBJECTIVE_TYPES = [
('individual', 'Individuel'),
('team', 'Équipe'),
]
OBJECTIVE_STATUSES = [
('active', 'Actif'),
('pending', 'En attente'),
('completed', 'Terminé'),
('overdue', 'En retard'),
]
OBJECTIVE_PRIORITIES = [
('low', 'Faible'),
('medium', 'Moyenne'),
('high', 'Élevée'),
]
OBJECTIVE_CHECK_FREQUENCIES = [
('weekly', 'Hebdomadaire'),
('biweekly', 'Bi-hebdomadaire'),
('monthly', 'Mensuelle'),
]
# Default templates seeded on first run.
# (priority, title, description, kpi_metric, kpi_target, kpi_unit, type, category)
DEFAULT_OBJECTIVE_TEMPLATES = [
# Quantitative
('medium', 'Améliorer le taux de headshot (HS)',
'Augmenter le pourcentage de têtes sur les kills totaux.',
'Taux de headshot', 45.0, '%', 'individual', 'Mécaniques'),
('medium', 'Augmenter le K/D ratio',
'Améliorer le ratio kills/morts sur les matchs compétitifs.',
'K/D Ratio', 1.2, '', 'individual', 'Performance'),
('medium', 'Atteindre un KDA cible',
'Maintenir un ratio kills/deaths/assists au-dessus du seuil.',
'KDA', 3.0, '', 'individual', 'Performance'),
('medium', 'Améliorer la précision',
'Augmenter le pourcentage de tirs touchés par partie.',
'Précision', 40.0, '%', 'individual', 'Mécaniques'),
('medium', 'Augmenter les CS par minute',
'Atteindre une moyenne de last-hits par minute en jeu.',
'CS/min', 8.0, '', 'individual', 'Farm'),
('medium', 'Réduire les morts par match',
'Diminuer le nombre de décès moyens par partie compétitive.',
'Morts/match', 5.0, '', 'individual', 'Survie'),
('medium', 'Augmenter les dégâts par round',
'Améliorer le damage output moyen par round joué.',
'Dégâts/round', 150.0, '', 'individual', 'Performance'),
('medium', 'Améliorer le taux de victoire',
'Augmenter le pourcentage de parties gagnées sur la saison.',
'Winrate', 55.0, '%', 'team', 'Compétitif'),
# Qualitative
('medium', 'Améliorer le positionnement',
'Travailler le placement sur la carte pour réduire les angles exposés.',
None, None, None, 'individual', 'Positionnement'),
('medium', 'Améliorer la communication',
'Être plus vocal et clair dans les callouts en équipe.',
None, None, None, 'individual', 'Communication'),
('medium', 'Développer la conscience de carte',
'Anticiper les rotations adverses et mieux lire la minimap.',
None, None, None, 'individual', 'Game Sense'),
('medium', 'Améliorer la prise de décision',
'Réduire les décisions risquées et améliorer le choix des engagements.',
None, None, None, 'individual', 'Decision'),
('medium', 'Renforcer la cohésion d\'équipe',
'Participer activement aux stratégies collectives et aux drills.',
None, None, None, 'team', 'Équipe'),
]
TRN_URLS = {
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}',
'League of Legends': 'https://tracker.gg/lol/riot/profile/{username}',
+84
View File
@@ -0,0 +1,84 @@
"""Player evolution objective — individual or team KPI-based goal."""
from app.extensions import db
from app.time_utils import utc_now_naive
class Objective(db.Model):
"""Player evolution objective — a KPI-driven plan for individual players
or teams, created and tracked by coaching staff.
Covers the full lifecycle: draft (pending), active, completed, overdue.
Supports arbitrary KPI metrics, optional tags, and verifiable progress.
"""
__tablename__ = 'objectives'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
# Category
type = db.Column(
db.String(20),
nullable=False,
default='individual',
)
status = db.Column(
db.String(20),
nullable=False,
default='active',
)
priority = db.Column(
db.String(20),
nullable=False,
default='medium',
)
category = db.Column(db.String(50), nullable=True)
is_template = db.Column(db.Boolean, default=False, nullable=False)
# Assignment (individual player / org team)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
# KPI fields
kpi_metric = db.Column(db.String(120), nullable=True)
kpi_target = db.Column(db.Numeric(10, 2), nullable=True)
kpi_unit = db.Column(db.String(20), nullable=True)
kpi_precision = db.Column(db.Integer, nullable=True)
# Timeline
start_date = db.Column(db.Date, nullable=True)
due_date = db.Column(db.Date, nullable=False)
check_frequency = db.Column(db.String(30), nullable=True)
# Progress tracking (0–100)
progress = db.Column(db.Integer, default=0, nullable=False)
# Misc
tags = db.Column(db.String(255), nullable=True)
# Who created it
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
# Timestamps
created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
# -----------------------------------------------------------------------
# Relationships
# -----------------------------------------------------------------------
player = db.relationship(
'User',
foreign_keys=[player_id],
backref=db.backref('objectives', lazy='dynamic'),
)
team = db.relationship(
'OrgTeam',
foreign_keys=[team_id],
backref=db.backref('objectives', lazy='dynamic'),
)
creator = db.relationship(
'User',
foreign_keys=[created_by],
)
+35
View File
@@ -0,0 +1,35 @@
"""Coach comments on a specific objective."""
from app.extensions import db
from app.time_utils import utc_now_naive
class ObjectiveComment(db.Model):
"""A coach's comment on a player-evolution objective.
This is a separate table from PersonalNote — no existing schema
is altered. The objective-form page also *reads* PersonalNote and
TeamNote for context, but never writes to those tables.
"""
__tablename__ = 'objective_comments'
id = db.Column(db.Integer, primary_key=True)
objective_id = db.Column(
db.Integer,
db.ForeignKey('objectives.id'),
nullable=False,
)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
# -----------------------------------------------------------------------
# Relationships
# -----------------------------------------------------------------------
objective = db.relationship(
'Objective',
backref=db.backref('comments', lazy='dynamic', order_by='ObjectiveComment.created_at'),
)
coach = db.relationship('User', foreign_keys=[coach_id])
+39
View File
@@ -0,0 +1,39 @@
"""Player evolution — KPI measurement entries for objectives."""
from app.extensions import db
from app.time_utils import utc_now_naive
class ObjectiveEntry(db.Model):
"""A single KPI measurement recorded for an objective.
Each entry records a value at a point in time so the evolution
graph can plot historical data and compute a trend line.
"""
__tablename__ = 'objective_entries'
id = db.Column(db.Integer, primary_key=True)
objective_id = db.Column(
db.Integer,
db.ForeignKey('objectives.id'),
nullable=False,
)
value = db.Column(db.Numeric(10, 2), nullable=True)
recorded_at = db.Column(db.DateTime, nullable=False, default=utc_now_naive)
note = db.Column(db.Text, nullable=True)
# Who recorded it
recorded_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
# Timestamps
created_at = db.Column(db.DateTime, default=utc_now_naive)
# -----------------------------------------------------------------------
# Relationships
# -----------------------------------------------------------------------
objective = db.relationship(
'Objective',
backref=db.backref('entries', lazy='dynamic', order_by='ObjectiveEntry.recorded_at'),
)
recorder = db.relationship('User', foreign_keys=[recorded_by])
+124
View File
@@ -0,0 +1,124 @@
"""Objective analytics — linear regression for KPI prediction.
Given a set of ObjectiveEntry measurements, computes a simple linear
regression (y = mx + b, where x = days since first entry) and answers:
- **predicted_achievement_date**: the date when the trend line crosses
the KPI target value (or None if the trend is flat or negative).
- **confidence_score**: a heuristic 0.0–1.0 based on sample count and
the R² coefficient of determination.
- **predicted_value_at_date(date)**: the value the trend predicts at a
given future date.
"""
from __future__ import annotations
import datetime
def _to_days(timestamps: list[datetime.datetime]) -> list[float]:
"""Convert datetimes to float days since the first entry."""
if not timestamps:
return []
t0 = timestamps[0]
return [(t - t0).total_seconds() / 86400.0 for t in timestamps]
def _r_squared(xs, ys, slope, intercept):
"""Coefficient of determination (R²)."""
if len(xs) < 2:
return 0.0
mean_y = sum(ys) / len(ys)
ss_res = sum((y - (slope * x + intercept)) ** 2 for x, y in zip(xs, ys, strict=True))
ss_tot = sum((y - mean_y) ** 2 for y in ys)
if ss_tot == 0:
return 1.0
return 1.0 - ss_res / ss_tot
def compute_trend(
timestamps: list[datetime.datetime],
values: list[float],
target: float | None,
) -> dict:
"""Compute a linear regression trend and prediction.
Args:
timestamps: Entry dates.
values: Entry values (must be same length as timestamps).
target: KPI target value, or None for qualitative objectives.
Returns:
dict with keys:
slope, intercept, r_squared, confidence,
predicted_achievement_days, predicted_achievement_date,
predicted_value_at_due, target_value
"""
n = len(timestamps)
if n < 2 or not values:
return {
'slope': 0.0,
'intercept': float(values[0]) if values else 0.0,
'r_squared': 0.0,
'confidence': 0.0,
'predicted_achievement_days': None,
'predicted_achievement_date': None,
'predicted_value_at_due': None,
'target_value': target,
}
xs = _to_days(timestamps)
ys = [float(v) for v in values]
n_f = float(n)
sum_x = sum(xs)
sum_y = sum(ys)
sum_xy = sum(x * y for x, y in zip(xs, ys, strict=True))
sum_xx = sum(x * x for x in xs)
denom = n_f * sum_xx - sum_x * sum_x
if abs(denom) < 1e-12:
return {
'slope': 0.0,
'intercept': sum_y / n_f,
'r_squared': 0.0,
'confidence': 0.0,
'predicted_achievement_days': None,
'predicted_achievement_date': None,
'predicted_value_at_due': None,
'target_value': target,
}
slope = (n_f * sum_xy - sum_x * sum_y) / denom
intercept = (sum_y - slope * sum_x) / n_f
r2 = _r_squared(xs, ys, slope, intercept)
# Confidence heuristic: combination of R² and sample count
sample_confidence = min(1.0, n / 10.0) # 10+ entries = full confidence
confidence = sample_confidence * r2
# Days from first entry to target (if slope > 0 and target > intercept)
predicted_achievement_days = None
predicted_achievement_date = None
if target is not None and slope > 1e-12:
days_to_target = (target - intercept) / slope
if days_to_target >= 0:
predicted_achievement_days = days_to_target
predicted_achievement_date = timestamps[0] + datetime.timedelta(
days=days_to_target,
)
return {
'slope': slope,
'intercept': intercept,
'r_squared': round(r2, 4),
'confidence': round(confidence, 4),
'predicted_achievement_days': (
round(predicted_achievement_days, 1) if predicted_achievement_days is not None else None
),
'predicted_achievement_date': (
predicted_achievement_date.isoformat() if predicted_achievement_date else None
),
'predicted_value_at_due': None,
'target_value': target,
}
+70
View File
@@ -343,3 +343,73 @@ def coach_manages_tryout(coach, tryout):
if tryout.target_org_team_id:
return tryout.target_org_team_id in coach_org_team_ids(coach)
return False
# ---------------------------------------------------------------------------
# Player Evolution — Objectives
# ---------------------------------------------------------------------------
def can_view_objectives(user):
"""Whether the user may view the objectives list.
Everyone except regular players without any context can browse objectives.
Players see only their own.
"""
return True
def can_manage_objectives(user):
"""Whether the user may create, edit, or delete objectives.
Presidents, managers, and coaches may manage objectives. Players are
read-only.
"""
from app.models import Admin, Coach, Manager
return isinstance(user, (Admin, Manager, Coach))
def can_manage_objective(user, objective):
"""Whether the user may edit or delete a specific objective.
Presidents and managers may manage any objective. A coach may manage
objectives assigned to their players or their teams.
"""
from app.models import Admin, Coach, Manager
if isinstance(user, (Admin, Manager)):
return True
if isinstance(user, Coach):
if objective.player_id and objective.player_id in coach_player_ids(user):
return True
if objective.team_id and objective.team_id in coach_org_team_ids(user):
return True
return False
def can_view_objective(user, objective):
"""Whether the user may view this objective's detail page.
Presidents, managers, and coaches may view any they manage.
A player may view only their own assigned objectives.
Everyone else may view objectives they created.
"""
from app.models import Admin, Coach, Manager, Player
if isinstance(user, (Admin, Manager)):
return True
if isinstance(user, Coach):
return can_manage_objective(user, objective)
if isinstance(user, Player):
return objective.player_id == user.id
return objective.created_by == user.id
def can_add_objective_entry(user, objective):
"""Whether the user may record a KPI measurement on this objective.
The same as can_view_objective — the assigned player may record
their own measurements on their own objectives.
"""
return can_view_objective(user, objective)
+661
View File
@@ -0,0 +1,661 @@
"""Player Evolution — Objective management routes.
Allows coaches and staff to create, update, delete, and list
KPI-driven objectives for individual players or teams.
"""
import datetime
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from app.api import json_endpoint
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
DEFAULT_OBJECTIVE_TEMPLATES,
OBJECTIVE_CHECK_FREQUENCIES,
OBJECTIVE_PRIORITIES,
OBJECTIVE_STATUSES,
OBJECTIVE_TYPES,
Admin,
Coach,
Manager,
Objective,
ObjectiveComment,
ObjectiveEntry,
OrgTeam,
PersonalNote,
TeamNote,
User,
)
from app.pagination import paginate
from app.permissions import (
can_manage_objective,
can_manage_objectives,
coach_org_team_ids,
coach_player_ids,
)
from app.validators import ObjectiveSchema
objectives_bp = Blueprint('objectives', __name__, url_prefix='/objectives')
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _resolve_objective(obj_id):
"""Load an objective or 404."""
return db.get_or_404(Objective, obj_id)
def _can_edit(user, objective):
"""Short-hand permission check with flash fallback."""
if can_manage_objective(user, objective):
return True
flash(_('You do not have permission to manage this objective.'), 'danger')
return False
# ---------------------------------------------------------------------------
# List / Dashboard
# ---------------------------------------------------------------------------
@objectives_bp.route('')
@login_required
def list_objectives():
"""List all objectives the current user may see."""
user = current_user
can_edit = can_manage_objectives(user)
# ── Query base ──────────────────────────────────────────────────────
base = Objective.query
# Scope: players only see their own, coaches/admins see theirs, etc.
if isinstance(user, Manager):
base = base.filter(Objective.created_by == user.id)
elif isinstance(user, Coach):
team_ids = coach_org_team_ids(user)
player_ids = coach_player_ids(user)
conditions = [Objective.created_by == user.id]
if team_ids:
conditions.append(Objective.team_id.in_(team_ids))
if player_ids:
conditions.append(Objective.player_id.in_(player_ids))
base = base.filter(db.or_(*conditions))
elif isinstance(user, Admin):
pass # all
elif hasattr(user, 'role') and user.role == 'player':
base = base.filter(Objective.player_id == user.id)
else:
base = base.filter(Objective.created_by == user.id)
# ── Tab / filter ────────────────────────────────────────────────────
tab = request.args.get('tab', 'all')
if tab == 'kpi':
base = base.filter(Objective.kpi_metric.isnot(None))
elif tab in ('modeles', 'templates'):
base = base.filter(Objective.is_template.is_(True))
search = request.args.get('search', '').strip()
if search:
base = base.filter(Objective.title.ilike(f'%{search}%'))
f_status = request.args.get('status', '').strip()
f_priority = request.args.get('priority', '').strip()
f_type = request.args.get('type', '').strip()
f_team = request.args.get('team', type=int)
if f_status:
base = base.filter(Objective.status == f_status)
if f_priority:
base = base.filter(Objective.priority == f_priority)
if f_type:
base = base.filter(Objective.type == f_type)
if f_team:
base = base.filter(Objective.team_id == f_team)
sort_column = request.args.get('sort', 'due_date')
sort_order = request.args.get('order', 'asc')
if sort_order not in ('asc', 'desc'):
sort_order = 'asc'
sort_map = {
'title': Objective.title,
'priority': Objective.priority,
'status': Objective.status,
'progress': Objective.progress,
'due_date': Objective.due_date,
'created_at': Objective.created_at,
}
sort_expr = sort_map.get(sort_column, Objective.due_date)
if sort_order == 'desc':
sort_expr = sort_expr.desc()
else:
sort_expr = sort_expr.asc()
objectives_page = paginate(base.order_by(sort_expr, Objective.id))
all_q = Objective.query
count_total = all_q.count()
count_kpi = all_q.filter(Objective.kpi_metric.isnot(None)).count()
count_templates = all_q.filter(Objective.is_template.is_(True)).count()
success_rate = 0.0
total_with_status = all_q.count()
if total_with_status > 0:
completed = all_q.filter(Objective.status == 'completed').count()
success_rate = round((completed / total_with_status) * 100, 1)
teams = OrgTeam.query.order_by(OrgTeam.name).all()
players = User.query.filter(User.role == 'player').order_by(User.username).all()
templates = Objective.query.filter_by(is_template=True).order_by(Objective.title).all()
return render_template(
'pages/objectives.html',
objectives=objectives_page,
pagination=objectives_page,
can_edit=can_edit,
tab=tab,
search=search,
f_status=f_status,
f_priority=f_priority,
f_type=f_type,
f_team=f_team,
sort_column=sort_column,
sort_order=sort_order,
count_total=count_total,
count_kpi=count_kpi,
count_templates=count_templates,
success_rate=success_rate,
objective_types=OBJECTIVE_TYPES,
objective_statuses=OBJECTIVE_STATUSES,
objective_priorities=OBJECTIVE_PRIORITIES,
objective_frequencies=OBJECTIVE_CHECK_FREQUENCIES,
teams=teams,
players=players,
templates=templates,
)
# ---------------------------------------------------------------------------
# Create
# ---------------------------------------------------------------------------
# New / Edit form page
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# View (player-facing detail page)
# ---------------------------------------------------------------------------
@objectives_bp.route('/<int:objective_id>')
@login_required
def view_objective(objective_id):
"""Player-facing detail page for a specific objective."""
from app.permissions import can_view_objective as _can_view
objective = _resolve_objective(objective_id)
if not _can_view(current_user, objective):
flash(_('You do not have permission to view this objective.'), 'danger')
return redirect(url_for('objectives.list_objectives'))
can_edit = can_manage_objectives(current_user)
entries = (
ObjectiveEntry.query.filter_by(objective_id=objective.id)
.order_by(ObjectiveEntry.recorded_at)
.all()
)
comments = (
ObjectiveComment.query.filter_by(objective_id=objective.id)
.order_by(ObjectiveComment.created_at.desc())
.all()
)
personal_notes = []
if objective.player_id:
personal_notes = (
PersonalNote.query.filter_by(player_id=objective.player_id)
.order_by(PersonalNote.created_at.desc())
.limit(10)
.all()
)
team_notes = []
if objective.team_id:
team_notes = (
TeamNote.query.filter_by(org_team_id=objective.team_id)
.order_by(TeamNote.created_at.desc())
.limit(10)
.all()
)
return render_template(
'pages/objective_view.html',
objective=objective,
entries=entries,
comments=comments,
personal_notes=personal_notes,
team_notes=team_notes,
can_edit=can_edit,
objective_types=OBJECTIVE_TYPES,
objective_statuses=OBJECTIVE_STATUSES,
objective_priorities=OBJECTIVE_PRIORITIES,
objective_frequencies=OBJECTIVE_CHECK_FREQUENCIES,
)
def _form_context():
"""Shared context for the create/edit form page."""
teams = OrgTeam.query.order_by(OrgTeam.name).all()
players = User.query.filter(User.role == 'player').order_by(User.username).all()
templates = Objective.query.filter_by(is_template=True).order_by(Objective.title).all()
return {
'objective_types': OBJECTIVE_TYPES,
'objective_statuses': OBJECTIVE_STATUSES,
'objective_priorities': OBJECTIVE_PRIORITIES,
'objective_frequencies': OBJECTIVE_CHECK_FREQUENCIES,
'teams': teams,
'players': players,
'templates': templates,
}
@objectives_bp.route('/new')
@login_required
def new_objective():
"""Render the create-objective form page with preview."""
if not can_manage_objectives(current_user):
flash(_('You do not have permission to create objectives.'), 'danger')
return redirect(url_for('objectives.list_objectives'))
ctx = _form_context()
ctx['objective'] = None
ctx['edit_mode'] = False
ctx['entries'] = []
ctx['comments'] = []
ctx['personal_notes'] = []
ctx['team_notes'] = []
return render_template('pages/objective_form.html', **ctx)
@objectives_bp.route('/<int:objective_id>/edit')
@login_required
def edit_objective(objective_id):
"""Render the edit-objective form page with preview."""
objective = _resolve_objective(objective_id)
if not _can_edit(current_user, objective):
return redirect(url_for('objectives.list_objectives'))
ctx = _form_context()
ctx['objective'] = objective
ctx['edit_mode'] = True
ctx['entries'] = (
ObjectiveEntry.query.filter_by(objective_id=objective.id)
.order_by(ObjectiveEntry.recorded_at)
.all()
)
ctx['comments'] = (
ObjectiveComment.query.filter_by(objective_id=objective.id)
.order_by(ObjectiveComment.created_at.desc())
.all()
)
ctx['personal_notes'] = []
if objective.player_id:
ctx['personal_notes'] = (
PersonalNote.query.filter_by(player_id=objective.player_id)
.order_by(PersonalNote.created_at.desc())
.limit(10)
.all()
)
ctx['team_notes'] = []
if objective.team_id:
ctx['team_notes'] = (
TeamNote.query.filter_by(org_team_id=objective.team_id)
.order_by(TeamNote.created_at.desc())
.limit(10)
.all()
)
return render_template('pages/objective_form.html', **ctx)
# ---------------------------------------------------------------------------
@objectives_bp.route('/create', methods=['POST'])
@login_required
def create_objective():
"""Create a new objective."""
if not can_manage_objectives(current_user):
flash(_('You do not have permission to create objectives.'), 'danger')
return redirect(url_for('objectives.list_objectives'))
try:
data = ObjectiveSchema().load(
form_payload(list_fields=(), optional_blank=('password', 'check_frequency'))
)
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('objectives.list_objectives'))
objective = Objective(
title=data['title'],
description=data.get('description'),
type=data['type'],
status=data['status'],
priority=data['priority'],
category=data.get('category'),
is_template=data.get('is_template', False),
player_id=data.get('player_id'),
team_id=data.get('team_id'),
kpi_metric=data.get('kpi_metric'),
kpi_target=data.get('kpi_target'),
kpi_unit=data.get('kpi_unit'),
kpi_precision=data.get('kpi_precision'),
start_date=data.get('start_date'),
due_date=data['due_date'],
check_frequency=data.get('check_frequency') or None,
progress=data.get('progress', 0),
tags=data.get('tags'),
created_by=current_user.id,
)
db.session.add(objective)
db.session.commit()
flash(_('Objective created successfully.'), 'success')
return redirect(url_for('objectives.list_objectives'))
# ---------------------------------------------------------------------------
# Update
# ---------------------------------------------------------------------------
@objectives_bp.route('/<int:objective_id>/update', methods=['POST'])
@login_required
def update_objective(objective_id):
"""Update an existing objective."""
objective = _resolve_objective(objective_id)
if not _can_edit(current_user, objective):
return redirect(url_for('objectives.list_objectives'))
try:
data = ObjectiveSchema().load(
form_payload(list_fields=(), optional_blank=('password', 'check_frequency')),
partial=('due_date',),
)
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('objectives.list_objectives'))
for field in (
'title',
'description',
'type',
'status',
'priority',
'category',
'is_template',
'player_id',
'team_id',
'kpi_metric',
'kpi_unit',
'kpi_precision',
'check_frequency',
'progress',
'tags',
):
val = data.get(field)
setattr(objective, field, val)
if 'kpi_target' in data:
objective.kpi_target = data['kpi_target']
if 'start_date' in data:
objective.start_date = data['start_date']
if 'due_date' in data:
objective.due_date = data['due_date']
db.session.commit()
flash(_('Objective updated successfully.'), 'success')
return redirect(url_for('objectives.list_objectives'))
# ---------------------------------------------------------------------------
# Delete
# ---------------------------------------------------------------------------
@objectives_bp.route('/<int:objective_id>/delete', methods=['POST'])
@login_required
def delete_objective(objective_id):
"""Delete an objective."""
objective = _resolve_objective(objective_id)
if not _can_edit(current_user, objective):
return redirect(url_for('objectives.list_objectives'))
db.session.delete(objective)
db.session.commit()
flash(_('Objective deleted.'), 'success')
return redirect(url_for('objectives.list_objectives'))
# ---------------------------------------------------------------------------
# JSON data endpoint for the edit modal
# ---------------------------------------------------------------------------
@objectives_bp.route('/<int:objective_id>/data')
@json_endpoint
@login_required
def objective_data(objective_id):
"""Return objective details as JSON for the edit modal."""
from flask import jsonify
objective = _resolve_objective(objective_id)
return jsonify(
{
'id': objective.id,
'title': objective.title,
'description': objective.description,
'type': objective.type,
'status': objective.status,
'priority': objective.priority,
'category': objective.category,
'is_template': objective.is_template,
'player_id': objective.player_id,
'team_id': objective.team_id,
'kpi_metric': objective.kpi_metric,
'kpi_target': str(objective.kpi_target) if objective.kpi_target is not None else None,
'kpi_unit': objective.kpi_unit,
'kpi_precision': objective.kpi_precision,
'start_date': objective.start_date.isoformat() if objective.start_date else None,
'due_date': objective.due_date.isoformat() if objective.due_date else None,
'check_frequency': objective.check_frequency,
'progress': objective.progress,
'tags': objective.tags,
}
)
# ---------------------------------------------------------------------------
# Seed default templates (idempotent)
# ---------------------------------------------------------------------------
def seed_default_templates():
"""Create the default Objective templates if they don't exist yet.
Idempotent — safe to call on every startup.
Skips if no admin user exists yet (first run seeding may not have run).
"""
admin = User.query.filter(User.role == 'admin').first()
if not admin:
return
existing = {t.title for t in Objective.query.filter_by(is_template=True).all()}
for (
priority,
title,
description,
kpi_metric,
kpi_target,
kpi_unit,
typ,
category,
) in DEFAULT_OBJECTIVE_TEMPLATES:
if title in existing:
continue
obj = Objective(
title=title,
description=description,
type=typ,
status='active',
priority=priority,
category=category,
is_template=True,
kpi_metric=kpi_metric,
kpi_target=kpi_target,
kpi_unit=kpi_unit,
progress=0,
due_date=datetime.date(2099, 12, 31),
created_by=admin.id,
)
db.session.add(obj)
db.session.commit()
# ---------------------------------------------------------------------------
# KPI Entries — measurements for the quantitative graph
# ---------------------------------------------------------------------------
@objectives_bp.route('/<int:objective_id>/entries', methods=['GET'])
@login_required
@json_endpoint
def list_entries(objective_id):
"""Return KPI entries for an objective as JSON."""
from flask import jsonify
objective = _resolve_objective(objective_id)
entries = (
ObjectiveEntry.query.filter_by(objective_id=objective.id)
.order_by(ObjectiveEntry.recorded_at)
.all()
)
return jsonify(
[
{
'id': e.id,
'value': str(e.value),
'recorded_at': e.recorded_at.isoformat(),
'note': e.note,
}
for e in entries
]
)
@objectives_bp.route('/<int:objective_id>/entries', methods=['POST'])
@login_required
def add_entry(objective_id):
"""Record a KPI measurement for an objective.
Accepts an optional numeric value (for quantitative KPI tracking)
and an optional note. If neither is provided the entry is rejected.
The assigned player may record entries on their own objectives.
"""
from datetime import datetime
from app.permissions import can_add_objective_entry
objective = _resolve_objective(objective_id)
if not can_add_objective_entry(current_user, objective):
flash(_('You do not have permission to record entries for this objective.'), 'danger')
return redirect(url_for('objectives.list_objectives'))
raw_value = request.form.get('value', '').strip()
note = request.form.get('note', '').strip()
value = None
if raw_value:
try:
value = float(raw_value)
except (ValueError, TypeError):
flash(_('Invalid measurement value.'), 'danger')
return redirect(url_for('objectives.view_objective', objective_id=objective_id))
if value is None and not note:
flash(_('Provide a value, a note, or both.'), 'warning')
return redirect(url_for('objectives.view_objective', objective_id=objective_id))
entry = ObjectiveEntry(
objective_id=objective.id,
value=value,
recorded_at=datetime.now(),
note=note or None,
recorded_by=current_user.id,
)
db.session.add(entry)
db.session.commit()
flash(_('Measurement recorded.'), 'success')
return redirect(url_for('objectives.view_objective', objective_id=objective_id))
flash(_('Measurement recorded.'), 'success')
return redirect(url_for('objectives.edit_objective', objective_id=objective_id))
# ---------------------------------------------------------------------------
# Comments — coach feedback on an objective
# ---------------------------------------------------------------------------
@objectives_bp.route('/<int:objective_id>/comments', methods=['POST'])
@login_required
def add_comment(objective_id):
"""Add a coach comment on an objective."""
objective = _resolve_objective(objective_id)
if not can_manage_objectives(current_user):
flash(_('You do not have permission to comment.'), 'danger')
return redirect(url_for('objectives.list_objectives'))
from app.validators import NoteContentSchema
try:
data = NoteContentSchema().load(form_payload(list_fields=()))
except ValidationError as err:
flash_validation_errors(err)
return redirect(url_for('objectives.edit_objective', objective_id=objective_id))
comment = ObjectiveComment(
objective_id=objective.id,
coach_id=current_user.id,
content=data['content'],
)
db.session.add(comment)
db.session.commit()
flash(_('Comment added.'), 'success')
return redirect(url_for('objectives.edit_objective', objective_id=objective_id))
# ---------------------------------------------------------------------------
# Trend / prediction data for the chart
# ---------------------------------------------------------------------------
@objectives_bp.route('/<int:objective_id>/trend')
@login_required
@json_endpoint
def trend_data(objective_id):
"""Return the trend + prediction data for an objective's chart."""
from flask import jsonify
from app.objectives_analytics import compute_trend
objective = _resolve_objective(objective_id)
entries = (
ObjectiveEntry.query.filter_by(objective_id=objective.id)
.order_by(ObjectiveEntry.recorded_at)
.all()
)
timestamps = [e.recorded_at for e in entries]
values = [float(e.value) for e in entries]
target = float(objective.kpi_target) if objective.kpi_target is not None else None
trend = compute_trend(timestamps, values, target)
return jsonify(trend)
+290
View File
@@ -1788,6 +1788,296 @@ a:hover { color: var(--primary-dark); }
box-shadow: var(--shadow-md);
}
/* ------------------------------------------------------------------- */
/* Player Evolution — Objective Management */
/* ------------------------------------------------------------------- */
/* Wider variant for the create/edit modal with five sections */
.modal-lg .modal-content {
max-width: 720px;
}
/* Section grouping inside the form */
.form-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--gray-200);
}
.form-section:last-child {
border-bottom: none;
margin-bottom: 0;
}
.form-section h4 {
font-size: 0.95rem;
color: var(--primary);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
}
/* Toolbar (tabs + search/filter bar) */
.objectives-toolbar {
display: flex;
flex-direction: column;
gap: 12px;
}
.objectives-tabs {
display: flex;
gap: 4px;
border-bottom: 2px solid var(--gray-200);
padding-bottom: 4px;
}
.tab-link {
padding: 6px 16px;
font-size: 0.9rem;
color: var(--gray-500);
text-decoration: none;
border-radius: var(--radius) var(--radius) 0 0;
transition: all 0.2s;
border: none;
background: transparent;
cursor: pointer;
}
.tab-link:hover {
color: var(--primary);
background: var(--gray-100);
}
.tab-link.active {
color: var(--primary);
font-weight: 600;
border-bottom: 2px solid var(--primary);
margin-bottom: -5px;
}
.objectives-search-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.objectives-search-bar .form-inline {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: 100%;
align-items: flex-end;
}
/* Tag badges inside objective cells */
.objective-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.objective-tags .badge {
font-size: 0.7rem;
padding: 2px 6px;
}
/* Progress bar in the table */
.progress-bar-container {
width: 80px;
height: 6px;
background: var(--gray-200);
border-radius: 3px;
overflow: hidden;
display: inline-block;
vertical-align: middle;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--danger), var(--warning), var(--success));
border-radius: 3px;
transition: width 0.3s ease;
}
/* Score-input range mirror (reused from evaluation forms) */
.score-input {
display: flex;
align-items: center;
gap: 8px;
}
.score-input input[type="range"] {
flex: 1;
}
.range-value {
font-weight: 600;
font-size: 1rem;
/* ------------------------------------------------------------------- */
/* Objective Form — two-column editor layout */
/* ------------------------------------------------------------------- */
.objective-editor {
display: grid;
grid-template-columns: 1fr 360px;
gap: 20px;
align-items: start;
}
.objective-editor-form .card-body {
max-height: 80vh;
overflow-y: auto;
}
.objective-editor-preview {
position: sticky;
top: 20px;
}
/* Preview card stats */
.preview-stats {
margin: 12px 0;
}
.preview-stat {
display: flex;
justify-content: space-between;
padding: 4px 0;
border-bottom: 1px solid var(--gray-100);
font-size: 0.85rem;
}
.preview-label {
color: var(--gray-500);
}
.preview-value {
font-weight: 600;
color: var(--gray-800);
}
.progress-section {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.progress-section .progress-bar-container {
flex: 1;
}
/* Template list items */
.template-item {
padding: 10px;
margin-bottom: 8px;
border: 1px solid var(--gray-200);
border-radius: var(--radius);
transition: border-color 0.2s;
}
.template-item:hover {
border-color: var(--primary);
}
.template-item h5 {
margin: 0 0 4px 0;
font-size: 0.9rem;
}
.template-item p {
margin: 0 0 8px 0;
font-size: 0.8rem;
}
/* ------------------------------------------------------------------- */
/* Objective form — comments, entries, prediction info */
/* ------------------------------------------------------------------- */
.comments-list {
max-height: 300px;
overflow-y: auto;
}
.comment-item {
padding: 8px 0;
border-bottom: 1px solid var(--gray-100);
}
.comment-item:last-child {
border-bottom: none;
}
.comment-item p {
margin: 4px 0 0 0;
font-size: 0.85rem;
line-height: 1.4;
}
#predictionInfo {
font-size: 0.85rem;
padding: 8px;
background: var(--gray-50);
border-radius: var(--radius);
}
#predictionInfo .confidence-high { color: var(--success); }
#predictionInfo .confidence-medium { color: var(--warning); }
#predictionInfo .confidence-low { color: var(--danger); }
/* Form-inline for measurement entry */
.form-inline {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-end;
}
.form-inline .form-group {
margin-bottom: 0;
}
/* Hide template/team fields initially */
#playerField, #teamField {
transition: opacity 0.2s;
}
/* Chart sizing */
#progressChart {
background: transparent;
}
/* Responsive: stack on small screens */
@media (max-width: 900px) {
.objective-editor {
grid-template-columns: 1fr;
}
.objective-editor-preview {
position: static;
}
}
min-width: 24px;
text-align: center;
color: var(--primary);
}
/* Actions cell in table */
.actions-cell {
white-space: nowrap;
display: flex;
gap: 4px;
}
/* Required field indicator */
.required {
color: var(--danger);
}
/* Reused stat card col-4 / col-6 helpers */
.col-4 { flex: 0 0 calc(33.333% - 12px); }
.col-6 { flex: 0 0 calc(50% - 12px); }
.col-12 { flex: 0 0 100%; }
.card-header .drag-handle {
margin-left: auto;
margin-right: 0;
+6
View File
@@ -68,6 +68,12 @@
</a>
</li>
{% endif %}
<li>
<a href="{{ url_for('objectives.list_objectives') }}" class="{% if request.endpoint and 'objectives' in request.endpoint %}active{% endif %}">
<i class="fas fa-bullseye"></i>
<span>{{ _('Objectives') }}</span>
</a>
</li>
{% if current_user.role == 'player' %}
<li>
<a href="{{ url_for('teams.my_teams') }}" class="{% if request.endpoint == 'teams.my_teams' %}active{% endif %}">
+348
View File
@@ -0,0 +1,348 @@
{% extends "layouts/base.html" %}
{% block title %}{% if edit_mode %}{{ _('Edit Objective') }}{% else %}{{ _('New Objective') }}{% endif %} - UdeS team manager{% endblock %}
{% block page_title %}{% if edit_mode %}{{ _('Edit Objective') }}{% else %}{{ _('New Objective') }}{% endif %}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">{{ _('Home') }} / <a href="{{ url_for('objectives.list_objectives') }}">{{ _('Objectives') }}</a> / {% if edit_mode %}{{ _('Edit') }}{% else %}{{ _('New') }}{% endif %}</span>{% endblock %}
{% block content %}
<div class="objective-editor">
<div class="objective-editor-form">
<div class="card">
<div class="card-header"><h3><i class="fas fa-edit"></i> {% if edit_mode %}{{ _('Edit Objective') }}{% else %}{{ _('Create Objective') }}{% endif %}</h3></div>
<div class="card-body">
<form method="POST" action="{% if edit_mode %}{{ url_for('objectives.update_objective', objective_id=objective.id) }}{% else %}{{ url_for('objectives.create_objective') }}{% endif %}" class="form" id="objForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-section">
<h4><i class="fas fa-info-circle"></i> {{ _('1. General Information') }}</h4>
<div class="form-row">
<div class="form-group col-6">
<label for="obj_type">{{ _('Type') }}</label>
<select name="type" id="obj_type" class="form-select" onchange="updatePreview();toggleAssignment()">
{% for val, label in objective_types %}<option value="{{ val }}"{% if objective and objective.type == val %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
<div class="form-group col-6">
<label for="obj_status">{{ _('Status') }}</label>
<select name="status" id="obj_status" class="form-select" onchange="updatePreview()">
{% for val, label in objective_statuses %}<option value="{{ val }}"{% if objective and objective.status == val %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label for="obj_title">{{ _('Title') }} <span class="required">*</span></label>
<input type="text" name="title" id="obj_title" class="form-input" required value="{{ objective.title if objective else '' }}" placeholder="{{ _('e.g. Improve headshot accuracy') }}" oninput="updatePreview()">
</div>
<div class="form-group">
<label for="obj_description">{{ _('Description') }}</label>
<textarea name="description" id="obj_description" rows="3" class="form-textarea" placeholder="{{ _('Describe the objective...') }}" oninput="updatePreview()">{{ objective.description if objective else '' }}</textarea>
</div>
<div class="form-row">
<div class="form-group col-6" id="playerField">
<label for="obj_player_id">{{ _('Assigned to (Player)') }}</label>
<select name="player_id" id="obj_player_id" class="form-select" onchange="updatePreview()">
<option value="">{{ _('-- Select a player --') }}</option>
{% for p in players %}<option value="{{ p.id }}"{% if objective and objective.player_id == p.id %} selected{% endif %}>{{ p.username }}</option>{% endfor %}
</select>
</div>
<div class="form-group col-6" id="teamField">
<label for="obj_team_id">{{ _('Team') }}</label>
<select name="team_id" id="obj_team_id" class="form-select" onchange="updatePreview()">
<option value="">{{ _('-- Select a team --') }}</option>
{% for t in teams %}<option value="{{ t.id }}"{% if objective and objective.team_id == t.id %} selected{% endif %}>{{ t.name }}</option>{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" name="is_template" id="obj_is_template" value="1"{% if objective and objective.is_template %} checked{% endif %} onchange="updatePreview();toggleAssignment()"> {{ _('This is a template (reusable pattern)') }}</label>
</div>
</div>
<div class="form-section">
<h4><i class="fas fa-chart-line"></i> {{ _('2. KPI & Target') }}</h4>
<div class="form-row">
<div class="form-group col-4">
<label for="obj_kpi_metric">{{ _('KPI metric') }}</label>
<input type="text" name="kpi_metric" id="obj_kpi_metric" class="form-input" value="{{ objective.kpi_metric if objective else '' }}" placeholder="{{ _('e.g. HS rate, KDA, CS/min') }}" oninput="updatePreview()">
</div>
<div class="form-group col-4">
<label for="obj_kpi_target">{{ _('Target value') }}</label>
<input type="number" step="0.01" name="kpi_target" id="obj_kpi_target" class="form-input" value="{{ objective.kpi_target if objective else '' }}" placeholder="{{ _('e.g. 40') }}" oninput="updatePreview()">
</div>
<div class="form-group col-4">
<label for="obj_kpi_unit">{{ _('Unit') }}</label>
<input type="text" name="kpi_unit" id="obj_kpi_unit" class="form-input" value="{{ objective.kpi_unit if objective else '' }}" placeholder="{{ _('e.g. points, percentage') }}" oninput="updatePreview()">
</div>
</div>
</div>
<div class="form-section">
<h4><i class="fas fa-calendar-alt"></i> {{ _('3. Deadline & Priority') }}</h4>
<div class="form-row">
<div class="form-group col-4">
<label for="obj_start_date">{{ _('Start date') }}</label>
<input type="date" name="start_date" id="obj_start_date" class="form-input" value="{{ objective.start_date.strftime('%Y-%m-%d') if objective and objective.start_date else '' }}" onchange="updatePreview()">
</div>
<div class="form-group col-4">
<label for="obj_due_date">{{ _('Deadline') }} <span class="required">*</span></label>
<input type="date" name="due_date" id="obj_due_date" class="form-input" required value="{{ objective.due_date.strftime('%Y-%m-%d') if objective else '' }}" onchange="updatePreview()">
</div>
<div class="form-group col-4">
<label for="obj_priority">{{ _('Priority') }}</label>
<select name="priority" id="obj_priority" class="form-select" onchange="updatePreview()">
{% for val, label in objective_priorities %}<option value="{{ val }}"{% if objective and objective.priority == val %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
</div>
</div>
<div class="form-section">
<h4><i class="fas fa-bell"></i> {{ _('4. Tracking & Alerts') }}</h4>
<div class="form-row">
<div class="form-group col-6">
<label for="obj_check_frequency">{{ _('Check frequency') }}</label>
<select name="check_frequency" id="obj_check_frequency" class="form-select">
<option value="">{{ _('-- None --') }}</option>
{% for val, label in objective_frequencies %}<option value="{{ val }}"{% if objective and objective.check_frequency == val %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
<div class="form-group col-6">
<label for="obj_progress">{{ _('Progress') }} (<span id="progressVal">{{ objective.progress if objective else 0 }}</span>%)</label>
<input type="range" id="obj_progress" name="progress" min="0" max="100" value="{{ objective.progress if objective else 0 }}" class="form-range" oninput="document.getElementById('progressVal').textContent=this.value;updatePreview()">
</div>
</div>
</div>
<div class="form-section">
<h4><i class="fas fa-cog"></i> {{ _('5. Advanced Settings') }}</h4>
<div class="form-group">
<label for="obj_category">{{ _('Category') }}</label>
<input type="text" name="category" id="obj_category" class="form-input" value="{{ objective.category if objective else '' }}" placeholder="{{ _('e.g. Mechanics, Teamplay, Communication') }}">
</div>
<div class="form-group">
<label for="obj_tags">{{ _('Tags') }}</label>
<input type="text" name="tags" id="obj_tags" class="form-input" value="{{ objective.tags if objective else '' }}" placeholder="{{ _('Separate with commas') }}">
</div>
</div>
<div class="form-actions">
<a href="{{ url_for('objectives.list_objectives') }}" class="btn btn-secondary">{{ _('Cancel') }}</a>
<button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> {% if edit_mode %}{{ _('Update') }}{% else %}{{ _('Create') }}{% endif %}</button>
</div>
</form>
</div>
</div>
</div>
<div class="objective-editor-preview">
<div class="card mb-4" id="previewCard">
<div class="card-header"><h3><i class="fas fa-eye"></i> {{ _('Player Preview') }}</h3></div>
<div class="card-body">
<div id="previewContent">
<h4 id="previewTitle">{{ objective.title if objective else _('Objective title') }}</h4>
<p id="previewDesc" class="text-muted small">{{ objective.description if objective else '' }}</p>
<div class="objective-tags" id="previewTags">{% if objective and objective.tags %}{% for tag in objective.tags.split(',') %}<span class="badge badge-light">{{ tag.strip() }}</span>{% endfor %}{% endif %}</div>
<div class="preview-stats">
<div class="preview-stat"><span class="preview-label">{{ _('Assigned to') }}</span><span id="previewAssignee" class="preview-value">{% if objective and objective.player %}{{ objective.player.username }}{% else %}-{% endif %}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('KPI') }}</span><span id="previewKpi" class="preview-value">{% if objective and objective.kpi_metric %}{{ objective.kpi_metric }}: {{ objective.kpi_target }}{{ objective.kpi_unit }}{% else %}-{% endif %}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Deadline') }}</span><span id="previewDeadline" class="preview-value">{% if objective and objective.due_date %}{{ objective.due_date.strftime('%Y-%m-%d') }}{% endif %}</span></div>
</div>
<div class="progress-section">
<span class="preview-label">{{ _('Progress') }}</span>
<div class="progress-bar-container" style="width:100%"><div class="progress-bar" id="previewBar" style="width:{{ objective.progress if objective else 0 }}%"></div></div>
<strong><span id="previewProgress">{{ objective.progress if objective else 0 }}</span>%</strong>
</div>
</div>
<div class="mt-3">
<span id="previewStatusBadge" class="badge">{% if objective %}{% for val, label in objective_statuses %}{% if val == objective.status %}{{ label }}{% endif %}{% endfor %}{% else %}{{ _('Active') }}{% endif %}</span>
<span id="previewPriorityBadge" class="badge">{% if objective %}{{ {'high':_('High'),'medium':_('Medium'),'low':_('Low')}.get(objective.priority,objective.priority) }}{% else %}{{ _('Medium') }}{% endif %}</span>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-chart-line"></i> {{ _('KPI Progression') }}</h3></div>
<div class="card-body">
<svg id="progressChart" viewBox="0 0 400 200" style="width:100%;height:auto;" xmlns="http://www.w3.org/2000/svg"></svg>
{% if edit_mode and objective.kpi_target %}
<div class="mt-2 text-center" id="predictionInfo"></div>
{% endif %}
</div>
</div>
{% if edit_mode %}
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-database"></i> {{ _('KPI Measurements') }}</h3></div>
<div class="card-body">
<form method="POST" action="{{ url_for('objectives.add_entry', objective_id=objective.id) }}" class="form-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group" style="flex:1;">
<input type="number" step="0.01" name="value" class="form-input" placeholder="{{ _('Value') }}" required>
</div>
<div class="form-group" style="flex:2;">
<input type="text" name="note" class="form-input" placeholder="{{ _('Note (optional)') }}">
</div>
<button type="submit" class="btn btn-sm btn-primary"><i class="fas fa-plus"></i> {{ _('Record') }}</button>
</form>
{% if entries %}
<table class="table mt-2">
<thead><tr><th>{{ _('Date') }}</th><th>{{ _('Value') }}</th><th>{{ _('Note') }}</th></tr></thead>
<tbody>
{% for e in entries %}
<tr><td>{{ e.recorded_at.strftime('%Y-%m-%d %H:%M') }}</td><td><strong>{{ e.value }}</strong></td><td class="text-muted small">{{ e.note or '' }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-comments"></i> {{ _('Comments') }}</h3></div>
<div class="card-body">
<form method="POST" action="{{ url_for('objectives.add_comment', objective_id=objective.id) }}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<textarea name="content" rows="2" class="form-textarea" placeholder="{{ _('Add a comment...') }}" required></textarea>
</div>
<button type="submit" class="btn btn-sm btn-primary"><i class="fas fa-paper-plane"></i> {{ _('Comment') }}</button>
</form>
{% if comments %}
<div class="comments-list mt-2">
{% for c in comments %}
<div class="comment-item">
<strong>{{ c.coach.username }}</strong> <span class="text-muted small">{{ c.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
<p>{{ c.content }}</p>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% if personal_notes or team_notes %}
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-sticky-note"></i> {{ _('Related Notes') }}</h3></div>
<div class="card-body">
{% if personal_notes %}
<h5>{{ _('Player Notes') }}</h5>
{% for n in personal_notes %}
<div class="comment-item">
<strong>{{ n.coach.username }}</strong> <span class="text-muted small">{{ n.created_at.strftime('%Y-%m-%d') }}</span>
<p>{{ n.content[:200] }}</p>
</div>
{% endfor %}
{% endif %}
{% if team_notes %}
<h5 class="mt-2">{{ _('Team Notes') }}</h5>
{% for n in team_notes %}
<div class="comment-item">
<strong>{{ n.coach.username }}</strong> <span class="text-muted small">{{ n.created_at.strftime('%Y-%m-%d') }}</span>
<p>{{ n.content[:200] }}</p>
</div>
{% endfor %}
{% endif %}
</div>
</div>
{% endif %}
{% endif %}
{% if templates %}
<div class="card">
<div class="card-header"><h3><i class="fas fa-copy"></i> {{ _('Templates') }}</h3></div>
<div class="card-body" id="templatesList">
{% for t in templates %}
<div class="template-item">
<h5>{{ t.title }}</h5>
<p class="text-muted small">{{ t.description[:120] if t.description else '' }}</p>
<button type="button" class="btn btn-sm btn-outline-primary use-template-btn" onclick="applyTemplate({{ t.id }})"><i class="fas fa-arrow-right"></i> {{ _('Use this template') }}</button>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
var templateDataUrl = '{{ url_for("objectives.objective_data", objective_id=0) }}';
var templates = {};
{% for t in templates %}
(function(id) {
fetch(templateDataUrl.replace('/0/', '/' + id + '/')).then(function(r) { return r.json(); }).then(function(d) { templates[id] = d; });
})({{ t.id }});
{% endfor %}
function toggleAssignment() {
var t = document.getElementById('obj_type').value;
var isTmpl = document.getElementById('obj_is_template').checked;
var pf = document.getElementById('playerField');
var tf = document.getElementById('teamField');
if (isTmpl) { pf.style.display = 'none'; tf.style.display = 'none'; return; }
pf.style.display = t === 'team' ? 'none' : '';
tf.style.display = t === 'individual' ? 'none' : '';
}
function applyTemplate(id) {
var t = templates[id]; if (!t) return;
document.getElementById('obj_title').value = t.title || '';
document.getElementById('obj_description').value = t.description || '';
document.getElementById('obj_type').value = t.type || 'individual';
document.getElementById('obj_status').value = t.status || 'active';
document.getElementById('obj_priority').value = t.priority || 'medium';
document.getElementById('obj_kpi_metric').value = t.kpi_metric || '';
document.getElementById('obj_kpi_target').value = t.kpi_target || '';
document.getElementById('obj_kpi_unit').value = t.kpi_unit || '';
document.getElementById('obj_start_date').value = t.start_date || '';
document.getElementById('obj_due_date').value = '';
document.getElementById('obj_check_frequency').value = t.check_frequency || '';
document.getElementById('obj_category').value = t.category || '';
document.getElementById('obj_tags').value = t.tags || '';
document.getElementById('obj_progress').value = 0;
document.getElementById('progressVal').textContent = '0';
toggleAssignment(); updatePreview();
}
function updatePreview() {
var title = document.getElementById('obj_title').value || '';
var desc = document.getElementById('obj_description').value || '';
var status = document.getElementById('obj_status').value;
var priority = document.getElementById('obj_priority').value;
var kpiMetric = document.getElementById('obj_kpi_metric').value;
var kpiTarget = document.getElementById('obj_kpi_target').value;
var kpiUnit = document.getElementById('obj_kpi_unit').value;
var deadline = document.getElementById('obj_due_date').value;
var progress = document.getElementById('obj_progress').value;
var sel = document.getElementById('obj_player_id');
var playerName = sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : '-';
document.getElementById('previewTitle').textContent = title || '{{ _('Objective title') | e }}';
document.getElementById('previewDesc').textContent = desc;
document.getElementById('previewAssignee').textContent = playerName;
document.getElementById('previewKpi').textContent = kpiMetric ? kpiMetric + ': ' + kpiTarget + (kpiUnit || '') : '-';
document.getElementById('previewDeadline').textContent = deadline;
document.getElementById('previewBar').style.width = progress + '%';
document.getElementById('previewProgress').textContent = progress;
var sl = {}, sc = {'active':'badge-success','pending':'badge-warning','completed':'badge-info','overdue':'badge-danger'};
var pl = {}, pc = {'high':'badge-danger','medium':'badge-warning','low':'badge-secondary'};
{% for val, label in objective_statuses %}sl['{{ val }}'] = '{{ label | e }}';{% endfor %}
{% for val, label in objective_priorities %}pl['{{ val }}'] = '{{ label | e }}';{% endfor %}
document.getElementById('previewStatusBadge').textContent = sl[status] || status;
document.getElementById('previewStatusBadge').className = 'badge ' + (sc[status] || 'badge-secondary');
document.getElementById('previewPriorityBadge').textContent = pl[priority] || priority;
document.getElementById('previewPriorityBadge').className = 'badge ' + (pc[priority] || 'badge-secondary');
drawChart(progress, kpiTarget);
}
function drawChart(progress, target) {
var svg = document.getElementById('progressChart');
var w = 400, h = 200, pt = 20, pr = 20, pb = 30, pl = 40;
var cw = w - pl - pr, ch = h - pt - pb;
var prog = Math.min(parseInt(progress) || 0, 100);
var py = pt + ch * (1 - prog / 100);
var tgt = parseFloat(target) || 100;
var tgtScale = Math.max(tgt, prog);
var tgtNorm = tgt / (tgtScale);
var ty = pt + ch * (1 - tgtNorm);
var html = '<rect x="0" y="0" width="' + w + '" height="' + h + '" fill="transparent"/>';
html += '<line x1="' + pl + '" y1="' + (pt + ch) + '" x2="' + (pl + cw) + '" y2="' + (pt + ch) + '" stroke="#ddd" stroke-width="1"/>';
html += '<line x1="' + pl + '" y1="' + pt + '" x2="' + pl + '" y2="' + (pt + ch) + '" stroke="#ddd" stroke-width="1"/>';
html += '<text x="' + (pl - 30) + '" y="' + (pt + 5) + '" fill="#999" font-size="10">' + tgtScale + '</text>';
html += '<text x="' + (pl - 30) + '" y="' + (pt + ch / 2 + 4) + '" fill="#999" font-size="10">' + (tgtScale / 2).toFixed(1) + '</text>';
html += '<text x="' + (pl - 30) + '" y="' + (pt + ch + 4) + '" fill="#999" font-size="10">0</text>';
html += '<text x="' + pl + '" y="' + (pt + ch + 18) + '" fill="#999" font-size="10">{{ _('Now') | e }}</text>';
html += '<text x="' + (pl + cw - 50) + '" y="' + (pt + ch + 18) + '" fill="#999" font-size="10">{{ _('Deadline') | e }}</text>';
html += '<line x1="' + pl + '" y1="' + ty + '" x2="' + (pl + cw) + '" y2="' + ty + '" stroke="#4CAF50" stroke-width="1.5" stroke-dasharray="4,3"/>';
html += '<text x="' + (pl + cw + 4) + '" y="' + (ty + 4) + '" fill="#4CAF50" font-size="9">{{ _('Target') | e }}</text>';
html += '<polygon points="' + pl + ',' + (pt + ch) + ' ' + pl + ',' + py + ' ' + (pl + (prog / 100) * cw) + ',' + py + ' ' + (pl + (prog / 100) * cw) + ',' + (pt + ch) + '" fill="rgba(59,130,246,0.15)"/>';
html += '<line x1="' + pl + '" y1="' + py + '" x2="' + (pl + (prog / 100) * cw) + '" y2="' + py + '" stroke="#3B82F6" stroke-width="2"/>';
html += '<circle cx="' + (pl + (prog / 100) * cw) + '" cy="' + py + '" r="4" fill="#3B82F6"/>';
html += '<text x="' + (pl + (prog / 100) * cw - 12) + '" y="' + (py - 8) + '" fill="#3B82F6" font-size="10" font-weight="bold">' + prog + '%</text>';
svg.innerHTML = html;
}
document.addEventListener('DOMContentLoaded', function() { toggleAssignment(); updatePreview(); });
</script>
{% endblock %}
+125
View File
@@ -0,0 +1,125 @@
{% extends "layouts/base.html" %}
{% block title %}{{ objective.title }} - UdeS team manager{% endblock %}
{% block page_title %}{{ objective.title }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">{{ _('Home') }} / <a href="{{ url_for('objectives.list_objectives') }}">{{ _('Objectives') }}</a> / {{ objective.title }}</span>{% endblock %}
{% block content %}
<div class="row">
<div class="col-8">
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-bullseye"></i> {{ objective.title }}</h3></div>
<div class="card-body">
<p>{{ objective.description or '' }}</p>
<div class="preview-stats">
<div class="preview-stat"><span class="preview-label">{{ _('Type') }}</span><span class="preview-value">{% for val, label in objective_types %}{% if val == objective.type %}{{ label }}{% endif %}{% endfor %}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Assigned to') }}</span><span class="preview-value">{% if objective.player %}{{ objective.player.username }}{% elif objective.team %}{{ objective.team.name }}{% else %}-{% endif %}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Category') }}</span><span class="preview-value">{{ objective.category or '-' }}</span></div>
</div>
<div class="mt-3">
<span id="statusBadge" class="badge">{% for val, label in objective_statuses %}{% if val == objective.status %}{{ label }}{% endif %}{% endfor %}</span>
<span id="priorityBadge" class="badge">{% set pl = {'high':_('High'),'medium':_('Medium'),'low':_('Low')} %}{{ pl.get(objective.priority,objective.priority) }}</span>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-chart-line"></i> {{ _('KPI Progression') }}</h3></div>
<div class="card-body">
<svg id="progressChart" viewBox="0 0 400 200" style="width:100%;height:auto;" xmlns="http://www.w3.org/2000/svg"></svg>
{% if objective.kpi_target %}<div class="mt-2 text-center" id="predictionInfo"></div>{% endif %}
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-database"></i> {{ _('KPI Measurements') }}</h3></div>
<div class="card-body">
<form method="POST" action="{{ url_for('objectives.add_entry', objective_id=objective.id) }}" class="form-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group" style="flex:1;"><input type="number" step="0.01" name="value" class="form-input" placeholder="{{ _('Value (optional)') }}"></div>
<div class="form-group" style="flex:2;"><input type="text" name="note" class="form-input" placeholder="{{ _('Note or comment') }}"></div>
<button type="submit" class="btn btn-sm btn-primary"><i class="fas fa-plus"></i> {{ _('Record') }}</button>
</form>
{% if entries %}<table class="table mt-2"><thead><tr><th>{{ _('Date') }}</th><th>{{ _('Value') }}</th><th>{{ _('Note') }}</th></tr></thead><tbody>
{% for e in entries %}<tr><td>{{ e.recorded_at.strftime('%Y-%m-%d %H:%M') }}</td><td><strong>{% if e.value is not none %}{{ e.value }}{% else %}-{% endif %}</strong></td><td class="text-muted small">{{ e.note or '' }}</td></tr>{% endfor %}</tbody></table>{% else %}<p class="text-muted mt-2">{{ _('No measurements recorded yet.') }}</p>{% endif %}
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-comments"></i> {{ _('Comments') }}</h3></div>
<div class="card-body">{% if comments %}<div class="comments-list">{% for c in comments %}<div class="comment-item"><strong>{{ c.coach.username }}</strong> <span class="text-muted small">{{ c.created_at.strftime('%Y-%m-%d %H:%M') }}</span><p>{{ c.content }}</p></div>{% endfor %}</div>{% else %}<p class="text-muted">{{ _('No comments yet.') }}</p>{% endif %}</div>
</div>
</div>
<div class="col-4">
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-info-circle"></i> {{ _('Details') }}</h3></div>
<div class="card-body">
<div class="preview-stats">
<div class="preview-stat"><span class="preview-label">{{ _('Priority') }}</span><span class="preview-value">{% set pl = {'high':_('High'),'medium':_('Medium'),'low':_('Low')} %}{{ pl.get(objective.priority,objective.priority) }}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Status') }}</span><span class="preview-value">{% for val, label in objective_statuses %}{% if val == objective.status %}{{ label }}{% endif %}{% endfor %}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Start date') }}</span><span class="preview-value">{{ objective.start_date.strftime('%Y-%m-%d') if objective.start_date else '-' }}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Deadline') }}</span><span class="preview-value">{{ objective.due_date.strftime('%Y-%m-%d') if objective.due_date else '-' }}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Progress') }}</span><span class="preview-value">{{ objective.progress }}%</span></div>
</div>
{% if objective.kpi_target %}<div class="progress-section mt-2"><span class="preview-label">{{ _('KPI Target') }}</span><strong>{{ objective.kpi_target }}{% if objective.kpi_unit %}{{ objective.kpi_unit }}{% endif %}</strong></div>{% endif %}
{% if can_edit %}<a href="{{ url_for('objectives.edit_objective', objective_id=objective.id) }}" class="btn btn-sm btn-primary mt-3"><i class="fas fa-edit"></i> {{ _('Edit') }}</a>{% endif %}
</div>
</div>
{% if personal_notes or team_notes %}<div class="card mb-4"><div class="card-header"><h3><i class="fas fa-sticky-note"></i> {{ _('Related Notes') }}</h3></div><div class="card-body">{% if personal_notes %}<h5>{{ _('Personal Notes') }}</h5>{% for n in personal_notes %}<div class="comment-item"><strong>{{ n.coach.username }}</strong> <span class="text-muted small">{{ n.created_at.strftime('%Y-%m-%d') }}</span><p>{{ n.content[:200] }}</p></div>{% endfor %}{% endif %}{% if team_notes %}<h5 class="mt-2">{{ _('Team Notes') }}</h5>{% for n in team_notes %}<div class="comment-item"><strong>{{ n.coach.username }}</strong> <span class="text-muted small">{{ n.created_at.strftime('%Y-%m-%d') }}</span><p>{{ n.content[:200] }}</p></div>{% endfor %}{% endif %}</div></div>{% endif %}
</div>
</div>{% endblock %}
{% block scripts %}
<script>
function drawChart(progress, target) {
var svg = document.getElementById('progressChart');
if (!svg) return;
var w = 400, h = 200, pt = 20, pr = 20, pb = 30, pl = 40;
var cw = w - pl - pr, ch = h - pt - pb;
var prog = Math.min(parseInt(progress) || 0, 100);
var py = pt + ch * (1 - prog / 100);
var tgt = parseFloat(target) || 100;
var tgtScale = Math.max(tgt, prog);
var ty = pt + ch * (1 - tgt / tgtScale);
var html = '<rect x="0" y="0" width="' + w + '" height="' + h + '" fill="transparent"/>';
html += '<line x1="' + pl + '" y1="' + (pt + ch) + '" x2="' + (pl + cw) + '" y2="' + (pt + ch) + '" stroke="#ddd" stroke-width="1"/>';
html += '<line x1="' + pl + '" y1="' + pt + '" x2="' + pl + '" y2="' + (pt + ch) + '" stroke="#ddd" stroke-width="1"/>';
html += '<text x="' + (pl - 30) + '" y="' + (pt + 5) + '" fill="#999" font-size="10">' + tgtScale + '</text>';
html += '<text x="' + (pl - 30) + '" y="' + (pt + ch / 2 + 4) + '" fill="#999" font-size="10">' + (tgtScale / 2).toFixed(1) + '</text>';
html += '<text x="' + (pl - 30) + '" y="' + (pt + ch + 4) + '" fill="#999" font-size="10">0</text>';
html += '<line x1="' + pl + '" y1="' + ty + '" x2="' + (pl + cw) + '" y2="' + ty + '" stroke="#4CAF50" stroke-width="1.5" stroke-dasharray="4,3"/>';
html += '<text x="' + (pl + cw + 4) + '" y="' + (ty + 4) + '" fill="#4CAF50" font-size="9">{{ _('Target') | e }}</text>';
html += '<polygon points="' + pl + ',' + (pt + ch) + ' ' + pl + ',' + py + ' ' + (pl + (prog / 100) * cw) + ',' + py + ' ' + (pl + (prog / 100) * cw) + ',' + (pt + ch) + '" fill="rgba(59,130,246,0.15)"/>';
html += '<line x1="' + pl + '" y1="' + py + '" x2="' + (pl + (prog / 100) * cw) + '" y2="' + py + '" stroke="#3B82F6" stroke-width="2"/>';
html += '<circle cx="' + (pl + (prog / 100) * cw) + '" cy="' + py + '" r="4" fill="#3B82F6"/>';
svg.innerHTML = html;
fetchTrendAndEntries();
}
function fetchTrendAndEntries() {
var trendUrl = '{{ url_for("objectives.trend_data", objective_id=objective.id) }}';
var entriesUrl = '{{ url_for("objectives.list_entries", objective_id=objective.id) }}';
Promise.all([fetch(trendUrl).then(function(r){return r.json()}), fetch(entriesUrl).then(function(r){return r.json()})]).then(function(results) {
var trend = results[0], entries = results[1];
var infoEl = document.getElementById('predictionInfo');
if (trend.predicted_achievement_date) {
infoEl.innerHTML = '<strong>{{ _("Predicted achievement") }}:</strong> ' + new Date(trend.predicted_achievement_date).toLocaleDateString() + ' - <span class=\"' + (trend.confidence > 0.6 ? 'confidence-high' : trend.confidence > 0.3 ? 'confidence-medium' : 'confidence-low') + '\">{{ _("Confidence") }}: ' + (trend.confidence * 100).toFixed(0) + '%</span>';
}
if (entries && entries.length > 1) {
var svg = document.getElementById('progressChart');
var html = svg.innerHTML;
var w = 400, pl = 40, pt = 20, cw = 320, ch = 150;
var entryValues = entries.map(function(e){return parseFloat(e.value)||0;});
var maxVal = Math.max.apply(null, entryValues);
var range = maxVal || 1;
var stepX = cw / (entries.length - 1);
entryValues.forEach(function(v, i) {
var ex = pl + i * stepX;
var ey = pt + ch * (1 - v / range);
html += '<circle cx="' + ex + '" cy="' + ey + '" r="3" fill="#8B5CF6"/>';
if (i > 0) {
var pex = pl + (i - 1) * stepX;
var pey = pt + ch * (1 - entryValues[i-1] / range);
html += '<line x1="' + pex + '" y1="' + pey + '" x2="' + ex + '" y2="' + ey + '" stroke="#8B5CF6" stroke-width="1.5"/>';
}
});
svg.innerHTML = html;
}
}).catch(function(){});
}
document.addEventListener('DOMContentLoaded', function() { drawChart({{ objective.progress }}, '{{ objective.kpi_target if objective.kpi_target else '' }}'); });
</script>
{% endblock %}
+64
View File
@@ -0,0 +1,64 @@
{% extends "layouts/base.html" %}
{% import 'layouts/_pagination.html' as pager %}
{% block title %}{{ _('Objectives') }} - UdeS team manager{% endblock %}
{% block page_title %}{{ _('Objective Management') }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">{{ _('Home') }} / {{ _('Objectives') }}</span>{% endblock %}
{% block content %}
<div class="stats-grid mb-4">
<div class="stat-card"><div class="stat-icon bg-primary"><i class="fas fa-flag-checkered"></i></div><div class="stat-info"><h3>{{ count_total }}</h3><p>{{ _('Total') }}</p></div></div>
<div class="stat-card"><div class="stat-icon bg-info"><i class="fas fa-chart-line"></i></div><div class="stat-info"><h3>{{ count_kpi }}</h3><p>{{ _('Custom KPIs') }}</p></div></div>
<div class="stat-card"><div class="stat-icon bg-secondary"><i class="fas fa-copy"></i></div><div class="stat-info"><h3>{{ count_templates }}</h3><p>{{ _('Templates') }}</p></div></div>
<div class="stat-card"><div class="stat-icon bg-success"><i class="fas fa-percentage"></i></div><div class="stat-info"><h3>{{ success_rate }}%</h3><p>{{ _('Success rate') }}</p></div></div>
</div>
<div class="card mb-4"><div class="card-body"><div class="objectives-toolbar">
<nav class="objectives-tabs" role="tablist">
<a href="{{ url_for('objectives.list_objectives', tab='all') }}" class="tab-link {% if tab == 'all' %}active{% endif %}">{{ _('All') }} ({{ count_total }})</a>
<a href="{{ url_for('objectives.list_objectives', tab='kpi') }}" class="tab-link {% if tab == 'kpi' %}active{% endif %}">{{ _('Custom KPIs') }} ({{ count_kpi }})</a>
<a href="{{ url_for('objectives.list_objectives', tab='modeles') }}" class="tab-link {% if tab in ('modeles','templates') %}active{% endif %}">{{ _('Templates') }} ({{ count_templates }})</a>
</nav>
<form method="GET" action="{{ url_for('objectives.list_objectives') }}" class="objectives-search-bar">
<input type="hidden" name="tab" value="{{ tab }}">
<div class="form-inline">
<div class="form-group" style="flex:1;min-width:200px;"><input type="text" name="search" class="form-input" placeholder="{{ _('Search an objective...') }}"></div>
<div class="form-group"><select name="status" class="form-select"><option value="">{{ _('Status') }}</option>{% for val, label in objective_statuses %}<option value="{{ val }}"{% if f_status == val %} selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="form-group"><select name="priority" class="form-select"><option value="">{{ _('Priority') }}</option>{% for val, label in objective_priorities %}<option value="{{ val }}"{% if f_priority == val %} selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="form-group"><select name="type" class="form-select"><option value="">{{ _('Type') }}</option>{% for val, label in objective_types %}<option value="{{ val }}"{% if f_type == val %} selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="form-group"><select name="team" class="form-select"><option value="">{{ _('Team') }}</option>{% for t in teams %}<option value="{{ t.id }}"{% if f_team == t.id %} selected{% endif %}>{{ t.name }}</option>{% endfor %}</select></div>
<button type="submit" class="btn btn-sm btn-primary"><i class="fas fa-filter"></i> {{ _('Filters') }}</button>
</div></form>
</div></div></div>
<div class="card"><div class="card-header"><h3><i class="fas fa-bullseye"></i> {{ _('Objectives') }}</h3>{% if can_edit %}<a href="{{ url_for('objectives.new_objective') }}" class="btn btn-sm btn-primary"><i class="fas fa-plus"></i> {{ _('New Objective') }}</a>{% endif %}</div>
<div class="card-body"><div class="table-container"><table class="table objectives-table"><thead><tr>
<th>{{ _('Objective') }}</th><th>{{ _('Assigned to') }}</th><th>{{ _('Team') }}</th><th>{{ _('Priority') }}</th><th>{{ _('Status') }}</th><th>{{ _('Target KPI') }}</th><th>{{ _('Progress') }}</th><th>{{ _('Deadline') }}</th>{% if can_edit %}<th>{{ _('Actions') }}</th>{% endif %}
</tr></thead><tbody>
{% for obj in objectives.items %}<tr data-objective-id="{{ obj.id }}">
<td><strong>{{ obj.title }}</strong>{% if obj.tags %}<div class="objective-tags">{% for tag in obj.tags.split(',') %}<span class="badge badge-light">{{ tag.strip() }}</span>{% endfor %}</div>{% endif %}{% if obj.kpi_metric %}<small class="text-muted d-block">{{ obj.kpi_metric }}</small>{% endif %}</td>
<td>{% if obj.player %}<span class="user-avatar avatar-sm">{{ obj.player.username[:2]|upper }}</span> {{ obj.player.username }}{% else %}<span class="text-muted">-</span>{% endif %}</td>
<td>{% if obj.team %}{{ obj.team.name }}{% else %}<span class="text-muted">-</span>{% endif %}</td>
<td>{% set pc = {'high':'badge-danger','medium':'badge-warning','low':'badge-secondary'} %}<span class="badge {{ pc.get(obj.priority,'badge-secondary') }}">{{ {'high':_('High'),'medium':_('Medium'),'low':_('Low')}.get(obj.priority,obj.priority) }}</span></td>
<td>{% set sc = {'active':'badge-success','pending':'badge-warning','completed':'badge-info','overdue':'badge-danger'} %}<span class="badge {{ sc.get(obj.status,'badge-secondary') }}">{% for val, label in objective_statuses %}{% if val == obj.status %}{{ label }}{% endif %}{% endfor %}</span></td>
<td>{% if obj.kpi_target is not none %}{{ obj.kpi_target }}{% if obj.kpi_unit %}{{ obj.kpi_unit }}{% endif %}{% else %}<span class="text-muted">-</span>{% endif %}</td>
<td><div class="progress-bar-container"><div class="progress-bar" style="width:{{ obj.progress }}%"></div></div><small>{{ obj.progress }}%</small></td>
<td>{% if obj.is_template %}-{% elif obj.due_date %}{{ obj.due_date.strftime('%Y-%m-%d') }}{% endif %}</td>
{% if can_edit %}<td class="actions-cell"><a href="{{ url_for('objectives.edit_objective', objective_id=obj.id) }}" class="btn btn-sm btn-outline-primary" title="{{ _('Edit') }}"><i class="fas fa-edit"></i></a> <a href="{{ url_for('objectives.view_objective', objective_id=obj.id) }}" class="btn btn-sm btn-outline-info" title="{{ _('View') }}"><i class="fas fa-eye"></i></a> <button class="btn btn-sm btn-outline-danger delete-obj-btn" data-obj-id="{{ obj.id }}" data-obj-title="{{ obj.title }}" title="{{ _('Delete') }}"><i class="fas fa-trash"></i></button></td>{% elif current_user.role == 'player' %}<td><a href="{{ url_for('objectives.view_objective', objective_id=obj.id) }}" class="btn btn-sm btn-outline-info"><i class="fas fa-eye"></i> {{ _('View') }}</a></td>{% endif %}
</tr>{% else %}
<tr><td colspan="{{ 9 if can_edit else 8 }}" class="text-center"><div class="empty-state"><i class="fas fa-bullseye"></i><h3>{{ _('No objectives yet') }}</h3><p>{{ _('Create your first objective to start tracking player evolution.') }}</p></div></td></tr>{% endfor %}
</tbody></table></div>{{ pager.controls(pagination) }}</div></div>
<div id="deleteModal" class="modal hidden"><div class="modal-backdrop" onclick="hideDeleteModal();"></div><div class="modal-content"><div class="modal-header"><h3><i class="fas fa-trash-alt"></i> {{ _('Delete Objective') }}</h3><button class="modal-close" onclick="hideDeleteModal();">&times;</button></div><div class="modal-body"><p>{{ _('Are you sure you want to delete this objective?') }}</p><p><strong id="deleteObjTitle"></strong></p><form id="deleteForm" method="POST" action=""><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="form-actions"><button type="button" class="btn btn-secondary" onclick="hideDeleteModal();">{{ _('Cancel') }}</button><button type="submit" class="btn btn-danger"><i class="fas fa-trash"></i> {{ _('Delete') }}</button></div></form></div></div></div>
{% endblock %}
{% block scripts %}<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.delete-obj-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var id = this.getAttribute('data-obj-id');
var title = this.getAttribute('data-obj-title');
document.getElementById('deleteObjTitle').textContent = title;
document.getElementById('deleteForm').action = '{{ url_for("objectives.delete_objective", objective_id=0) }}'.replace('/0/', '/' + id + '/');
document.getElementById('deleteModal').classList.remove('hidden');
});
});
document.getElementById('deleteModal').querySelector('.modal-backdrop').addEventListener('click', hideDeleteModal);
});
function hideDeleteModal(){document.getElementById('deleteModal').classList.add('hidden');}
document.addEventListener('keydown',function(e){if(e.key==='Escape'){hideDeleteModal();}});
</script>{% endblock %}
+2 -2
View File
@@ -2453,10 +2453,10 @@ msgstr "Mes disponibilités"
#: app/templates/pages/profile.html:234
msgid ""
"Select your available time blocks for matches (5pm to 12am). Green = "
"selected, Gray = available to select."
"available, Gray = unavailable"
msgstr ""
"Choisissez vos plages disponibles pour les matchs (17 h à minuit). Vert ="
" sélectionné, gris = disponible."
" disponible, gris = non-disponible."
#: app/templates/pages/profile.html:251
msgid "My Coaching Availability"
+143 -1
View File
@@ -23,7 +23,15 @@ from marshmallow import (
validates_schema,
)
from app.models import ESPORT_GAMES, GAME_PLATFORMS, USER_TYPES
from app.models import (
ESPORT_GAMES,
GAME_PLATFORMS,
OBJECTIVE_CHECK_FREQUENCIES,
OBJECTIVE_PRIORITIES,
OBJECTIVE_STATUSES,
OBJECTIVE_TYPES,
USER_TYPES,
)
# =============================================================================
# Custom Validators
@@ -1049,3 +1057,137 @@ class EvaluationSchema(StripMixin):
allow_none=True,
load_default=None,
)
# =============================================================================
# Player Evolution — Objective / Plan Schema
# =============================================================================
class ObjectiveSchema(StripMixin):
"""A player-evolution objective — individual or team KPI goal.
Validates the form payload from the create / edit modal on the
objectives management page.
"""
title = fields.String(
required=True,
validate=validate.Length(min=1, max=200, error=_l('Le titre est obligatoire.')),
error_messages={'required': _l('Le titre est obligatoire.')},
)
description = fields.String(
allow_none=True,
load_default=None,
validate=validate.Length(max=5000),
)
type = fields.String(
validate=validate.OneOf(
[v for v, _ in OBJECTIVE_TYPES],
error=_l("Type d'objectif invalide."),
),
load_default='individual',
)
status = fields.String(
validate=validate.OneOf(
[v for v, _ in OBJECTIVE_STATUSES],
error=_l('Statut invalide.'),
),
load_default='active',
)
priority = fields.String(
validate=validate.OneOf(
[v for v, _ in OBJECTIVE_PRIORITIES],
error=_l('Priorité invalide.'),
),
load_default='medium',
)
category = fields.String(
allow_none=True,
load_default=None,
validate=validate.Length(max=50),
)
is_template = fields.Boolean(load_default=False)
player_id = fields.Integer(
allow_none=True,
load_default=None,
validate=validate.Range(min=1),
error_messages={'invalid': _l('Joueur invalide.')},
)
team_id = fields.Integer(
allow_none=True,
load_default=None,
validate=validate.Range(min=1),
error_messages={'invalid': _l('Équipe invalide.')},
)
kpi_metric = fields.String(
allow_none=True,
load_default=None,
validate=validate.Length(max=120),
)
kpi_target = fields.Decimal(
allow_none=True,
load_default=None,
places=2,
error_messages={'invalid': _l('Cible KPI invalide.')},
)
kpi_unit = fields.String(
allow_none=True,
load_default=None,
validate=validate.Length(max=20),
)
kpi_precision = fields.Integer(
allow_none=True,
load_default=None,
validate=validate.Range(min=0, max=10),
)
start_date = fields.Date(
allow_none=True,
load_default=None,
format='%Y-%m-%d',
error_messages={'invalid': _l('Date de début invalide (AAAA-MM-JJ).')},
)
due_date = fields.Date(
required=True,
format='%Y-%m-%d',
error_messages={
'required': _l('La date d\'échéance est obligatoire.'),
'invalid': _l('Date d\'échéance invalide (AAAA-MM-JJ).'),
},
)
check_frequency = fields.String(
allow_none=True,
load_default=None,
validate=validate.OneOf(
[''] + [v for v, _ in OBJECTIVE_CHECK_FREQUENCIES],
error=_l('Fréquence de vérification invalide.'),
),
)
progress = fields.Integer(
load_default=0,
validate=validate.Range(
min=0, max=100, error=_l('La progression doit être entre 0 et 100.')
),
)
tags = fields.String(
allow_none=True,
load_default=None,
validate=validate.Length(max=255),
)
@validates_schema
def validate_assignment(self, data, **kwargs):
"""Type-aware assignment: coerce to a clean state.
- Templates → no player or team.
- Team objectives → clear player_id.
- Individual objectives → clear team_id.
"""
if data.get('is_template'):
data['player_id'] = None
data['team_id'] = None
return
if data.get('type') == 'team':
data['player_id'] = None
else:
data['team_id'] = None
Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

+4
View File
@@ -72,6 +72,10 @@ if __name__ == '__main__':
_db.session.commit()
print('[INFO] Seeded 4 default users (password for all: password).')
# Seed default objective templates (idempotent)
from app.routes.objectives import seed_default_templates
seed_default_templates()
debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
if debug_mode:
app.logger.warning(
+62
View File
@@ -0,0 +1,62 @@
import pathlib
tmpl = '''{% extends "layouts/base.html" %}
{% import 'layouts/_pagination.html' as pager %}
{% block title %}{{ _('Objectives') }} - UdeS team manager{% endblock %}
{% block page_title %}{{ _('Objective Management') }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">{{ _('Home') }} / {{ _('Objectives') }}</span>{% endblock %}
{% block content %}
<div class="stats-grid mb-4">
<div class="stat-card"><div class="stat-icon bg-primary"><i class="fas fa-flag-checkered"></i></div><div class="stat-info"><h3>{{ count_total }}</h3><p>{{ _('Total') }}</p></div></div>
<div class="stat-card"><div class="stat-icon bg-info"><i class="fas fa-chart-line"></i></div><div class="stat-info"><h3>{{ count_kpi }}</h3><p>{{ _('Custom KPIs') }}</p></div></div>
<div class="stat-card"><div class="stat-icon bg-secondary"><i class="fas fa-copy"></i></div><div class="stat-info"><h3>{{ count_templates }}</h3><p>{{ _('Templates') }}</p></div></div>
<div class="stat-card"><div class="stat-icon bg-success"><i class="fas fa-percentage"></i></div><div class="stat-info"><h3>{{ success_rate }}%</h3><p>{{ _('Success rate') }}</p></div></div>
</div>
<div class="card mb-4"><div class="card-body"><div class="objectives-toolbar">
<nav class="objectives-tabs" role="tablist">
<a href="{{ url_for('objectives.list_objectives', tab='all') }}" class="tab-link {% if tab == 'all' %}active{% endif %}">{{ _('All') }} ({{ count_total }})</a>
<a href="{{ url_for('objectives.list_objectives', tab='kpi') }}" class="tab-link {% if tab == 'kpi' %}active{% endif %}">{{ _('Custom KPIs') }} ({{ count_kpi }})</a>
<a href="{{ url_for('objectives.list_objectives', tab='modeles') }}" class="tab-link {% if tab in ('modeles','templates') %}active{% endif %}">{{ _('Templates') }} ({{ count_templates }})</a>
</nav>
<form method="GET" action="{{ url_for('objectives.list_objectives') }}" class="objectives-search-bar">
<input type="hidden" name="tab" value="{{ tab }}">
<div class="form-inline">
<div class="form-group" style="flex:1;min-width:200px;"><input type="text" name="search" class="form-input" placeholder="{{ _('Search an objective...') }}"></div>
<div class="form-group"><select name="status" class="form-select"><option value="">{{ _('Status') }}</option>{% for val, label in objective_statuses %}<option value="{{ val }}"{% if f_status == val %} selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="form-group"><select name="priority" class="form-select"><option value="">{{ _('Priority') }}</option>{% for val, label in objective_priorities %}<option value="{{ val }}"{% if f_priority == val %} selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="form-group"><select name="type" class="form-select"><option value="">{{ _('Type') }}</option>{% for val, label in objective_types %}<option value="{{ val }}"{% if f_type == val %} selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="form-group"><select name="team" class="form-select"><option value="">{{ _('Team') }}</option>{% for t in teams %}<option value="{{ t.id }}"{% if f_team == t.id %} selected{% endif %}>{{ t.name }}</option>{% endfor %}</select></div>
<button type="submit" class="btn btn-sm btn-primary"><i class="fas fa-filter"></i> {{ _('Filters') }}</button>
</div></form>
</div></div></div>
<div class="card"><div class="card-header"><h3><i class="fas fa-bullseye"></i> {{ _('Objectives') }}</h3>{% if can_edit %}<a href="{{ url_for('objectives.new_objective') }}" class="btn btn-sm btn-primary"><i class="fas fa-plus"></i> {{ _('New Objective') }}</a>{% endif %}</div>
<div class="card-body"><div class="table-container"><table class="table objectives-table"><thead><tr>
<th>{{ _('Objective') }}</th><th>{{ _('Assigned to') }}</th><th>{{ _('Team') }}</th><th>{{ _('Priority') }}</th><th>{{ _('Status') }}</th><th>{{ _('Target KPI') }}</th><th>{{ _('Progress') }}</th><th>{{ _('Deadline') }}</th>{% if can_edit %}<th>{{ _('Actions') }}</th>{% endif %}
</tr></thead><tbody>
'''
pathlib.Path('app/templates/pages/objectives.html').write_text(tmpl, encoding='utf-8')
print('LIST_PAGE_PART1_OK')
tmpl2 = '''{% for obj in objectives.items %}<tr data-objective-id="{{ obj.id }}">
<td><strong>{{ obj.title }}</strong>{% if obj.tags %}<div class="objective-tags">{% for tag in obj.tags.split(',') %}<span class="badge badge-light">{{ tag.strip() }}</span>{% endfor %}</div>{% endif %}{% if obj.kpi_metric %}<small class="text-muted d-block">{{ obj.kpi_metric }}</small>{% endif %}</td>
<td>{% if obj.player %}<span class="user-avatar avatar-sm">{{ obj.player.username[:2]|upper }}</span> {{ obj.player.username }}{% else %}<span class="text-muted">-</span>{% endif %}</td>
<td>{% if obj.team %}{{ obj.team.name }}{% else %}<span class="text-muted">-</span>{% endif %}</td>
<td>{% set pc = {'high':'badge-danger','medium':'badge-warning','low':'badge-secondary'} %}<span class="badge {{ pc.get(obj.priority,'badge-secondary') }}">{{ {'high':_('High'),'medium':_('Medium'),'low':_('Low')}.get(obj.priority,obj.priority) }}</span></td>
<td>{% set sc = {'active':'badge-success','pending':'badge-warning','completed':'badge-info','overdue':'badge-danger'} %}<span class="badge {{ sc.get(obj.status,'badge-secondary') }}">{% for val, label in objective_statuses %}{% if val == obj.status %}{{ label }}{% endif %}{% endfor %}</span></td>
<td>{% if obj.kpi_target is not none %}{{ obj.kpi_target }}{% if obj.kpi_unit %}{{ obj.kpi_unit }}{% endif %}{% else %}<span class="text-muted">-</span>{% endif %}</td>
<td><div class="progress-bar-container"><div class="progress-bar" style="width:{{ obj.progress }}%"></div></div><small>{{ obj.progress }}%</small></td>
<td>{% if obj.due_date %}{{ obj.due_date.strftime('%Y-%m-%d') }}{% endif %}</td>
{% if can_edit %}<td class="actions-cell"><a href="{{ url_for('objectives.edit_objective', objective_id=obj.id) }}" class="btn btn-sm btn-outline-primary" title="{{ _('Edit') }}"><i class="fas fa-edit"></i></a> <button class="btn btn-sm btn-outline-danger" onclick="showDeleteModal({{ obj.id }}, '{{ obj.title|e }}')" title="{{ _('Delete') }}"><i class="fas fa-trash"></i></button></td>{% endif %}
</tr>{% else %}
<tr><td colspan="{{ 9 if can_edit else 8 }}" class="text-center"><div class="empty-state"><i class="fas fa-bullseye"></i><h3>{{ _('No objectives yet') }}</h3><p>{{ _('Create your first objective to start tracking player evolution.') }}</p></div></td></tr>{% endfor %}
</tbody></table></div>{{ pager.controls(pagination) }}</div></div>
<div id="deleteModal" class="modal hidden"><div class="modal-backdrop" onclick="hideDeleteModal();"></div><div class="modal-content"><div class="modal-header"><h3><i class="fas fa-trash-alt"></i> {{ _('Delete Objective') }}</h3><button class="modal-close" onclick="hideDeleteModal();">&times;</button></div><div class="modal-body"><p>{{ _('Are you sure you want to delete this objective?') }}</p><p><strong id="deleteObjTitle"></strong></p><form id="deleteForm" method="POST" action=""><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="form-actions"><button type="button" class="btn btn-secondary" onclick="hideDeleteModal();">{{ _('Cancel') }}</button><button type="submit" class="btn btn-danger"><i class="fas fa-trash"></i> {{ _('Delete') }}</button></div></form></div></div></div>
{% endblock %}
{% block scripts %}<script>
function showDeleteModal(i,t){document.getElementById('deleteObjTitle').textContent=t;document.getElementById('deleteForm').action='{{ url_for("objectives.delete_objective", objective_id=0) }}'.replace('/0/','/'+i+'/');document.getElementById('deleteModal').classList.remove('hidden');}
function hideDeleteModal(){document.getElementById('deleteModal').classList.add('hidden');}
document.addEventListener('keydown',function(e){if(e.key==='Escape'){hideDeleteModal();}});
</script>{% endblock %}
'''
with open('app/templates/pages/objectives.html', 'a', encoding='utf-8') as f:
f.write(tmpl2)
print('LIST_PAGE_DONE')
+69
View File
@@ -0,0 +1,69 @@
import pathlib
t = """{% extends "layouts/base.html" %}
{% block title %}{{ objective.title }} - UdeS team manager{% endblock %}
{% block page_title %}{{ objective.title }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">{{ _('Home') }} / <a href="{{ url_for('objectives.list_objectives') }}">{{ _('Objectives') }}</a> / {{ objective.title }}</span>{% endblock %}
{% block content %}
<div class="row">
<div class="col-8">
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-bullseye"></i> {{ objective.title }}</h3></div>
<div class="card-body">
<p>{{ objective.description or '' }}</p>
<div class="preview-stats">
<div class="preview-stat"><span class="preview-label">{{ _('Type') }}</span><span class="preview-value">{% for val, label in objective_types %}{% if val == objective.type %}{{ label }}{% endif %}{% endfor %}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Assigned to') }}</span><span class="preview-value">{% if objective.player %}{{ objective.player.username }}{% elif objective.team %}{{ objective.team.name }}{% else %}-{% endif %}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Category') }}</span><span class="preview-value">{{ objective.category or '-' }}</span></div>
</div>
<div class="mt-3">
<span id="statusBadge" class="badge">{% for val, label in objective_statuses %}{% if val == objective.status %}{{ label }}{% endif %}{% endfor %}</span>
<span id="priorityBadge" class="badge">{% set pl = {'high':_('High'),'medium':_('Medium'),'low':_('Low')} %}{{ pl.get(objective.priority,objective.priority) }}</span>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-chart-line"></i> {{ _('KPI Progression') }}</h3></div>
<div class="card-body">
<svg id="progressChart" viewBox="0 0 400 200" style="width:100%;height:auto;" xmlns="http://www.w3.org/2000/svg"></svg>
{% if objective.kpi_target %}<div class="mt-2 text-center" id="predictionInfo"></div>{% endif %}
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-database"></i> {{ _('KPI Measurements') }}</h3></div>
<div class="card-body">
<form method="POST" action="{{ url_for('objectives.add_entry', objective_id=objective.id) }}" class="form-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group" style="flex:1;"><input type="number" step="0.01" name="value" class="form-input" placeholder="{{ _('Value (optional)') }}"></div>
<div class="form-group" style="flex:2;"><input type="text" name="note" class="form-input" placeholder="{{ _('Note or comment') }}"></div>
<button type="submit" class="btn btn-sm btn-primary"><i class="fas fa-plus"></i> {{ _('Record') }}</button>
</form>
{% if entries %}<table class="table mt-2"><thead><tr><th>{{ _('Date') }}</th><th>{{ _('Value') }}</th><th>{{ _('Note') }}</th></tr></thead><tbody>
{% for e in entries %}<tr><td>{{ e.recorded_at.strftime('%%Y-%%m-%%d %%H:%%M') }}</td><td><strong>{% if e.value is not none %}{{ e.value }}{% else %}-{% endif %}</strong></td><td class="text-muted small">{{ e.note or '' }}</td></tr>{% endfor %}</tbody></table>{% else %}<p class="text-muted mt-2">{{ _('No measurements recorded yet.') }}</p>{% endif %}
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-comments"></i> {{ _('Comments') }}</h3></div>
<div class="card-body">{% if comments %}<div class="comments-list">{% for c in comments %}<div class="comment-item"><strong>{{ c.coach.username }}</strong> <span class="text-muted small">{{ c.created_at.strftime('%%Y-%%m-%%d %%H:%%M') }}</span><p>{{ c.content }}</p></div>{% endfor %}</div>{% else %}<p class="text-muted">{{ _('No comments yet.') }}</p>{% endif %}</div>
</div>
</div>
<div class="col-4">
<div class="card mb-4">
<div class="card-header"><h3><i class="fas fa-info-circle"></i> {{ _('Details') }}</h3></div>
<div class="card-body">
<div class="preview-stats">
<div class="preview-stat"><span class="preview-label">{{ _('Priority') }}</span><span class="preview-value">{% set pl = {'high':_('High'),'medium':_('Medium'),'low':_('Low')} %}{{ pl.get(objective.priority,objective.priority) }}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Status') }}</span><span class="preview-value">{% for val, label in objective_statuses %}{% if val == objective.status %}{{ label }}{% endif %}{% endfor %}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Start date') }}</span><span class="preview-value">{{ objective.start_date.strftime('%%Y-%%m-%%d') if objective.start_date else '-' }}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Deadline') }}</span><span class="preview-value">{{ objective.due_date.strftime('%%Y-%%m-%%d') if objective.due_date else '-' }}</span></div>
<div class="preview-stat"><span class="preview-label">{{ _('Progress') }}</span><span class="preview-value">{{ objective.progress }}%</span></div>
</div>
{% if objective.kpi_target %}<div class="progress-section mt-2"><span class="preview-label">{{ _('KPI Target') }}</span><strong>{{ objective.kpi_target }}{% if objective.kpi_unit %}{{ objective.kpi_unit }}{% endif %}</strong></div>{% endif %}
{% if can_edit %}<a href="{{ url_for('objectives.edit_objective', objective_id=objective.id) }}" class="btn btn-sm btn-primary mt-3"><i class="fas fa-edit"></i> {{ _('Edit') }}</a>{% endif %}
</div>
</div>
{% if personal_notes or team_notes %}<div class="card mb-4"><div class="card-header"><h3><i class="fas fa-sticky-note"></i> {{ _('Related Notes') }}</h3></div><div class="card-body">{% if personal_notes %}<h5>{{ _('Personal Notes') }}</h5>{% for n in personal_notes %}<div class="comment-item"><strong>{{ n.coach.username }}</strong> <span class="text-muted small">{{ n.created_at.strftime('%%Y-%%m-%%d') }}</span><p>{{ n.content[:200] }}</p></div>{% endfor %}{% endif %}{% if team_notes %}<h5 class="mt-2">{{ _('Team Notes') }}</h5>{% for n in team_notes %}<div class="comment-item"><strong>{{ n.coach.username }}</strong> <span class="text-muted small">{{ n.created_at.strftime('%%Y-%%m-%%d') }}</span><p>{{ n.content[:200] }}</p></div>{% endfor %}{% endif %}</div></div>{% endif %}
</div>
</div>{% endblock %}
"""
pathlib.Path('app/templates/pages/objective_view.html').write_text(t, encoding='utf-8')
print('OK')