From 4233175067be39236ba65acb6c519855cdd0d14b Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Wed, 15 Apr 2026 13:18:50 +0200 Subject: [PATCH] feat: add shared task assignments and quick win sorting --- app/__init__.py | 2 +- app/forms.py | 4 + app/models.py | 50 +++++++++++- app/routes/settings.py | 31 ++++++- app/routes/tasks.py | 77 ++++++++++++++--- app/services/badges.py | 9 +- app/services/bootstrap.py | 30 +++++++ app/services/calendar_feeds.py | 9 +- app/services/tasks.py | 21 ++++- app/static/css/style.css | 32 ++++++++ app/static/js/app.js | 109 +++++++++++++++++++++++-- app/templates/base.html | 11 +-- app/templates/partials/macros.html | 20 +++-- app/templates/settings/quick_wins.html | 10 ++- app/templates/tasks/all_tasks.html | 4 +- app/templates/tasks/calendar.html | 8 +- app/templates/tasks/my_tasks.html | 35 +++++++- app/templates/tasks/task_form.html | 7 ++ 18 files changed, 414 insertions(+), 55 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 8ffb0a2..ead05df 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -76,7 +76,7 @@ def create_app(config_class: type[Config] = Config) -> Flask: (key, values["label"]) for key, values in quick_task_config.items() ] - quick_wins = QuickWin.query.filter_by(active=True).order_by(QuickWin.id.asc()).all() + quick_wins = QuickWin.query.filter_by(active=True).order_by(QuickWin.sort_order.asc(), QuickWin.id.asc()).all() def asset_version(filename: str) -> int: path = Path(app.static_folder) / filename try: diff --git a/app/forms.py b/app/forms.py index 43ff754..140778d 100644 --- a/app/forms.py +++ b/app/forms.py @@ -48,6 +48,7 @@ class TaskForm(FlaskForm): description = TextAreaField("Beschreibung", validators=[Optional(), Length(max=2000)]) default_points = IntegerField("Punkte", validators=[DataRequired(), NumberRange(min=1, max=500)], default=10) assigned_user_id = SelectField("Zugewiesen an", coerce=int, validators=[DataRequired()]) + assigned_user_secondary_id = SelectField("Zweite Person", coerce=int, validators=[Optional()], default=0) due_date = DateField("Fälligkeitsdatum", format="%Y-%m-%d", validators=[DataRequired()]) recurrence_interval_value = IntegerField( "Intervallwert", @@ -74,6 +75,9 @@ class TaskForm(FlaskForm): if self.recurrence_interval_unit.data != "none" and not self.recurrence_interval_value.data: self.recurrence_interval_value.errors.append("Bitte gib einen Intervallwert an.") return False + if self.assigned_user_secondary_id.data and self.assigned_user_secondary_id.data == self.assigned_user_id.data: + self.assigned_user_secondary_id.errors.append("Bitte wähle hier eine andere Person oder keine zweite Person.") + return False return True diff --git a/app/models.py b/app/models.py index dca33f2..e2061ac 100644 --- a/app/models.py +++ b/app/models.py @@ -35,12 +35,24 @@ class User(UserMixin, TimestampMixin, db.Model): backref="default_assigned_user", lazy=True, ) + secondary_assigned_task_templates = db.relationship( + "TaskTemplate", + foreign_keys="TaskTemplate.default_assigned_user_secondary_id", + backref="default_assigned_user_secondary", + lazy=True, + ) assigned_tasks = db.relationship( "TaskInstance", foreign_keys="TaskInstance.assigned_user_id", backref="assigned_user", lazy=True, ) + secondary_assigned_tasks = db.relationship( + "TaskInstance", + foreign_keys="TaskInstance.assigned_user_secondary_id", + backref="assigned_user_secondary", + lazy=True, + ) completed_tasks = db.relationship( "TaskInstance", foreign_keys="TaskInstance.completed_by_user_id", @@ -87,6 +99,7 @@ class TaskTemplate(TimestampMixin, db.Model): description = db.Column(db.Text, nullable=True) default_points = db.Column(db.Integer, nullable=False, default=10) default_assigned_user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + default_assigned_user_secondary_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) recurrence_interval_value = db.Column(db.Integer, nullable=True) recurrence_interval_unit = db.Column(db.String(20), nullable=False, default="none") active = db.Column(db.Boolean, nullable=False, default=True) @@ -113,6 +126,7 @@ class QuickWin(TimestampMixin, db.Model): effort = db.Column(db.String(40), nullable=False, index=True) active = db.Column(db.Boolean, nullable=False, default=True) created_by_user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True) + sort_order = db.Column(db.Integer, nullable=False, default=0, index=True) class TaskInstance(TimestampMixin, db.Model): @@ -121,6 +135,7 @@ class TaskInstance(TimestampMixin, db.Model): title = db.Column(db.String(160), nullable=False) description = db.Column(db.Text, nullable=True) assigned_user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True, index=True) + assigned_user_secondary_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True, index=True) due_date = db.Column(db.Date, nullable=False, index=True) status = db.Column(db.String(20), nullable=False, default="open", index=True) completed_at = db.Column(db.DateTime, nullable=True, index=True) @@ -131,21 +146,50 @@ class TaskInstance(TimestampMixin, db.Model): def is_completed(self) -> bool: return self.completed_at is not None + @property + def assigned_users(self) -> list[User]: + users: list[User] = [] + if self.assigned_user: + users.append(self.assigned_user) + if self.assigned_user_secondary and self.assigned_user_secondary.id not in {user.id for user in users}: + users.append(self.assigned_user_secondary) + return users + + @property + def assigned_user_ids(self) -> list[int]: + return [user.id for user in self.assigned_users] + + @property + def is_shared_assignment(self) -> bool: + return self.assigned_user_id is not None and self.assigned_user_secondary_id is not None + + @property + def assignee_label(self) -> str: + if not self.assigned_users: + return "Ohne Person" + return " & ".join(user.name for user in self.assigned_users) + def compute_status(self, reference_date: date | None = None) -> str: reference_date = reference_date or date.today() if self.completed_at: return "completed" if self.due_date < reference_date: return "overdue" - if self.due_date <= reference_date + timedelta(days=2): - return "soon" + if self.due_date == reference_date: + return "due_today" + if self.due_date == reference_date + timedelta(days=1): + return "due_tomorrow" + if self.due_date == reference_date + timedelta(days=2): + return "due_day_after_tomorrow" return "open" @property def status_label(self) -> str: labels = { "open": "Offen", - "soon": "Bald fällig", + "due_today": "Bald fällig", + "due_tomorrow": "Morgen fällig", + "due_day_after_tomorrow": "Übermorgen fällig", "overdue": "Überfällig", "completed": "Erledigt", } diff --git a/app/routes/settings.py b/app/routes/settings.py index fa3259f..0a9f507 100644 --- a/app/routes/settings.py +++ b/app/routes/settings.py @@ -5,6 +5,7 @@ from uuid import uuid4 from flask import Blueprint, current_app, flash, jsonify, redirect, render_template, request, url_for from flask_login import current_user, login_required +from sqlalchemy import func from werkzeug.utils import secure_filename from ..extensions import csrf, db @@ -109,6 +110,7 @@ def quick_wins(): effort=quick_win_form.effort.data, active=True, created_by_user_id=current_user.id, + sort_order=(db.session.query(func.max(QuickWin.sort_order)).scalar() or -1) + 1, ) db.session.add(quick_win) db.session.commit() @@ -120,7 +122,7 @@ def quick_wins(): quick_win_form=quick_win_form, quick_task_config_form=quick_task_config_form, quick_task_config=quick_task_config, - quick_wins=QuickWin.query.filter_by(active=True).order_by(QuickWin.id.asc()).all(), + quick_wins=QuickWin.query.filter_by(active=True).order_by(QuickWin.sort_order.asc(), QuickWin.id.asc()).all(), settings_tabs=_settings_tabs(), active_settings_tab="settings.quick_wins", ) @@ -268,6 +270,31 @@ def update_quick_win(quick_win_id: int): return redirect(url_for("settings.quick_wins")) +@bp.route("/quick-wins/reorder", methods=["POST"]) +@login_required +@csrf.exempt +def reorder_quick_wins(): + payload = request.get_json(silent=True) or {} + raw_ids = payload.get("ids", []) + ordered_ids = [int(item) for item in raw_ids if str(item).isdigit()] + + quick_wins = QuickWin.query.filter_by(active=True).all() + quick_wins_by_id = {quick_win.id: quick_win for quick_win in quick_wins} + + for position, quick_win_id in enumerate(ordered_ids): + quick_win = quick_wins_by_id.get(quick_win_id) + if quick_win: + quick_win.sort_order = position + + used_ids = set(ordered_ids) + remaining = [quick_win for quick_win in quick_wins if quick_win.id not in used_ids] + for offset, quick_win in enumerate(sorted(remaining, key=lambda item: (item.sort_order, item.id)), start=len(ordered_ids)): + quick_win.sort_order = offset + + db.session.commit() + return jsonify({"ok": True}) + + @bp.route("/users//toggle-admin", methods=["POST"]) @login_required def toggle_admin(user_id: int): @@ -308,7 +335,9 @@ def delete_user(user_id: int): return redirect(url_for("settings.index")) TaskTemplate.query.filter_by(default_assigned_user_id=user.id).update({"default_assigned_user_id": None}) + TaskTemplate.query.filter_by(default_assigned_user_secondary_id=user.id).update({"default_assigned_user_secondary_id": None}) TaskInstance.query.filter_by(assigned_user_id=user.id).update({"assigned_user_id": None}) + TaskInstance.query.filter_by(assigned_user_secondary_id=user.id).update({"assigned_user_secondary_id": None}) TaskInstance.query.filter_by(completed_by_user_id=user.id).update({"completed_by_user_id": None}) MonthlyScoreSnapshot.query.filter_by(user_id=user.id).delete() NotificationLog.query.filter_by(user_id=user.id).delete() diff --git a/app/routes/tasks.py b/app/routes/tasks.py index 9e68a9f..f2c1cf2 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -6,6 +6,7 @@ from datetime import date from flask import Blueprint, flash, redirect, render_template, request, url_for from flask_login import current_user, login_required +from sqlalchemy import or_ from ..forms import QuickTaskForm, TaskForm from ..models import QuickWin, TaskInstance, User @@ -28,22 +29,44 @@ def _user_choices() -> list[tuple[int, str]]: return [(user.id, user.name) for user in User.query.order_by(User.name.asc()).all()] +def _secondary_user_choices() -> list[tuple[int, str]]: + return [(0, "Keine zweite Person")] + _user_choices() + + @bp.route("/my-tasks") @login_required def my_tasks(): tasks = ( - TaskInstance.query.filter_by(assigned_user_id=current_user.id) + TaskInstance.query.filter( + or_( + TaskInstance.assigned_user_id == current_user.id, + TaskInstance.assigned_user_secondary_id == current_user.id, + ) + ) .order_by(TaskInstance.completed_at.is_(None).desc(), TaskInstance.due_date.asc()) .all() ) refresh_task_statuses(tasks) - sections = {"open": [], "soon": [], "overdue": [], "completed": []} + sections = { + "open": [], + "due_today": [], + "due_tomorrow": [], + "due_day_after_tomorrow": [], + "overdue": [], + "completed": [], + } for task in tasks: sections[task.status].append(task) completed_count = len(sections["completed"]) - active_count = len(sections["open"]) + len(sections["soon"]) + len(sections["overdue"]) + active_count = ( + len(sections["open"]) + + len(sections["due_today"]) + + len(sections["due_tomorrow"]) + + len(sections["due_day_after_tomorrow"]) + + len(sections["overdue"]) + ) completion_ratio = 0 if completed_count + active_count == 0 else round(completed_count / (completed_count + active_count) * 100) return render_template( @@ -64,9 +87,19 @@ def all_tasks(): sort = request.args.get("sort", "due") if mine == "1": - query = query.filter(TaskInstance.assigned_user_id == current_user.id) + query = query.filter( + or_( + TaskInstance.assigned_user_id == current_user.id, + TaskInstance.assigned_user_secondary_id == current_user.id, + ) + ) elif user_filter: - query = query.filter(TaskInstance.assigned_user_id == user_filter) + query = query.filter( + or_( + TaskInstance.assigned_user_id == user_filter, + TaskInstance.assigned_user_secondary_id == user_filter, + ) + ) if sort == "points": query = query.order_by(TaskInstance.points_awarded.desc(), TaskInstance.due_date.asc()) @@ -79,10 +112,20 @@ def all_tasks(): refresh_task_statuses(tasks) if status != "all": - status_map = {"completed": "completed", "overdue": "overdue", "open": "open", "soon": "soon"} - selected = status_map.get(status) - if selected: - tasks = [task for task in tasks if task.status == selected] + if status == "soon": + tasks = [task for task in tasks if task.status in {"due_today", "due_tomorrow", "due_day_after_tomorrow"}] + else: + status_map = { + "completed": "completed", + "overdue": "overdue", + "open": "open", + "today": "due_today", + "tomorrow": "due_tomorrow", + "day_after_tomorrow": "due_day_after_tomorrow", + } + selected = status_map.get(status) + if selected: + tasks = [task for task in tasks if task.status == selected] return render_template( "tasks/all_tasks.html", @@ -97,6 +140,7 @@ def all_tasks(): def create(): form = TaskForm() form.assigned_user_id.choices = _user_choices() + form.assigned_user_secondary_id.choices = _secondary_user_choices() if request.method == "GET" and not form.due_date.data: form.due_date.data = today_local() @@ -158,13 +202,15 @@ def edit(task_id: int): task = TaskInstance.query.get_or_404(task_id) form = TaskForm(obj=task.task_template) form.assigned_user_id.choices = _user_choices() + form.assigned_user_secondary_id.choices = _secondary_user_choices() next_url = request.args.get("next") or request.form.get("next") or request.referrer or url_for("tasks.all_tasks") if request.method == "GET": form.title.data = task.title form.description.data = task.description - form.default_points.data = task.points_awarded + form.default_points.data = task.task_template.default_points form.assigned_user_id.data = task.assigned_user_id or _user_choices()[0][0] + form.assigned_user_secondary_id.data = task.assigned_user_secondary_id or 0 form.due_date.data = task.due_date form.recurrence_interval_value.data = task.task_template.recurrence_interval_value or 1 form.recurrence_interval_unit.data = task.task_template.recurrence_interval_unit @@ -200,8 +246,15 @@ def complete(task_id: int): return redirect(request.referrer or url_for("tasks.my_tasks")) completed_by_id = current_user.id - if task.assigned_user_id and task.assigned_user_id != current_user.id and choice == "assigned": - completed_by_id = task.assigned_user_id + allowed_ids = {current_user.id} + if task.assigned_user_id: + allowed_ids.add(task.assigned_user_id) + if task.assigned_user_secondary_id: + allowed_ids.add(task.assigned_user_secondary_id) + if choice != "me": + selected_user_id = request.form.get("completed_for", type=int) + if selected_user_id in allowed_ids: + completed_by_id = selected_user_id complete_task(task, completed_by_id) flash("Punkte verbucht. Gute Arbeit.", "success") diff --git a/app/services/badges.py b/app/services/badges.py index f57d34c..9cd9184 100644 --- a/app/services/badges.py +++ b/app/services/badges.py @@ -4,7 +4,7 @@ import json from collections import defaultdict from datetime import date, datetime, time, timedelta -from sqlalchemy import and_ +from sqlalchemy import and_, or_ from ..extensions import db from ..models import BadgeDefinition, MonthlyScoreSnapshot, TaskInstance, User, UserBadge @@ -92,7 +92,7 @@ def _completion_metrics(user: User) -> dict[str, int]: metrics["on_time_tasks_completed"] += 1 if completion_day <= task.due_date - timedelta(days=1): metrics["early_tasks_completed"] += 1 - if task.assigned_user_id and task.assigned_user_id != user.id: + if task.assigned_user_ids and user.id not in task.assigned_user_ids: metrics["foreign_tasks_completed"] += 1 max_points = max(max_points, task.points_awarded) @@ -127,7 +127,10 @@ def _user_had_clean_month(user_id: int, year: int, month: int) -> bool: start_date = date(year, month, 1) end_date = (month_bounds(year, month)[1] - timedelta(days=1)).date() tasks = TaskInstance.query.filter( - TaskInstance.assigned_user_id == user_id, + or_( + TaskInstance.assigned_user_id == user_id, + TaskInstance.assigned_user_secondary_id == user_id, + ), TaskInstance.due_date >= start_date, TaskInstance.due_date <= end_date, ).all() diff --git a/app/services/bootstrap.py b/app/services/bootstrap.py index cb3c536..fca3d5c 100644 --- a/app/services/bootstrap.py +++ b/app/services/bootstrap.py @@ -21,6 +21,21 @@ def ensure_schema_and_admins() -> None: db.session.execute(text("ALTER TABLE user ADD COLUMN calendar_feed_token VARCHAR(255)")) db.session.commit() + task_template_columns = {column["name"] for column in inspector.get_columns("task_template")} + if "default_assigned_user_secondary_id" not in task_template_columns: + db.session.execute(text("ALTER TABLE task_template ADD COLUMN default_assigned_user_secondary_id INTEGER")) + db.session.commit() + + task_instance_columns = {column["name"] for column in inspector.get_columns("task_instance")} + if "assigned_user_secondary_id" not in task_instance_columns: + db.session.execute(text("ALTER TABLE task_instance ADD COLUMN assigned_user_secondary_id INTEGER")) + db.session.commit() + + quick_win_columns = {column["name"] for column in inspector.get_columns("quick_win")} + if "sort_order" not in quick_win_columns: + db.session.execute(text("ALTER TABLE quick_win ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0")) + db.session.commit() + ensure_app_settings() users_without_feed = User.query.filter(User.calendar_feed_token.is_(None)).all() @@ -46,6 +61,7 @@ def ensure_schema_and_admins() -> None: default_quick_win_user = first_user _ensure_default_quick_wins(default_quick_win_user or User.query.order_by(User.id.asc()).first()) + _ensure_quick_win_ordering() def _ensure_default_quick_wins(default_user: User | None) -> None: @@ -62,6 +78,7 @@ def _ensure_default_quick_wins(default_user: User | None) -> None: existing_titles = {quick_win.title for quick_win in QuickWin.query.all()} created = False + next_sort_order = QuickWin.query.count() for title, effort in defaults: if title not in existing_titles: db.session.add( @@ -70,8 +87,21 @@ def _ensure_default_quick_wins(default_user: User | None) -> None: effort=effort, active=True, created_by_user_id=default_user.id, + sort_order=next_sort_order, ) ) + next_sort_order += 1 created = True if created: db.session.commit() + + +def _ensure_quick_win_ordering() -> None: + quick_wins = QuickWin.query.order_by(QuickWin.sort_order.asc(), QuickWin.id.asc()).all() + dirty = False + for index, quick_win in enumerate(quick_wins): + if quick_win.sort_order != index: + quick_win.sort_order = index + dirty = True + if dirty: + db.session.commit() diff --git a/app/services/calendar_feeds.py b/app/services/calendar_feeds.py index 4a116b3..7d1e8d2 100644 --- a/app/services/calendar_feeds.py +++ b/app/services/calendar_feeds.py @@ -2,6 +2,8 @@ from __future__ import annotations from datetime import UTC, datetime, time, timedelta +from sqlalchemy import or_ + from ..models import TaskInstance, User @@ -23,7 +25,12 @@ def _format_timestamp(value: datetime | None) -> str: def build_calendar_feed(user: User, base_url: str) -> str: tasks = ( - TaskInstance.query.filter_by(assigned_user_id=user.id) + TaskInstance.query.filter( + or_( + TaskInstance.assigned_user_id == user.id, + TaskInstance.assigned_user_secondary_id == user.id, + ) + ) .order_by(TaskInstance.due_date.asc(), TaskInstance.id.asc()) .all() ) diff --git a/app/services/tasks.py b/app/services/tasks.py index bd8f7f3..0af4b8a 100644 --- a/app/services/tasks.py +++ b/app/services/tasks.py @@ -25,12 +25,19 @@ def refresh_task_statuses(tasks: list[TaskInstance]) -> None: db.session.commit() +def effective_points(base_points: int, assigned_user_secondary_id: int | None) -> int: + if assigned_user_secondary_id: + return max(1, base_points // 2) + return base_points + + def create_task_template_and_instance(form) -> TaskInstance: template = TaskTemplate( title=form.title.data.strip(), description=(form.description.data or "").strip(), default_points=form.default_points.data, default_assigned_user_id=form.assigned_user_id.data, + default_assigned_user_secondary_id=form.assigned_user_secondary_id.data or None, recurrence_interval_value=form.recurrence_interval_value.data if form.recurrence_interval_unit.data != "none" else None, recurrence_interval_unit=form.recurrence_interval_unit.data, active=form.active.data, @@ -43,8 +50,9 @@ def create_task_template_and_instance(form) -> TaskInstance: title=template.title, description=template.description, assigned_user_id=template.default_assigned_user_id, + assigned_user_secondary_id=template.default_assigned_user_secondary_id, due_date=form.due_date.data, - points_awarded=template.default_points, + points_awarded=effective_points(template.default_points, template.default_assigned_user_secondary_id), status="open", ) refresh_task_status(task, form.due_date.data) @@ -59,6 +67,7 @@ def update_template_and_instance(task: TaskInstance, form) -> TaskInstance: template.description = (form.description.data or "").strip() template.default_points = form.default_points.data template.default_assigned_user_id = form.assigned_user_id.data + template.default_assigned_user_secondary_id = form.assigned_user_secondary_id.data or None template.recurrence_interval_unit = form.recurrence_interval_unit.data template.recurrence_interval_value = ( form.recurrence_interval_value.data if form.recurrence_interval_unit.data != "none" else None @@ -68,7 +77,8 @@ def update_template_and_instance(task: TaskInstance, form) -> TaskInstance: task.title = template.title task.description = template.description task.assigned_user_id = template.default_assigned_user_id - task.points_awarded = template.default_points + task.assigned_user_secondary_id = template.default_assigned_user_secondary_id + task.points_awarded = effective_points(template.default_points, template.default_assigned_user_secondary_id) task.due_date = form.due_date.data refresh_task_status(task, form.due_date.data) db.session.commit() @@ -108,8 +118,12 @@ def ensure_next_recurring_task(task: TaskInstance) -> TaskInstance | None: title=task.task_template.title, description=task.task_template.description, assigned_user_id=task.task_template.default_assigned_user_id, + assigned_user_secondary_id=task.task_template.default_assigned_user_secondary_id, due_date=next_due, - points_awarded=task.task_template.default_points, + points_awarded=effective_points( + task.task_template.default_points, + task.task_template.default_assigned_user_secondary_id, + ), status="open", ) refresh_task_status(next_task, today_local()) @@ -149,6 +163,7 @@ def create_quick_task(title: str, effort: str, creator: User, description: str = title=template.title, description=description, assigned_user_id=creator.id, + assigned_user_secondary_id=None, due_date=today_local(), points_awarded=template.default_points, status="open", diff --git a/app/static/css/style.css b/app/static/css/style.css index 37d4ca4..f1708df 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -473,6 +473,9 @@ p { color: #1d4ed8; } +.status-badge--due_today, +.status-badge--due_tomorrow, +.status-badge--due_day_after_tomorrow, .status-badge--soon { background: #fff3d6; color: #b45309; @@ -514,6 +517,16 @@ p { color: var(--muted); } +.task-assignee__avatars { + display: inline-flex; + align-items: center; +} + +.task-assignee__avatars .avatar + .avatar { + margin-left: -10px; + border: 2px solid var(--surface-strong); +} + .avatar { width: 34px; height: 34px; @@ -903,6 +916,9 @@ p { border-left: 4px solid #2563eb; } +.calendar-task--due_today, +.calendar-task--due_tomorrow, +.calendar-task--due_day_after_tomorrow, .calendar-task--soon { border-left: 4px solid #f59e0b; } @@ -1066,6 +1082,11 @@ p { .quick-win-manage-card { align-items: stretch; + cursor: move; +} + +.quick-win-manage-card.is-dragging { + opacity: 0.72; } .quick-win-manage-form { @@ -1073,6 +1094,14 @@ p { gap: 12px; } +.quick-win-manage-card__drag { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 0.9rem; +} + .quick-win-manage-card__actions { display: flex; gap: 10px; @@ -1427,6 +1456,9 @@ p { color: #8db7ff; } + .status-badge--due_today, + .status-badge--due_tomorrow, + .status-badge--due_day_after_tomorrow, .status-badge--soon { background: rgba(245, 158, 11, 0.18); color: #ffd38a; diff --git a/app/static/js/app.js b/app/static/js/app.js index 05822ce..bf80ada 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -3,6 +3,7 @@ const dialogForm = document.getElementById("completeDialogForm"); const dialogChoice = document.getElementById("completeDialogChoice"); const dialogText = document.getElementById("completeDialogText"); + const dialogChoices = document.getElementById("completeDialogChoices"); const closeButton = document.getElementById("completeDialogClose"); const quickTaskDialog = document.getElementById("quickTaskDialog"); const quickTaskOpen = document.getElementById("quickTaskOpen"); @@ -13,23 +14,55 @@ const quickWinCustomFields = document.getElementById("quickWinCustomFields"); const quickWinTitle = document.getElementById("quick-title"); const quickWinEffort = document.getElementById("quick-effort"); + const currentUserId = document.body.dataset.currentUserId; + const currentUserName = document.body.dataset.currentUserName; + const quickWinSortList = document.querySelector("[data-quick-win-sort-list]"); + const quickWinSortToken = document.getElementById("quickWinSortToken"); + let draggedQuickWin = null; + + function buildCompletionOptions(button) { + const options = []; + const assignedPairs = [ + [button.dataset.assignedPrimaryId, button.dataset.assignedPrimaryName], + [button.dataset.assignedSecondaryId, button.dataset.assignedSecondaryName], + ]; + + assignedPairs.forEach(([id, label]) => { + if (id && label && !options.some((option) => option.value === id)) { + options.push({ value: id, label }); + } + }); + + if (currentUserId && currentUserName && !options.some((option) => option.value === currentUserId)) { + options.push({ value: currentUserId, label: "Ich" }); + } + + return options; + } document.querySelectorAll("[data-complete-action]").forEach((button) => { button.addEventListener("click", () => { - if (!dialog || !dialogForm || !dialogChoice || !dialogText) { + if (!dialog || !dialogForm || !dialogChoice || !dialogText || !dialogChoices) { return; } dialogForm.action = button.dataset.completeAction; dialogText.textContent = `Die Aufgabe "${button.dataset.completeTitle}" war ${button.dataset.completeAssigned} zugewiesen. Wer hat sie erledigt?`; - dialog.showModal(); - }); - }); + dialogChoices.innerHTML = ""; - document.querySelectorAll("[data-complete-choice]").forEach((button) => { - button.addEventListener("click", () => { - dialogChoice.value = button.dataset.completeChoice || "me"; - dialog.close(); - dialogForm.submit(); + buildCompletionOptions(button).forEach((option, index) => { + const choiceButton = document.createElement("button"); + choiceButton.type = "button"; + choiceButton.className = index === 0 ? "button button--secondary" : "button"; + choiceButton.dataset.completeChoice = option.value; + choiceButton.textContent = option.label; + choiceButton.addEventListener("click", () => { + dialogChoice.value = option.value; + dialog.close(); + dialogForm.submit(); + }); + dialogChoices.appendChild(choiceButton); + }); + dialog.showModal(); }); }); @@ -87,6 +120,64 @@ }); } + async function persistQuickWinSort() { + if (!quickWinSortList || !quickWinSortToken) { + return; + } + const ids = [...quickWinSortList.querySelectorAll("[data-quick-win-sort-item]")] + .map((item) => item.dataset.quickWinSortItem) + .filter(Boolean); + + await fetch("/settings/quick-wins/reorder", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": quickWinSortToken.value, + }, + body: JSON.stringify({ ids }), + }); + } + + if (quickWinSortList) { + quickWinSortList.querySelectorAll("[data-quick-win-sort-item]").forEach((item) => { + item.addEventListener("dragstart", () => { + draggedQuickWin = item; + item.classList.add("is-dragging"); + }); + + item.addEventListener("dragend", () => { + item.classList.remove("is-dragging"); + draggedQuickWin = null; + }); + + item.addEventListener("dragover", (event) => { + event.preventDefault(); + }); + + item.addEventListener("drop", async (event) => { + event.preventDefault(); + if (!draggedQuickWin || draggedQuickWin === item) { + return; + } + + const items = [...quickWinSortList.querySelectorAll("[data-quick-win-sort-item]")]; + const draggedIndex = items.indexOf(draggedQuickWin); + const targetIndex = items.indexOf(item); + if (draggedIndex < targetIndex) { + item.after(draggedQuickWin); + } else { + item.before(draggedQuickWin); + } + + try { + await persistQuickWinSort(); + } catch (error) { + console.error("Quick-Win-Sortierung konnte nicht gespeichert werden", error); + } + }); + }); + } + const pushButton = document.getElementById("pushToggle"); const pushHint = document.getElementById("pushHint"); const vapidKey = document.body.dataset.pushKey; diff --git a/app/templates/base.html b/app/templates/base.html index ca8cb5e..fdc2935 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -16,7 +16,11 @@ - + {% from "partials/macros.html" import nav_icon %}
{% if current_user.is_authenticated %} @@ -119,10 +123,7 @@

Punkte fair verbuchen

Wer hat diese Aufgabe erledigt?

Bitte wähle aus, wem die Punkte gutgeschrieben werden sollen.

-
- - -
+
diff --git a/app/templates/partials/macros.html b/app/templates/partials/macros.html index cecb80d..8c42e15 100644 --- a/app/templates/partials/macros.html +++ b/app/templates/partials/macros.html @@ -48,7 +48,7 @@
{{ status_badge(task) }} - {{ task.points_awarded }} Punkte + {{ task.points_awarded }} Punkte{% if task.is_shared_assignment %} / Person{% endif %}

{{ task.title }}

@@ -68,7 +68,7 @@
Zuständig
-
{{ task.assigned_user.name if task.assigned_user else 'Unverteilt' }}
+
{{ task.assignee_label }}
Rhythmus
@@ -84,18 +84,26 @@