feat: add time report feature with CSV export

- Implemented a new time report page in the frontend that displays totals by agent, ticket, or account over a specified date range.
- Added a CSV export functionality for the time report, allowing users to download their report data.
- Created API endpoint for exporting time report data in CSV format.
- Developed data models and providers for managing time report data in the mobile application.
- Integrated time report navigation into the mobile app, allowing users to access the report from the timesheet screen.
- Added unit tests for time report data parsing and API request handling.
This commit is contained in:
Ashwin 2026-08-16 10:23:02 +05:30
parent 3726150a2e
commit d2a2d690fc
32 changed files with 3147 additions and 64 deletions

View file

@ -30,6 +30,7 @@ from cases.analytics import DEFAULT_SERVICE_DAYS
from cases.models import Case
from cases.serializer import CaseSerializer
from common.permissions import HasOrgContext
from common.renderers import CSV_RENDERERS
from common.validators import uuid_param
# ---------------------------------------------------------------------------
@ -272,6 +273,11 @@ def _iso_or_blank(value) -> str:
class AnalyticsExportView(_AnalyticsBaseView):
# This endpoint answers in CSV, so it has to say it accepts CSV: content
# negotiation runs before the handler, and `src/routes/api/cases/analytics/
# export/+server.js` sends `Accept: text/csv`, which was answered 406.
renderer_classes = CSV_RENDERERS
@extend_schema(
tags=["cases-analytics"],
parameters=_FILTER_PARAMS

View file

@ -16,7 +16,7 @@ from __future__ import annotations
import json
import logging
from crum import get_current_user
from crum import get_current_request
from django.contrib.contenttypes.models import ContentType
from django.db.models.signals import (
m2m_changed,
@ -61,12 +61,22 @@ _TRACKED_SCALAR_FIELDS = (
def _resolve_actor():
"""Return a Profile to record as Activity.user, or None for system actions."""
user = get_current_user()
if user is None or not getattr(user, "is_authenticated", False):
return None
profile = getattr(user, "profile", None)
return profile
"""Return a Profile to record as Activity.user, or None for system actions.
Read off the request, which `GetProfileAndOrg` has already resolved from
the JWT into the caller's Profile for this org. It used to read
``get_current_user().profile``, and ``Profile.user`` is a reverse FK named
``profiles``, so that attribute has never existed on User: the getattr
returned None on every request and the whole audit log was attributed to
"System". A user also has one Profile per org, so ``user.profile`` could
not have named the right one even if it resolved.
``request.profile`` is None for an anonymous request and for a customer
portal one (the portal's caller lands on ``request.portal_contact``, and a
Contact is not a Profile), so those still record as system actions.
"""
request = get_current_request()
return getattr(request, "profile", None)
def _jsonify(value):

View file

@ -172,6 +172,62 @@ class TestActivityFeedAPI:
assert response.status_code in (401, 403)
@pytest.mark.django_db
class TestTheActorIsRecorded:
"""Who did it, not "System".
`_resolve_actor` used to read `get_current_user().profile`, an attribute
that cannot exist (`Profile.user` is a reverse FK named `profiles`), so
every row in the audit log was written with `user=None` and every client
rendered the whole timeline as the work of a system account.
"""
def test_a_change_made_through_the_api_names_the_person_who_made_it(
self, admin_client, admin_profile, case_a
):
response = admin_client.patch(
f"/api/cases/{case_a.pk}/",
{"status": "Pending"},
content_type="application/json",
)
assert response.status_code == 200, response.content
row = (
_case_activities(case_a)
.filter(action="STATUS_CHANGED")
.latest("created_at")
)
assert row.user_id == admin_profile.id
def test_the_feed_serves_that_person_and_a_readable_verb(
self, admin_client, admin_profile, case_a
):
admin_client.patch(
f"/api/cases/{case_a.pk}/",
{"priority": "Urgent"},
content_type="application/json",
)
response = admin_client.get(f"/api/cases/{case_a.pk}/activities/")
assert response.status_code == 200
row = next(
r
for r in response.json()["activities"]
if r["action"] == "PRIORITY_CHANGED"
)
assert row["user"]["user_details"]["email"] == admin_profile.user.email
# The label off ACTION_CHOICES, so no client keeps its own verb list.
assert row["action_display"] == "Priority Changed"
def test_work_with_no_request_behind_it_stays_a_system_action(self, case_a):
from cases.signals import _create_activity
# No request in flight: a Celery task, a management command, the
# auto-stop sweep. Attributing those to a person would be a lie.
_create_activity(case_a, "UPDATE", {"changes": {}})
assert _case_activities(case_a).latest("created_at").user_id is None
@pytest.mark.django_db
class TestMetadataTruncation:
def test_oversized_metadata_truncated(self, admin_client, case_a, org_a):

View file

@ -0,0 +1,352 @@
"""Tests for the time reports: ``/api/time-entries/report/`` and its CSV.
Covers the grouping and the money in ``cases/time_reports.py``, the window and
filter parsing in ``_report_entries``, and the two rules that decide what a
caller may see: an agent reports on their own time, an admin on the org's.
"""
from __future__ import annotations
import csv
import io
from datetime import timedelta
from decimal import Decimal
import pytest
from crum import impersonate
from django.utils import timezone
from accounts.models import Account
from cases.models import Case, TimeEntry
REPORT = "/api/time-entries/report/"
EXPORT = "/api/time-entries/report/export/"
def _case(org, creator, name="Sample case", account=None):
with impersonate(creator):
return Case.objects.create(
name=name, status="New", priority="Normal", org=org, account=account
)
def _logged(case, profile, minutes, *, days_ago=0, billable=False, rate=None, **kwargs):
"""A stopped entry of `minutes`, ending `days_ago` days back."""
ended = timezone.now() - timedelta(days=days_ago)
return TimeEntry.objects.create(
org=case.org,
case=case,
profile=profile,
started_at=ended - timedelta(minutes=minutes),
ended_at=ended,
billable=billable,
hourly_rate=Decimal(rate) if rate is not None else None,
**kwargs,
)
@pytest.mark.django_db
class TestGrouping:
def test_groups_by_agent_with_the_billable_split_and_its_value(
self, admin_client, admin_user, admin_profile, user_profile, org_a
):
case = _case(org_a, admin_user)
_logged(case, admin_profile, 60, billable=True, rate="90")
_logged(case, admin_profile, 30)
_logged(case, user_profile, 45, billable=True, rate="60")
response = admin_client.get(REPORT, {"group_by": "agent"})
assert response.status_code == 200
body = response.json()
assert body["group_by"] == "agent"
# Ordered by time logged, so the 90 minutes comes first.
assert [r["total_minutes"] for r in body["rows"]] == [90, 45]
first = body["rows"][0]
assert first["billable_minutes"] == 60
assert first["billable_value"] == "90.00"
assert first["entry_count"] == 2
assert body["totals"] == {
"total_minutes": 135,
"billable_minutes": 105,
# 90 for the hour, plus 45 minutes at 60/hr.
"billable_value": "135.00",
"entry_count": 3,
}
def test_groups_by_ticket(self, admin_client, admin_user, admin_profile, org_a):
first = _case(org_a, admin_user, name="Printer down")
second = _case(org_a, admin_user, name="Password reset")
_logged(first, admin_profile, 25)
_logged(second, admin_profile, 50)
rows = admin_client.get(REPORT, {"group_by": "ticket"}).json()["rows"]
assert [(r["name"], r["total_minutes"]) for r in rows] == [
("Password reset", 50),
("Printer down", 25),
]
def test_groups_by_account_and_names_the_unattributed_bucket(
self, admin_client, admin_user, admin_profile, org_a
):
account = Account.objects.create(name="Reyes & Co", org=org_a)
_logged(_case(org_a, admin_user, account=account), admin_profile, 40)
# A ticket with no account still logged time, and time nobody can bill
# is the thing this report exists to surface, so it is named, not
# dropped.
_logged(_case(org_a, admin_user, name="Internal"), admin_profile, 20)
rows = admin_client.get(REPORT, {"group_by": "account"}).json()["rows"]
assert [(r["name"], r["key"] is None) for r in rows] == [
("Reyes & Co", False),
("No account", True),
]
def test_a_running_timer_is_not_counted_until_it_is_stopped(
self, admin_client, admin_user, admin_profile, org_a
):
case = _case(org_a, admin_user)
_logged(case, admin_profile, 30)
TimeEntry.objects.create(
org=org_a, case=case, profile=admin_profile, started_at=timezone.now()
)
body = admin_client.get(REPORT).json()
assert body["totals"]["total_minutes"] == 30
assert body["totals"]["entry_count"] == 1
def test_names_the_currencies_in_range_so_a_mixed_total_can_be_flagged(
self, admin_client, admin_user, admin_profile, org_a
):
case = _case(org_a, admin_user)
_logged(case, admin_profile, 60, billable=True, rate="10", currency="USD")
_logged(case, admin_profile, 60, billable=True, rate="10", currency="EUR")
body = admin_client.get(REPORT).json()
assert body["currencies"] == ["EUR", "USD"]
@pytest.mark.django_db
class TestWindowAndFilters:
def test_defaults_to_the_last_30_days(
self, admin_client, admin_user, admin_profile, org_a
):
case = _case(org_a, admin_user)
_logged(case, admin_profile, 15, days_ago=2)
_logged(case, admin_profile, 99, days_ago=45)
body = admin_client.get(REPORT).json()
assert body["totals"]["total_minutes"] == 15
assert body["start"] == (timezone.localdate() - timedelta(days=29)).isoformat()
assert body["end"] == timezone.localdate().isoformat()
def test_an_explicit_window_is_inclusive_at_both_ends(
self, admin_client, admin_user, admin_profile, org_a
):
case = _case(org_a, admin_user)
_logged(case, admin_profile, 20, days_ago=3)
today = timezone.localdate()
inside = admin_client.get(
REPORT,
{
"start": (today - timedelta(days=3)).isoformat(),
"end": (today - timedelta(days=3)).isoformat(),
},
).json()
outside = admin_client.get(
REPORT,
{
"start": (today - timedelta(days=2)).isoformat(),
"end": today.isoformat(),
},
).json()
assert inside["totals"]["total_minutes"] == 20
assert outside["totals"]["total_minutes"] == 0
def test_filters_by_account_and_by_billable(
self, admin_client, admin_user, admin_profile, org_a
):
account = Account.objects.create(name="Reyes & Co", org=org_a)
billed = _case(org_a, admin_user, account=account)
_logged(billed, admin_profile, 60, billable=True, rate="50")
_logged(billed, admin_profile, 15)
_logged(_case(org_a, admin_user, name="Internal"), admin_profile, 30)
by_account = admin_client.get(REPORT, {"account": str(account.id)}).json()
billable_only = admin_client.get(
REPORT, {"account": str(account.id), "billable": "true"}
).json()
assert by_account["totals"]["total_minutes"] == 75
assert billable_only["totals"]["total_minutes"] == 60
def test_rejects_a_malformed_date_a_backwards_window_and_a_bad_flag(
self, admin_client
):
assert (
admin_client.get(
REPORT, {"start": "16-08-2026", "end": "2026-08-16"}
).status_code
== 400
)
assert (
admin_client.get(
REPORT, {"start": "2026-08-16", "end": "2026-08-01"}
).status_code
== 400
)
assert admin_client.get(REPORT, {"billable": "yes"}).status_code == 400
assert admin_client.get(REPORT, {"group_by": "planet"}).status_code == 400
def test_unauthenticated_is_refused(self, unauthenticated_client):
assert unauthenticated_client.get(REPORT).status_code in (401, 403)
@pytest.mark.django_db
class TestWhoMaySeeWhat:
def test_an_agent_reports_on_their_own_time_only(
self, user_client, admin_user, admin_profile, user_profile, org_a
):
case = _case(org_a, admin_user)
_logged(case, admin_profile, 60)
_logged(case, user_profile, 20)
body = user_client.get(REPORT).json()
assert body["totals"]["total_minutes"] == 20
assert [r["total_minutes"] for r in body["rows"]] == [20]
def test_an_agent_asking_for_someone_elses_time_is_refused_not_emptied(
self, user_client, admin_profile
):
response = user_client.get(REPORT, {"profile": str(admin_profile.id)})
# An empty report would read as "that person logged nothing".
assert response.status_code == 403
def test_an_admin_may_report_on_one_agent(
self, admin_client, admin_user, admin_profile, user_profile, org_a
):
case = _case(org_a, admin_user)
_logged(case, admin_profile, 60)
_logged(case, user_profile, 20)
body = admin_client.get(REPORT, {"profile": str(user_profile.id)}).json()
assert body["totals"]["total_minutes"] == 20
def test_another_org_is_not_in_the_report(
self, admin_client, admin_profile, user_b, profile_b, org_a, org_b
):
_logged(_case(org_a, admin_profile.user), admin_profile, 10)
_logged(_case(org_b, user_b, name="Theirs"), profile_b, 500)
body = admin_client.get(REPORT).json()
assert body["totals"]["total_minutes"] == 10
@pytest.mark.django_db
class TestCsvExport:
def _rows(self, response):
text = b"".join(response.streaming_content).decode()
return list(csv.reader(io.StringIO(text)))
def test_serves_one_row_per_entry_with_the_billing_columns(
self, admin_client, admin_user, admin_profile, org_a
):
account = Account.objects.create(name="Reyes & Co", org=org_a)
case = _case(org_a, admin_user, name="Printer down", account=account)
_logged(
case,
admin_profile,
45,
billable=True,
rate="80",
description="Traced the failed import",
)
response = admin_client.get(EXPORT)
assert response.status_code == 200
assert response["Content-Type"] == "text/csv"
assert "attachment; filename=" in response["Content-Disposition"]
header, row = self._rows(response)
assert header[:5] == ["Date", "Agent", "Ticket", "Account", "Description"]
assert row[2] == "Printer down"
assert row[3] == "Reyes & Co"
assert row[4] == "Traced the failed import"
assert row[5] == "45"
assert row[6] == "0.75"
assert row[7] == "yes"
assert row[10] == "60.00"
def test_a_non_billable_entry_has_no_value_rather_than_a_zero(
self, admin_client, admin_user, admin_profile, org_a
):
_logged(_case(org_a, admin_user), admin_profile, 30)
_header, row = self._rows(admin_client.get(EXPORT))
assert row[7] == "no"
assert row[8] == ""
assert row[10] == ""
def test_carries_the_same_window_and_the_same_visibility_as_the_report(
self, user_client, admin_user, admin_profile, user_profile, org_a
):
case = _case(org_a, admin_user)
_logged(case, admin_profile, 60, description="Not yours")
_logged(case, user_profile, 20, description="Yours")
_logged(case, user_profile, 15, days_ago=90, description="Too old")
rows = self._rows(user_client.get(EXPORT))
assert [r[4] for r in rows[1:]] == ["Yours"]
def test_an_agent_cannot_export_another_agents_time(
self, user_client, admin_profile
):
assert (
user_client.get(EXPORT, {"profile": str(admin_profile.id)}).status_code
== 403
)
@pytest.mark.django_db
class TestTheDownloadIsReachable:
"""A client that says what it wants gets it.
Content negotiation runs before the handler, so a view with no CSV
renderer answers `Accept: text/csv` with 406 and never runs. Both export
proxies in the SvelteKit app send exactly that header, so both downloads
were broken from the browser while `curl` with no Accept header worked.
"""
def test_the_time_export_accepts_a_csv_request(
self, admin_client, admin_user, admin_profile, org_a
):
_logged(_case(org_a, admin_user), admin_profile, 30)
response = admin_client.get(EXPORT, headers={"accept": "text/csv"})
assert response.status_code == 200
assert response["Content-Type"] == "text/csv"
def test_the_analytics_export_accepts_one_too(self, admin_client):
response = admin_client.get(
"/api/cases/analytics/export/",
{"metric": "frt"},
headers={"accept": "text/csv"},
)
assert response.status_code == 200
assert response["Content-Type"] == "text/csv"

View file

@ -16,6 +16,16 @@ urlpatterns = [
time_views.TimesheetView.as_view(),
name="time_entries_timesheet",
),
path(
"report/",
time_views.TimeReportView.as_view(),
name="time_entries_report",
),
path(
"report/export/",
time_views.TimeReportExportView.as_view(),
name="time_entries_report_export",
),
path(
"unbilled/",
time_views.UnbilledEntriesView.as_view(),

View file

@ -0,0 +1,203 @@
"""Grouped totals and the CSV rows behind the time reports.
Kept beside ``time_views.py`` for the same reason ``analytics.py`` sits beside
``analytics_views.py``: the grouping and the money are the parts worth testing
without an HTTP layer wrapped around them.
Two shapes come out of here. :func:`build_report` answers "where did the time
go", one row per agent, ticket or account. :func:`csv_rows` answers "give me
the entries", one row per :class:`cases.models.TimeEntry`, which is what a
finance team pastes into a spreadsheet and what an invoice is checked against.
Both read only stopped entries. A running timer has ``duration_minutes`` 0
until it is stopped, so counting one would report a morning's work as nothing
and quietly drag an agent's total down.
"""
from __future__ import annotations
from decimal import ROUND_HALF_UP, Decimal
from django.db.models import (
Count,
DecimalField,
ExpressionWrapper,
F,
Q,
Sum,
Value,
)
from django.utils import timezone
from rest_framework import status
GROUPINGS = ("agent", "ticket", "account")
# The columns each grouping needs out of the row. The first is the key.
_GROUP_FIELDS = {
"agent": ("profile_id", "profile__user__name", "profile__user__email"),
"ticket": ("case_id", "case__name"),
"account": ("case__account_id", "case__account__name"),
}
_BILLABLE = Q(billable=True) & Q(hourly_rate__isnull=False)
# Money, computed in the database at the rate stored on each entry rather than
# today's rate: `hourly_rate` is snapshotted per entry precisely so that a rate
# change next month does not silently rewrite what last month was worth.
# `decimal_places=4` because minutes/60 is a third of a cent on odd durations,
# and rounding is done once, on the sum, not on every row of it.
_VALUE = ExpressionWrapper(
F("duration_minutes") * F("hourly_rate") / Value(Decimal("60")),
output_field=DecimalField(max_digits=16, decimal_places=4),
)
CSV_COLUMNS = (
"Date",
"Agent",
"Ticket",
"Account",
"Description",
"Minutes",
"Hours",
"Billable",
"Rate",
"Currency",
"Value",
"Invoice",
)
class TimeReportParamError(Exception):
"""A query parameter the report will not run on.
Carries the status as well as the message so the caller does not have to
guess whether a rejected ``profile`` is a 400 or a 403; reading another
agent's time without being an admin is the second one.
"""
def __init__(self, detail, status_code=status.HTTP_400_BAD_REQUEST):
super().__init__(detail)
self.detail = detail
self.status_code = status_code
def money(value):
"""A Decimal rounded to cents, as a string. ``None`` becomes ``"0.00"``."""
amount = Decimal(value or 0)
return str(amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
def hours(minutes):
"""Minutes as decimal hours, to two places: 45 minutes is ``"0.75"``."""
return str(
(Decimal(minutes or 0) / Decimal(60)).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
)
def build_report(entries, group_by):
"""Group ``entries`` and return ``{rows, totals, currencies}``.
``entries`` is expected to be scoped and filtered already: this function
does not know who is asking, and adding an org filter here would read as
though it did.
Rows are ordered by time logged, descending, because the question the
report answers is which agent, ticket or account took the most of it. A
row's ``key`` is null for time on tickets with no account; that bucket is
named rather than dropped, since unattributed time is exactly what someone
running this report is looking for.
"""
if group_by not in _GROUP_FIELDS:
raise TimeReportParamError(
f"group_by must be one of {', '.join(GROUPINGS)}, not {group_by!r}."
)
fields = _GROUP_FIELDS[group_by]
grouped = (
entries.values(*fields)
.annotate(
total_minutes=Sum("duration_minutes"),
billable_minutes=Sum("duration_minutes", filter=Q(billable=True)),
billable_value=Sum(_VALUE, filter=_BILLABLE),
entry_count=Count("id"),
)
.order_by("-total_minutes")
)
rows = []
for row in grouped:
key = row[fields[0]]
rows.append(
{
"key": str(key) if key else None,
"name": _row_name(group_by, row, fields),
"total_minutes": row["total_minutes"] or 0,
"billable_minutes": row["billable_minutes"] or 0,
"billable_value": money(row["billable_value"]),
"entry_count": row["entry_count"],
}
)
totals = {
"total_minutes": sum(r["total_minutes"] for r in rows),
"billable_minutes": sum(r["billable_minutes"] for r in rows),
"billable_value": money(sum(Decimal(r["billable_value"]) for r in rows)),
"entry_count": sum(r["entry_count"] for r in rows),
}
# Every currency present in the range, so a client can say "these totals
# mix currencies" instead of printing one symbol over a sum of two. The
# value column adds them regardless, which is the same compromise the
# timesheet page and the shell's pipeline figures already make.
currencies = sorted(set(entries.values_list("currency", flat=True).distinct()))
return {"rows": rows, "totals": totals, "currencies": currencies}
def _row_name(group_by, row, fields):
if group_by == "agent":
# `User.name` is never blank (it falls back to the email local-part on
# first save), but an entry can outlive nothing here, so the email is
# the backstop rather than an empty cell.
return row[fields[1]] or row[fields[2]] or "Unknown agent"
if group_by == "ticket":
return row[fields[1]] or "Deleted ticket"
return row[fields[1]] or "No account"
def csv_rows(entries):
"""Yield the header, then one list per entry, for ``csv.writer``.
A generator over ``.iterator()`` so a year of entries streams instead of
being built in memory: the caller wraps this in a ``StreamingHttpResponse``.
"""
yield list(CSV_COLUMNS)
queryset = entries.select_related(
"profile__user", "case", "case__account", "invoice"
).order_by("started_at")
for entry in queryset.iterator(chunk_size=200):
user = entry.profile.user if entry.profile_id else None
account = entry.case.account if entry.case_id else None
value = (
money(Decimal(entry.duration_minutes) * entry.hourly_rate / Decimal(60))
if entry.billable and entry.hourly_rate is not None
else ""
)
yield [
timezone.localdate(entry.started_at).isoformat(),
(user.name or user.email) if user else "",
entry.case.name if entry.case_id else "",
account.name if account else "",
entry.description or "",
entry.duration_minutes,
hours(entry.duration_minutes),
"yes" if entry.billable else "no",
str(entry.hourly_rate) if entry.hourly_rate is not None else "",
entry.currency,
value,
entry.invoice.invoice_number if entry.invoice_id else "",
]

View file

@ -14,13 +14,17 @@ Entry-scoped (registered at the project root under ``/api/time-entries/``):
* ``PUT /api/time-entries/<pk>/``: owner or admin
* ``DELETE /api/time-entries/<pk>/``: owner or admin
* ``GET /api/time-entries/timesheet/``: week view, grouped by day
* ``GET /api/time-entries/report/``: totals by agent, ticket or account
* ``GET /api/time-entries/report/export/``: the same window as ``text/csv``
"""
import csv
from collections import OrderedDict
from datetime import datetime, timedelta
from django.db import IntegrityError, transaction
from django.db.models import Sum
from django.http import StreamingHttpResponse
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import status
@ -28,16 +32,29 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from cases import time_reports
# The file-like shim `csv.writer` needs to stream instead of buffer. Imported
# rather than copied: `cases/analytics_views.py` already exports its CSV the
# same way, and two of these would be two places to fix a streaming bug.
from cases.analytics_views import _Echo
from cases.models import Case, TimeEntry
from cases.serializer import (
TimeEntryCreateSerializer,
TimeEntrySerializer,
TimeEntryUpdateSerializer,
)
from cases.time_reports import TimeReportParamError
from common.models import Profile
from common.permissions import HasOrgContext, is_org_admin
from common.renderers import CSV_RENDERERS
from common.validators import uuid_param
# How far back a report reaches when it is asked for without a window. Long
# enough to cover "what did we do this month", short enough that a stray click
# does not scan a year.
DEFAULT_REPORT_DAYS = 30
def _visible_entry_qs(profile):
"""Org-scoped queryset honouring agent-vs-admin visibility."""
@ -47,6 +64,72 @@ def _visible_entry_qs(profile):
return qs.select_related("profile", "profile__user", "case")
def _parse_date(value):
"""``YYYY-MM-DD`` to a date, or None when absent. Raises on anything else.
Shared by the timesheet and the reports so a malformed date is answered
the same way on both, rather than one of them reading it as today.
"""
if not value:
return None
try:
return datetime.strptime(value, "%Y-%m-%d").date()
except (TypeError, ValueError) as exc:
raise ValueError(f"Invalid date {value!r}; expected YYYY-MM-DD.") from exc
def _report_entries(request):
"""Return ``(entries, start, end)`` for a report request.
Only stopped entries, inside the window, narrowed by the optional
``profile``, ``account`` and ``billable`` filters. Visibility is
`_visible_entry_qs`'s: an agent reports on their own time, an admin on the
org's, and asking for somebody else's without being an admin is a 403
rather than an empty report, because a silent empty answer reads as "that
person logged nothing".
Raises :class:`TimeReportParamError`, which the views turn into a response.
"""
profile = request.profile
entries = _visible_entry_qs(profile).filter(ended_at__isnull=False)
target = uuid_param(request.query_params, "profile")
if target:
if target != str(profile.id) and not is_org_admin(profile):
raise TimeReportParamError(
"Only admins can report on another profile's time.",
status.HTTP_403_FORBIDDEN,
)
entries = entries.filter(profile_id=target)
account = uuid_param(request.query_params, "account")
if account:
entries = entries.filter(case__account_id=account)
billable = request.query_params.get("billable")
if billable is not None:
if billable not in ("true", "false"):
raise TimeReportParamError("billable must be 'true' or 'false'.")
entries = entries.filter(billable=(billable == "true"))
try:
start = _parse_date(request.query_params.get("start"))
end = _parse_date(request.query_params.get("end"))
except ValueError as exc:
raise TimeReportParamError(str(exc)) from exc
# Both or neither, the same rule the timesheet uses: half a window is
# more likely a mistake than a request to run from a date to whenever.
if start is None or end is None:
end = timezone.localdate()
start = end - timedelta(days=DEFAULT_REPORT_DAYS - 1)
if end < start:
raise TimeReportParamError("end must be on or after start.")
entries = entries.filter(started_at__date__gte=start, started_at__date__lte=end)
return entries, start, end
class TimeEntryListCreateView(APIView):
permission_classes = (IsAuthenticated, HasOrgContext)
@ -277,8 +360,8 @@ class TimesheetView(APIView):
target_profile_id = profile_param
try:
start = self._parse_date(request.query_params.get("start"))
end = self._parse_date(request.query_params.get("end"))
start = _parse_date(request.query_params.get("start"))
end = _parse_date(request.query_params.get("end"))
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
if start is None or end is None:
@ -390,11 +473,67 @@ class TimesheetView(APIView):
}
)
@staticmethod
def _parse_date(value):
if not value:
return None
class TimeReportView(APIView):
"""``GET /api/time-entries/report/``: where the time went.
``?start=&end=`` (YYYY-MM-DD, inclusive, defaulting to the last 30 days),
``?group_by=agent|ticket|account``, and the optional ``profile``,
``account`` and ``billable`` filters. One row per group with the minutes,
the billable split, the value at each entry's own rate, and how many
entries went into it.
The productivity half of the answer. For the billing half, the same
window comes out of ``report/export/`` as one row per entry.
"""
permission_classes = (IsAuthenticated, HasOrgContext)
def get(self, request):
group_by = request.query_params.get("group_by") or "agent"
try:
return datetime.strptime(value, "%Y-%m-%d").date()
except (TypeError, ValueError) as exc:
raise ValueError(f"Invalid date {value!r}; expected YYYY-MM-DD.") from exc
entries, start, end = _report_entries(request)
report = time_reports.build_report(entries, group_by)
except TimeReportParamError as exc:
return Response({"detail": exc.detail}, status=exc.status_code)
return Response(
{
"start": start.isoformat(),
"end": end.isoformat(),
"group_by": group_by,
**report,
}
)
class TimeReportExportView(APIView):
"""``GET /api/time-entries/report/export/``: the same window as CSV.
One row per entry, not per group: an invoice is checked against the
entries behind it, and a spreadsheet can group them again in whatever way
the person opening it needs.
Streams, so a year of entries does not have to be built in memory first.
Same filters and the same visibility as the report above.
"""
permission_classes = (IsAuthenticated, HasOrgContext)
# Content negotiation runs before the handler, so a download proxy asking
# for `Accept: text/csv` was answered 406 without this.
renderer_classes = CSV_RENDERERS
def get(self, request):
try:
entries, start, end = _report_entries(request)
except TimeReportParamError as exc:
return Response({"detail": exc.detail}, status=exc.status_code)
writer = csv.writer(_Echo())
stream = (writer.writerow(row) for row in time_reports.csv_rows(entries))
response = StreamingHttpResponse(stream, content_type="text/csv")
response["Content-Disposition"] = (
f'attachment; filename="time-{start.isoformat()}-to-{end.isoformat()}.csv"'
)
return response

View file

@ -0,0 +1,42 @@
"""Renderers for endpoints that answer with something other than JSON."""
import json
from rest_framework.renderers import BaseRenderer, BrowsableAPIRenderer, JSONRenderer
class CsvRenderer(BaseRenderer):
"""Lets a view accept ``Accept: text/csv``.
Nothing is rendered through this in the normal case: the CSV endpoints
build a :class:`django.http.StreamingHttpResponse` themselves so a large
export streams instead of being assembled in memory, and DRF hands a plain
``HttpResponse`` straight to the client.
It exists because content negotiation runs *before* the handler. Without a
renderer claiming ``text/csv``, a client that politely says what it wants
is answered 406 and the handler never runs, which is what both CSV exports
did to their own download proxies.
``render`` is still implemented, because a view can return a DRF
``Response`` on the error path (a bad date, a forbidden profile) while
``text/csv`` is the negotiated type. That body is JSON, and saying so in
bytes beats handing Django a dict it cannot write.
"""
media_type = "text/csv"
format = "csv"
charset = "utf-8"
def render(self, data, accepted_media_type=None, renderer_context=None):
if data is None:
return ""
if isinstance(data, str):
return data
return json.dumps(data)
# What a CSV endpoint declares: JSON first, so an error keeps its usual shape
# for a client that did not ask for anything in particular, plus CSV, plus the
# browsable renderer DRF's own default list carries for the API explorer.
CSV_RENDERERS = (JSONRenderer, CsvRenderer, BrowsableAPIRenderer)

View file

@ -445,12 +445,18 @@ class ActivitySerializer(serializers.ModelSerializer):
"""Activity timeline row, used by audit-log feeds (Cases first)."""
user = CommentUserSerializer(read_only=True)
# The label from `Activity.ACTION_CHOICES` ("Time Logged"), so no client
# has to keep its own copy of a verb list that lives on the model. Both
# the web and the Flutter timeline already read this key and were falling
# back to the raw enum, which is why they printed "TIME_LOGGED".
action_display = serializers.CharField(source="get_action_display", read_only=True)
class Meta:
model = Activity
fields = (
"id",
"action",
"action_display",
"user",
"entity_type",
"entity_id",

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "django-crm"
version = "1.6.0"
version = "1.7.0"
# The one-line summary PyPI shows under the project title and in search results,
# so it carries the terms people actually search for ("open source CRM",
# "self-hosted CRM", "Django CRM") and names the modules rather than the

View file

@ -583,7 +583,7 @@ wheels = [
[[package]]
name = "django-crm"
version = "1.5.0"
version = "1.7.0"
source = { editable = "." }
dependencies = [
{ name = "cairocffi" },

View file

@ -224,6 +224,8 @@ Interactive versions of the same schema are served by a running backend at
| `/api/tasks/{id}/move/` | PATCH |
| `/api/teams/` | GET, POST |
| `/api/teams/{id}/` | DELETE, GET, PATCH, PUT |
| `/api/time-entries/report/` | GET |
| `/api/time-entries/report/export/` | GET |
| `/api/time-entries/timesheet/` | GET |
| `/api/time-entries/unbilled/` | GET |
| `/api/time-entries/{id}/` | DELETE, PUT |

View file

@ -0,0 +1,109 @@
/**
* What the rail reads off a ticket: the activity feed, and the time totals.
*
* Both are plain passthroughs of `ActivitySerializer` and `CaseSerializer`
* keys, and both were wrong in the same way at some point: a key that the API
* does not emit renders as an empty string rather than as an error, so the
* page looks fine and says nothing. These pin the key names.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const apiRequest = vi.fn();
vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
const { getTicket } = await import('./tickets.js');
const event = /** @type {any} */ ({ cookies: { get: () => 'token' } });
/** A detail payload with no account, so `getTicket` makes exactly one call. */
function detail(extra = {}) {
return {
cases_obj: { id: 'c1', name: 'Printer down', status: 'New', priority: 'Low' },
comment_permission: false,
...extra
};
}
describe('the activity feed', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('reads the time, the verb and the actor from the keys the API sends', async () => {
apiRequest.mockResolvedValue(
detail({
activities: [
{
id: 'a1',
action: 'TIME_LOGGED',
action_display: 'Time Logged',
created_at: '2026-08-16T09:00:00Z',
user: { id: 'p1', user_details: { email: 'agent@example.com' } }
}
]
})
);
const { activity } = await getTicket(event, 'c1');
expect(activity).toEqual([
{
id: 'a1',
action: 'TIME_LOGGED',
label: 'Time Logged',
at: '2026-08-16T09:00:00Z',
by: 'agent@example.com'
}
]);
});
it('falls back to the raw verb, and calls an actorless row nobody', async () => {
// A Celery task or a management command writes rows with no user; the
// page renders "System" for a null `by`, which is the truth there.
apiRequest.mockResolvedValue(
detail({
activities: [
{ id: 'a2', action: 'ESCALATED', created_at: '2026-08-16T09:00:00Z', user: null }
]
})
);
const { activity } = await getTicket(event, 'c1');
expect(activity[0].label).toBe('ESCALATED');
expect(activity[0].by).toBe(null);
expect(activity[0].at).toBe('2026-08-16T09:00:00Z');
});
});
describe('the time totals on the ticket', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('carries the whole-ticket summary through for the time panel', async () => {
apiRequest.mockResolvedValue(
detail({
cases_obj: {
id: 'c1',
name: 'Printer down',
status: 'New',
priority: 'Low',
time_summary: { total_minutes: 105, billable_minutes: 45, by_profile: [] }
}
})
);
const { ticket } = await getTicket(event, 'c1');
expect(ticket.time_summary.total_minutes).toBe(105);
});
it('is null on a row from the list endpoint, which does not carry it', async () => {
apiRequest.mockResolvedValue(detail());
const { ticket } = await getTicket(event, 'c1');
expect(ticket.time_summary).toBe(null);
});
});

View file

@ -111,6 +111,12 @@ function toRow(row) {
paused_at: row.sla_paused_at ?? null,
child_count: row.child_count ?? 0,
parent: row.parent_summary ?? null,
// Everyone's logged time on this ticket, which is not what
// `/cases/<id>/time-entries/` returns to an agent: that list is narrowed
// to their own rows, so a panel adding it up would tell a team of three
// the ticket had taken a third of the time it had. Absent on the list
// endpoint (`slim=true`), hence the null.
time_summary: row.time_summary ?? null,
is_open: OPEN_STATUSES.includes(row.status)
};
}
@ -264,20 +270,22 @@ export async function getTicket({ cookies }, id) {
url: attachmentHref(file.id),
at: file.created_at
})),
// `ActivitySerializer` names the time `timestamp`, not `created_at`, and
// supplies its own label for the verb. Both are used as given.
// `ActivitySerializer` (`common/serializer.py`) names the time
// `created_at` and the verb `action_display`, and puts the actor's email
// one level in, under `user.user_details`.
//
// Worth knowing before reading that serializer: `common/serializer.py`
// defines **two** classes called `ActivitySerializer`, at lines 344 and
// 843. The second shadows the first, so the one with `metadata` and
// `created_at` is unreachable and coding against it silently gets you
// nothing. This maps the one that actually runs.
// This read `row.timestamp` and `row.user.email`, neither of which the
// serializer has ever emitted, so every row in the rail rendered as
// "System · — ago". A comment here used to justify `timestamp` by saying
// two classes named `ActivitySerializer` existed and the shadowing one
// used it; there is one class today, and
// `common/tests/test_activity_serializer_shapes.py` pins its shape.
activity: (response.activities ?? []).map((/** @type {any} */ row) => ({
id: row.id,
action: row.action,
label: row.action_display || row.action,
at: row.timestamp,
by: row.user?.user_details?.email || row.user?.email || null
at: row.created_at,
by: row.user?.user_details?.email ?? null
})),
// The API's own answer about whether this person may reply, rather than a
// guess from their role. It used to disagree with the endpoint.

View file

@ -1,19 +1,26 @@
/**
* Weekly timesheet: the wiring behind /v2/timesheet.
* Time entries: the wiring behind /v2/timesheet and the ticket's time panel.
*
* Server-only. `GET /time-entries/timesheet/` returns the caller's own week
* grouped into day buckets (every day present, empty or not) with week
* totals, the billable split, and a running-timer count. The page reads it
* verbatim as `data.week`.
*
* The v2 page is always "your timesheet", it has no profile switcher, so this
* layer never passes a `profile`, which also keeps it clear of the endpoint's
* admin-only "another profile" 403. `TimesheetView` already expands `case` and
* `invoice` to `{id, name}` / `{id, invoice_number}` and names the profile, so
* there is no reshaping here.
* The v2 timesheet page is always "your timesheet", it has no profile
* switcher, so this layer never passes a `profile`, which also keeps it clear
* of the endpoint's admin-only "another profile" 403. `TimesheetView` already
* expands `case` and `invoice` to `{id, name}` / `{id, invoice_number}` and
* names the profile, so there is no reshaping here.
*
* `stopTimer` is the one write path here: `POST /time-entries/<id>/stop/`.
* Nothing else on this page is editable.
* The rest is the ticket panel's surface, ticket-scoped on the way in
* (`/cases/<id>/time-entries/`) and entry-scoped on the way back out
* (`/time-entries/<id>/`), which is how the API splits it. Every write lands
* here rather than in `tickets.js` so one file holds what the app knows about
* time entries; `stopTimer` is shared by both callers already.
*
* None of these check who owns an entry. The API does: it narrows a list to
* the caller's own rows for a non-admin, and answers 403 on someone else's
* entry. Nothing below should be read as having re-checked that.
*/
import { apiRequest } from '$lib/api-helpers.js';
@ -69,3 +76,187 @@ export async function stopTimer({ cookies }, entryId) {
return await apiRequest(`/time-entries/${entryId}/stop/`, { method: 'POST' }, { cookies });
}
/** Longest manual entry the form will send, in minutes. A day of work logged
* in one go is already unusual; more than that is a typo, and the API has no
* ceiling of its own to catch it. */
const MAX_ENTRY_MINUTES = 1440;
/** The groupings `/time-entries/report/` accepts. */
export const REPORT_GROUPINGS = ['agent', 'ticket', 'account'];
/**
* Where the time went over a window: totals by agent, ticket or account.
*
* `group_by` and `billable` are checked against what the API accepts rather
* than forwarded blind. Both arrive from the query string, so a hand-edited
* URL would otherwise turn a 400 into a 500 page; an unknown value falls back
* to the default instead, which is what the filter bar shows anyway.
*
* The window is passed through as given (YYYY-MM-DD, inclusive). Left off, the
* API answers for the last 30 days and says which days those were, so the page
* never has to guess a range it did not choose.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @param {{ start?: string, end?: string, group_by?: string, billable?: string, account?: string, profile?: string }} [filters]
* @returns {Promise<{ report: any }>}
*/
export async function getTimeReport({ cookies }, filters = {}) {
const qs = new URLSearchParams();
if (filters.start) qs.set('start', filters.start);
if (filters.end) qs.set('end', filters.end);
qs.set(
'group_by',
REPORT_GROUPINGS.includes(filters.group_by ?? '') ? String(filters.group_by) : 'agent'
);
if (filters.billable === 'true' || filters.billable === 'false') {
qs.set('billable', filters.billable);
}
if (filters.account) qs.set('account', filters.account);
if (filters.profile) qs.set('profile', filters.profile);
const report = await apiRequest(`/time-entries/report/?${qs.toString()}`, {}, { cookies });
return { report };
}
/**
* Every time entry on one ticket, newest first.
*
* Returns the array as given. An agent sees only their own rows here and an
* admin sees the team's, which is the API's decision, not this layer's, so
* the panel must not present the total it can add up from this list as the
* ticket's total. `time_summary` on the ticket is that number.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @param {string} ticketId
* @returns {Promise<any[]>}
*/
export async function listTicketTime({ cookies }, ticketId) {
if (!ticketId) throw new Error('Which ticket? No ticket id was given.');
const entries = await apiRequest(`/cases/${ticketId}/time-entries/`, {}, { cookies });
return Array.isArray(entries) ? entries : [];
}
/**
* Start a running timer on a ticket.
*
* The API answers 409 when this person already has one running, anywhere, and
* names the ticket it is on. That id is carried through on the thrown error so
* the page can offer a way to it: "you have a timer running" with no way to
* reach it is a dead end on the one screen that could fix it.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @param {string} ticketId
* @returns {Promise<any>} the started entry
*/
export async function startTicketTimer({ cookies }, ticketId) {
if (!ticketId) throw new Error('Which ticket? No ticket id was given.');
return await apiRequest(
`/cases/${ticketId}/time-entries/start/`,
{ method: 'POST' },
{ cookies }
);
}
/**
* Log a stopped entry after the fact.
*
* The API wants two timestamps; the form asks for one duration, so the window
* is built here as "the last `minutes` minutes", ending now. That is what the
* phone does too (`mobile/lib/widgets/tickets/ticket_time_panel.dart`), and it
* keeps the clock out of the browser's hands: a UTC instant computed on the
* server cannot be a day out because somebody's laptop is set wrong.
*
* It also means an entry cannot be backdated to last Tuesday from here. That
* is the same limit the phone has, and adding a date field would need a
* timezone to resolve it against, which a `<input type="date">` does not send.
*
* `description` is required by `TimeEntryCreateSerializer` and checked here
* too, so an empty box is answered without a round trip. `currency` is the
* org's, passed in by the caller: left off, every entry would be USD and an
* org that invoices in euros could not bill a single one of them, since the
* invoice builder refuses a mixed-currency set.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @param {string} ticketId
* @param {{ minutes: number|string, description: string, billable?: boolean, hourlyRate?: string|number|null, currency?: string|null }} entry
* @returns {Promise<any>} the created entry
*/
export async function logTicketTime({ cookies }, ticketId, entry) {
if (!ticketId) throw new Error('Which ticket? No ticket id was given.');
const minutes = Number(entry.minutes);
if (!Number.isInteger(minutes) || minutes < 1 || minutes > MAX_ENTRY_MINUTES) {
throw new Error(`Minutes must be a whole number between 1 and ${MAX_ENTRY_MINUTES}.`);
}
const description = (entry.description ?? '').trim();
if (!description) throw new Error('Say what the time was spent on.');
const ended = new Date();
const started = new Date(ended.getTime() - minutes * 60000);
/** @type {Record<string, unknown>} */
const body = {
started_at: started.toISOString(),
ended_at: ended.toISOString(),
description,
billable: Boolean(entry.billable)
};
// Optional, and only when it is a number the API will take. A rate is the
// difference between an invoice line worth something and one worth 0.00.
if (entry.hourlyRate !== null && entry.hourlyRate !== undefined && entry.hourlyRate !== '') {
const rate = Number(entry.hourlyRate);
if (!Number.isFinite(rate) || rate < 0) throw new Error('The rate must be a positive number.');
body.hourly_rate = rate.toFixed(2);
}
if (entry.currency) body.currency = entry.currency;
return await apiRequest(
`/cases/${ticketId}/time-entries/`,
{ method: 'POST', body },
{ cookies }
);
}
/**
* Flip one entry between billable and not.
*
* A PUT carrying the single field. The endpoint updates partially, so the
* description, the window, and the rate are left where they are; sending the
* whole entry back would race anyone editing it elsewhere.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @param {string} entryId
* @param {boolean} billable
* @returns {Promise<any>} the updated entry
*/
export async function setEntryBillable({ cookies }, entryId, billable) {
if (!entryId) throw new Error('Which time entry? No entry id was given.');
return await apiRequest(
`/time-entries/${entryId}/`,
{ method: 'PUT', body: { billable: Boolean(billable) } },
{ cookies }
);
}
/**
* Delete an entry.
*
* The API refuses one that has been invoiced, which is the check that matters
* and is not repeated here: an entry can be invoiced between this page
* rendering and the button being pressed, so the answer has to come from the
* server either way.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @param {string} entryId
* @returns {Promise<null>}
*/
export async function deleteEntry({ cookies }, entryId) {
if (!entryId) throw new Error('Which time entry? No entry id was given.');
return await apiRequest(`/time-entries/${entryId}/`, { method: 'DELETE' }, { cookies });
}

View file

@ -3,7 +3,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
const apiRequest = vi.fn();
vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
const { stopTimer } = await import('$lib/server/v2/timesheet.js');
const {
stopTimer,
listTicketTime,
startTicketTimer,
logTicketTime,
setEntryBillable,
deleteEntry,
getTimeReport
} = await import('$lib/server/v2/timesheet.js');
// Cast rather than shaping a full Cookies mock, matching leads.test.js and
// tags.test.js: stopTimer only ever calls `cookies.get`, and `apiRequest`
// itself is mocked above, so nothing here touches
@ -32,3 +40,150 @@ describe('stopTimer', () => {
expect(apiRequest).not.toHaveBeenCalled();
});
});
describe('the ticket panel', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('lists a ticket’s entries and hands back an array even when the API does not', async () => {
apiRequest.mockResolvedValue([{ id: 'e1' }]);
expect(await listTicketTime(event, 't1')).toEqual([{ id: 'e1' }]);
expect(apiRequest.mock.calls[0][0]).toBe('/cases/t1/time-entries/');
// A 204, an error envelope, or a paginated object would each render as
// `entries.length` of undefined in the panel.
apiRequest.mockResolvedValue(null);
expect(await listTicketTime(event, 't1')).toEqual([]);
});
it('starts a timer on the ticket, with no body to be trusted', async () => {
apiRequest.mockResolvedValue({ id: 'e1', ended_at: null });
await startTicketTimer(event, 't1');
const [endpoint, options] = apiRequest.mock.calls[0];
expect(endpoint).toBe('/cases/t1/time-entries/start/');
expect(options.method).toBe('POST');
expect(options.body).toBeUndefined();
});
it('turns minutes into a window ending now', async () => {
apiRequest.mockResolvedValue({ id: 'e1' });
await logTicketTime(event, 't1', { minutes: '45', description: ' Traced it ' });
const [endpoint, options] = apiRequest.mock.calls[0];
expect(endpoint).toBe('/cases/t1/time-entries/');
const spanMinutes =
(Date.parse(options.body.ended_at) - Date.parse(options.body.started_at)) / 60000;
expect(spanMinutes).toBe(45);
expect(options.body.description).toBe('Traced it');
expect(options.body.billable).toBe(false);
// Absent, not null: the API takes the model default for both.
expect('hourly_rate' in options.body).toBe(false);
expect('currency' in options.body).toBe(false);
});
it('sends the rate and currency when it is given them', async () => {
apiRequest.mockResolvedValue({ id: 'e1' });
await logTicketTime(event, 't1', {
minutes: 30,
description: 'Call',
billable: true,
hourlyRate: '85',
currency: 'EUR'
});
const { body } = apiRequest.mock.calls[0][1];
expect(body.hourly_rate).toBe('85.00');
expect(body.currency).toBe('EUR');
expect(body.billable).toBe(true);
});
it('rejects a duration that is not a whole number of minutes inside a day', async () => {
for (const minutes of ['0', '-30', '1.5', '1441', 'half an hour', '']) {
await expect(logTicketTime(event, 't1', { minutes, description: 'x' })).rejects.toThrow(
/minutes/i
);
}
expect(apiRequest).not.toHaveBeenCalled();
});
it('rejects an empty description, which the API requires, and a negative rate', async () => {
await expect(logTicketTime(event, 't1', { minutes: 30, description: ' ' })).rejects.toThrow(
/spent on/i
);
await expect(
logTicketTime(event, 't1', { minutes: 30, description: 'x', hourlyRate: '-5' })
).rejects.toThrow(/positive/i);
expect(apiRequest).not.toHaveBeenCalled();
});
it('sends only the billable flag when flipping one, leaving the rest of the entry alone', async () => {
apiRequest.mockResolvedValue({ id: 'e1', billable: true });
await setEntryBillable(event, 'e1', true);
const [endpoint, options] = apiRequest.mock.calls[0];
expect(endpoint).toBe('/time-entries/e1/');
expect(options.method).toBe('PUT');
expect(options.body).toEqual({ billable: true });
});
it('deletes by id, and refuses to send a request without one', async () => {
apiRequest.mockResolvedValue(null);
await deleteEntry(event, 'e1');
expect(apiRequest.mock.calls[0][0]).toBe('/time-entries/e1/');
expect(apiRequest.mock.calls[0][1].method).toBe('DELETE');
apiRequest.mockReset();
await expect(deleteEntry(event, '')).rejects.toThrow(/entry/i);
await expect(listTicketTime(event, '')).rejects.toThrow(/ticket/i);
await expect(startTicketTimer(event, '')).rejects.toThrow(/ticket/i);
expect(apiRequest).not.toHaveBeenCalled();
});
});
describe('the time report', () => {
beforeEach(() => {
apiRequest.mockReset();
apiRequest.mockResolvedValue({ rows: [], totals: {}, currencies: [] });
});
/** The query string the helper sent, as a plain object. */
function sentParams() {
const [endpoint] = apiRequest.mock.calls[0];
return Object.fromEntries(new URLSearchParams(endpoint.split('?')[1]));
}
it('passes the window and the grouping through', async () => {
await getTimeReport(event, { start: '2026-08-01', end: '2026-08-31', group_by: 'account' });
expect(sentParams()).toEqual({
start: '2026-08-01',
end: '2026-08-31',
group_by: 'account'
});
});
it('falls back to grouping by agent rather than forwarding a bad value', async () => {
// Straight off the query string, so this is a hand-edited URL, not a bug.
// Forwarded, the API answers 400 and the page becomes an error screen.
await getTimeReport(event, { group_by: 'planet' });
expect(sentParams().group_by).toBe('agent');
});
it('sends the billable filter only when it is one of the two the API takes', async () => {
await getTimeReport(event, { billable: 'false' });
expect(sentParams().billable).toBe('false');
apiRequest.mockClear();
await getTimeReport(event, { billable: 'maybe' });
expect('billable' in sentParams()).toBe(false);
});
it('leaves the window off entirely when it is not given, so the API picks it', async () => {
await getTimeReport(event, {});
expect(sentParams()).toEqual({ group_by: 'agent' });
});
});

View file

@ -114,6 +114,23 @@ export function relativeTime(iso, now = new Date()) {
return relativeDays(iso, now);
}
/**
* A duration in minutes as "1h 48m".
*
* Logged time is read in hours: "108m" makes the reader do the division, and
* two of them on the same screen makes them do it twice. Minutes below the
* hour keep their own suffix so a short entry does not render as "0h 12m".
*
* @param {number|string|null|undefined} mins
*/
export function hoursMinutes(mins) {
const v = Number(mins ?? 0);
if (!Number.isFinite(v)) return '0m';
const m = Math.max(0, Math.round(v));
const h = Math.floor(m / 60);
return h ? `${h}h ${m % 60}m` : `${m}m`;
}
/** Compact age for a table cell: "12d", "3h". */
export function shortAge(iso, now = new Date()) {
const d = parseIso(iso);

View file

@ -1,5 +1,12 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { shortDate, longDate, daysSince, relativeDays, relativeTime } from '$lib/v2/format.js';
import {
shortDate,
longDate,
daysSince,
relativeDays,
relativeTime,
hoursMinutes
} from '$lib/v2/format.js';
/**
* A date and an instant are not the same kind of value, and the API sends both.
@ -71,6 +78,26 @@ describe('a timestamp is an instant and keeps its zone', () => {
});
});
describe('a duration reads in hours', () => {
it('splits minutes into hours and minutes', () => {
expect(hoursMinutes(108)).toBe('1h 48m');
expect(hoursMinutes(120)).toBe('2h 0m');
});
it('keeps a short entry in minutes rather than printing 0h', () => {
expect(hoursMinutes(12)).toBe('12m');
expect(hoursMinutes(0)).toBe('0m');
});
it('never renders a negative or a NaN duration', () => {
// A running timer's elapsed minutes are a subtraction, and a clock that
// disagrees with the server can make it come out below zero.
expect(hoursMinutes(-5)).toBe('0m');
expect(hoursMinutes(null)).toBe('0m');
expect(hoursMinutes('not a number')).toBe('0m');
});
});
describe('nothing to render', () => {
it('null and an unparseable string both give the placeholder', () => {
expect(shortDate(null)).toBe('—');

View file

@ -7,6 +7,14 @@ import {
updateTicket
} from '$lib/server/v2/tickets.js';
import { getOrgSettings } from '$lib/server/v2/organization.js';
import {
listTicketTime,
startTicketTimer,
stopTimer,
logTicketTime,
setEntryBillable,
deleteEntry
} from '$lib/server/v2/timesheet.js';
import { readableError } from '$lib/server/v2/form-errors.js';
import { openDescendants, subtreeTruncated, cascadedCount, closeResultMessage } from './close.js';
@ -22,14 +30,37 @@ import { openDescendants, subtreeTruncated, cascadedCount, closeResultMessage }
* falls back to the plain one. Both fall back quietly, and the close action
* re-derives everything server-side anyway, so nothing here is trusted.
*
* The time entries ride along in the same wave. They are their own request,
* the ticket envelope carries only the `time_summary` totals, and they are
* allowed to fail: a ticket that will not render because the time panel could
* not load is the same bad trade as the tree below. `null` means the fetch
* failed and the panel says so; `[]` means nobody has logged anything.
*
* @type {import('./$types').PageServerLoad}
*/
export async function load({ cookies, params }) {
const data = await getTicket({ cookies }, params.id);
export async function load({ cookies, params, locals }) {
const [data, timeEntries] = await Promise.all([
getTicket({ cookies }, params.id),
listTicketTime({ cookies }, params.id).catch(() => null)
]);
const time = {
entries: timeEntries,
// Server-derived from the JWT, never the client. Display-only: it tells
// the panel which running timer is this person's to stop, since an admin
// sees the whole team's rows. The API decides who may actually stop one.
viewerUserId: locals.user?.id ?? null,
// What a running timer's elapsed minutes are counted from. The browser
// clock only adds the minutes since this page loaded, the same split the
// timesheet page uses: how long somebody has been working is not a
// question a machine with the wrong date gets to answer.
now: new Date().toISOString()
};
// `child_count` sits on the ticket itself here. The `server` block with a
// `child_count` of its own belongs to the EDIT page's loader, and reading it
// from this one is silently always-undefined, so the panel never appeared.
if (!data.ticket?.child_count) return data;
if (!data.ticket?.child_count) return { ...data, time };
const [tree, settings] = await Promise.all([
getTicketTree({ cookies }, params.id).catch(() => null),
@ -38,6 +69,7 @@ export async function load({ cookies, params }) {
return {
...data,
time,
close: {
descendants: openDescendants(tree?.root, params.id),
truncated: subtreeTruncated(tree?.root, params.id),
@ -167,5 +199,111 @@ export const actions = {
moved: 'Closed',
closed: closeResultMessage({ cascade, cascaded: cascadedCount(result) })
};
},
/*
* The five time-panel writes below all report through `timeError` rather
* than the `error` the reply and close actions use. That banner sits at the
* top of the page and the panel does not, so on a phone a shared key puts
* the reason a delete was refused a full screen above the button that was
* pressed.
*/
/**
* Start the clock on this ticket.
*
* One timer per person, org-wide: the API answers 409 when there is already
* one running and names the ticket it is on. That id is passed back so the
* panel can link to it, because the fix for "you already have a timer
* running" is on that other ticket, not this one.
*/
startTimer: async ({ cookies, params }) => {
try {
await startTicketTimer({ cookies }, params.id);
} catch (/** @type {any} */ err) {
return fail(err?.status === 409 ? 409 : 400, {
timeError: readableError(err, 'Could not start the timer.'),
runningTicketId: err?.body?.running_case_id ?? null
});
}
return { timeStarted: true };
},
/** Stop a running timer. The API rejects one that is already stopped, which
* is what a double-submit looks like, so the panel disables the button
* while this is in flight. */
stopTimer: async ({ cookies, request }) => {
const form = await request.formData();
const entryId = form.get('entry_id')?.toString() ?? '';
try {
await stopTimer({ cookies }, entryId);
} catch (/** @type {any} */ err) {
return fail(400, { timeError: readableError(err, 'Could not stop the timer.') });
}
return { timeStopped: true };
},
/**
* Log time that was worked without the timer running.
*
* The currency is the org's, from the JWT, not from the form: what an entry
* is billed in is a fact about the org, and a client that could name it
* could bill an hour in a currency nobody trades.
*/
logTime: async ({ cookies, params, request, locals }) => {
const form = await request.formData();
const minutes = form.get('minutes')?.toString() ?? '';
const description = form.get('description')?.toString() ?? '';
const billable = form.get('billable') === 'on';
const hourlyRate = form.get('hourly_rate')?.toString() ?? '';
try {
await logTicketTime({ cookies }, params.id, {
minutes,
description,
billable,
hourlyRate,
currency: /** @type {any} */ (locals).org_settings?.default_currency ?? null
});
} catch (/** @type {any} */ err) {
return fail(400, { timeError: readableError(err, 'Could not log this time.') });
}
return { timeLogged: true };
},
/** Flip one entry between billable and not. The next value comes from the
* form rather than being derived from what was rendered, so two clicks in
* quick succession cannot land on the same value twice. */
setBillable: async ({ cookies, request }) => {
const form = await request.formData();
const entryId = form.get('entry_id')?.toString() ?? '';
const billable = form.get('billable') === 'true';
try {
await setEntryBillable({ cookies }, entryId, billable);
} catch (/** @type {any} */ err) {
return fail(400, { timeError: readableError(err, 'Could not change this entry.') });
}
return { timeUpdated: true };
},
/** Delete an entry. Refused by the API once the entry has been invoiced,
* and that message is worth showing as it is: it says what to undo first. */
deleteTime: async ({ cookies, request }) => {
const form = await request.formData();
const entryId = form.get('entry_id')?.toString() ?? '';
try {
await deleteEntry({ cookies }, entryId);
} catch (/** @type {any} */ err) {
return fail(400, { timeError: readableError(err, 'Could not delete this entry.') });
}
return { timeDeleted: true };
}
};

View file

@ -1,14 +1,33 @@
<script>
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import PageHeader from '$lib/v2/components/PageHeader.svelte';
import NextAction from '$lib/v2/components/NextAction.svelte';
import Pill from '$lib/v2/components/Pill.svelte';
import Avatar from '$lib/v2/components/Avatar.svelte';
import { relativeDays, shortAge, longDate, relativeTime } from '$lib/v2/format.js';
import {
relativeDays,
shortAge,
longDate,
relativeTime,
money,
hoursMinutes as hm
} from '$lib/v2/format.js';
import { PRIORITY_TONE, CASE_STATUS_TONE } from '$lib/v2/enums.js';
import { cascadeSummary } from './close.js';
import { ChevronRight, Lock, Paperclip, Pencil, Ticket, X } from '@lucide/svelte';
import {
ChevronRight,
Lock,
Paperclip,
Pencil,
Play,
Plus,
Square,
Ticket,
Trash2,
X
} from '@lucide/svelte';
/** @type {{ data: any, form: any }} */
let { data, form } = $props();
@ -80,6 +99,81 @@
};
};
/*
* ── Time ──────────────────────────────────────────────────────────────
*
* `data.time.entries` holds what THIS person may see: their own rows, or
* the whole team's for an admin, which is the API's decision. The totals
* beside the heading come from `ticket.time_summary` instead, computed
* across everybody, so a ticket three agents have worked does not report a
* third of its time to each of them. Adding up the rows on screen would.
*
* `entries` is null, not empty, when the fetch failed. The two are
* different answers and the panel says which.
*/
let time = $derived(data.time);
let entries = $derived(time.entries);
let timeSummary = $derived(ticket.time_summary);
/** Minutes since this page loaded, added to the server's own measurement. */
let sinceLoad = $state(0);
onMount(() => {
const opened = Date.now();
const id = setInterval(() => {
sinceLoad = Math.floor((Date.now() - opened) / 60000);
}, 30000);
return () => clearInterval(id);
});
/**
* How long a running timer has been going.
*
* Measured server-side up to page load, ticked locally after that. The
* browser only ever contributes minutes it watched pass, so a machine with
* the wrong date cannot report a timer as eight hours old.
*
* @param {any} entry
*/
const runningMinutes = (entry) =>
Math.floor((Date.parse(time.now) - Date.parse(entry.started_at)) / 60000) + sinceLoad;
/**
* Whose entry this is. Both sides are User ids: `user_details.id` on the
* row, and the JWT's `user_id` passed down by the loader. Comparing the
* Profile id to it would be false for everyone, silently, and the Stop
* button would never appear.
*
* @param {any} entry
*/
const isMine = (entry) => entry.profile?.user_details?.id === time.viewerUserId;
/** This person's own running timer, if they have one on this ticket. */
let myTimer = $derived((entries ?? []).find((/** @type {any} */ e) => !e.ended_at && isMine(e)));
let timeBusy = $state(false);
/**
* The entry whose row is asking "delete?", if any.
*
* An in-page step rather than the native `confirm()`, which blocks the
* browser automation this app is smoke-tested with. Deleting is the one
* control here that needs JavaScript; it is also the only one that destroys
* something, so a version of this page with scripting off losing it is the
* right way round.
*/
let confirmDelete = $state('');
/** @type {import('@sveltejs/kit').SubmitFunction} */
const timeSubmit = () => {
timeBusy = true;
return async ({ update }) => {
timeBusy = false;
// `reset: false` keeps a rejected "log time" form filled in. Retyping
// the description because the minutes were wrong is a small insult.
await update({ reset: false });
};
};
/**
* The one thing that needs a person right now, said as the state it is.
* Ember when the ball is in our court, rust when a target has already been
@ -311,6 +405,229 @@
</div>
{/if}
<!--
Time on this ticket.
Above the conversation rather than under it. The timer is what an
agent reaches for on arriving, and a forty-message thread would
otherwise sit between them and the button. Every control is a form
post, so the panel works by keyboard and, apart from the delete
confirm, without JavaScript at all; `enhance` only saves the reload.
-->
<section class="v2-card time-panel">
<div class="time-head">
<div class="time-title">
<div class="v2-label">Time</div>
<div class="v2-sub time-total">
{#if timeSummary?.total_minutes}
<b class="v2-num">{hm(timeSummary.total_minutes)}</b> logged
{#if timeSummary.billable_minutes}
· {hm(timeSummary.billable_minutes)} billable
{/if}
{:else}
Nothing logged yet
{/if}
</div>
</div>
<!-- The person's own timer, and only theirs. An admin sees the
team's rows here, and stopping someone else's clock from a
button labelled "Stop" with no name on it is not something to
do by accident; that one is on its row. -->
{#if myTimer}
<form method="POST" action="?/stopTimer" use:enhance={timeSubmit} class="time-timer">
<input type="hidden" name="entry_id" value={myTimer.id} />
<button class="v2-btn v2-btn-primary" disabled={timeBusy}>
<Square size={12} />Stop {hm(runningMinutes(myTimer))}
</button>
</form>
{:else}
<form method="POST" action="?/startTimer" use:enhance={timeSubmit} class="time-timer">
<button class="v2-btn" disabled={timeBusy}><Play size={12} />Start timer</button>
</form>
{/if}
<!-- Native disclosure, so the form opens without JavaScript. Open,
it takes a row of its own rather than the button's column. -->
<details class="time-log">
<summary class="v2-btn"><Plus size={12} />Log time</summary>
<form method="POST" action="?/logTime" use:enhance={timeSubmit} class="time-log-form">
<div class="v2-field">
<label for="time-minutes">Minutes</label>
<input
id="time-minutes"
class="v2-input"
name="minutes"
type="number"
inputmode="numeric"
min="1"
max="1440"
step="1"
value="30"
required
/>
</div>
<div class="v2-field">
<label for="time-rate">Rate per hour</label>
<input
id="time-rate"
class="v2-input"
name="hourly_rate"
type="number"
inputmode="decimal"
min="0"
step="0.01"
placeholder="Optional"
/>
</div>
<div class="v2-field time-wide">
<label for="time-what">What was done</label>
<input
id="time-what"
class="v2-input"
name="description"
placeholder="Traced the failed import to the CSV encoding"
required
/>
</div>
<div class="time-wide time-log-foot">
<label class="time-check">
<input type="checkbox" name="billable" />
Billable
</label>
<button class="v2-btn v2-btn-primary" disabled={timeBusy}>Log time</button>
</div>
<p class="v2-hint time-wide">
Counted back from now. To record a session from an earlier day, start and stop the
timer on it.
</p>
</form>
</details>
</div>
{#if form?.timeError}
<p class="v2-error time-error">
<span>{form.timeError}</span>
{#if form.runningTicketId}
<a href={resolve(`/tickets/${form.runningTicketId}`)}>Open that ticket</a>
{/if}
</p>
{/if}
{#if entries === null}
<p class="v2-sub time-empty">
The time entries could not be loaded. Nothing else on this ticket is affected.
</p>
{:else if entries.length === 0}
<p class="v2-sub time-empty">
No time logged yet. Start the timer, or log a session you have already worked.
</p>
{:else}
<div class="v2-table-wrap">
<table class="v2-table">
<thead>
<tr>
<th>What was done</th>
<th>Who</th>
<th>When</th>
<th class="v2-r">Logged</th>
<th class="v2-r">Billing</th>
</tr>
</thead>
<tbody>
{#each entries as e (e.id)}
<tr>
<td data-m="title">
{e.description || 'No description'}
{#if !e.ended_at}<span class="time-running">Running</span>{/if}
{#if e.auto_stopped}
<span class="v2-sub" title="Stopped automatically after running overnight"
>auto-stopped</span
>
{/if}
</td>
<td data-m="meta">
{e.profile?.user_details?.name ||
e.profile?.user_details?.email ||
'Someone'}
</td>
<td data-m="meta">{shortAge(e.started_at)} ago</td>
<td class="v2-num v2-r" data-m="tag">
{e.ended_at ? hm(e.duration_minutes) : hm(runningMinutes(e))}
</td>
<td class="v2-r time-row-actions">
{#if !e.ended_at}
<form method="POST" action="?/stopTimer" use:enhance={timeSubmit}>
<input type="hidden" name="entry_id" value={e.id} />
<button class="v2-btn v2-btn-sm" disabled={timeBusy}>
<Square size={11} />Stop
</button>
</form>
{:else if e.invoice}
<!-- Billed. The toggle and the delete are both gone:
the API refuses to delete an invoiced entry, and
flipping one to non-billable after it has been
charged for would leave the invoice standing. -->
<a class="v2-sub" href={resolve(`/invoices/${e.invoice}`)}>Invoiced</a>
{:else if confirmDelete === e.id}
<form method="POST" action="?/deleteTime" use:enhance={timeSubmit}>
<input type="hidden" name="entry_id" value={e.id} />
<button class="v2-btn v2-btn-sm time-danger" disabled={timeBusy}>
Delete
</button>
</form>
<button
type="button"
class="v2-btn v2-btn-sm"
onclick={() => (confirmDelete = '')}
>
Keep
</button>
{:else}
<form method="POST" action="?/setBillable" use:enhance={timeSubmit}>
<input type="hidden" name="entry_id" value={e.id} />
<!-- The value to move to, decided when the row was
rendered, so two quick clicks cannot both send
"make it billable". -->
<input
type="hidden"
name="billable"
value={e.billable ? 'false' : 'true'}
/>
<button
class="v2-btn v2-btn-sm"
class:time-billable={e.billable}
disabled={timeBusy}
title={e.billable ? 'Mark as non-billable' : 'Mark as billable'}
>
{#if e.billable}
{e.hourly_rate
? money(e.hourly_rate, e.currency) + '/hr'
: 'Billable'}
{:else}
Not billable
{/if}
</button>
</form>
<button
type="button"
class="v2-btn v2-btn-sm"
aria-label="Delete this entry"
title="Delete this entry"
onclick={() => (confirmDelete = e.id)}
>
<Trash2 size={11} />
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
{#if conversation.length === 0}
<p class="v2-sub" style="margin:0 0 18px;font-size:12.5px">
Nothing has been said on this ticket yet. A reply below is the first response. It is
@ -705,4 +1022,141 @@
.att:hover {
background: var(--v2-hover);
}
/* ── time panel ─────────────────────────────────────────────────────── */
.time-panel {
margin-bottom: 18px;
padding: 13px 15px;
}
.time-head {
display: flex;
align-items: flex-start;
gap: 8px;
flex-wrap: wrap;
}
/* Pushes the two controls to the right without either of them owning a
margin, so they stay put when the disclosure below drops to its own row. */
.time-title {
margin-right: auto;
}
.time-total {
font-size: 12.5px;
margin-top: 3px;
}
/* The summary is the button; the default triangle would sit inside it. */
.time-log > summary {
list-style: none;
cursor: pointer;
}
.time-log > summary::-webkit-details-marker {
display: none;
}
.time-log[open] > summary {
border-color: var(--v2-slate);
}
/* Open, the disclosure claims the whole row: a two-column form inside a
button-width column would be two columns of nothing. Closed, it is just
the button and sits beside Start. */
.time-log[open] {
flex: 1 0 100%;
}
.time-log-form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 12px;
padding-top: 13px;
border-top: 1px solid var(--v2-line);
}
.time-wide {
grid-column: 1 / -1;
}
.time-log-form .v2-field {
margin-bottom: 0;
}
.time-log-foot {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.time-check {
display: flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
cursor: pointer;
}
.time-log-form .v2-hint {
margin: 0;
}
.time-error {
margin: 12px 0 0;
gap: 8px;
flex-wrap: wrap;
}
.time-empty {
font-size: 12.5px;
margin: 12px 0 2px;
}
.time-panel .v2-table-wrap {
margin: 12px -15px -13px;
border: 0;
}
.time-running {
color: var(--v2-ember);
font-size: 11.5px;
font-weight: 600;
margin-left: 6px;
}
.time-row-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
align-items: center;
}
.time-billable {
color: var(--v2-moss);
border-color: color-mix(in srgb, var(--v2-moss) 40%, transparent);
}
.time-danger {
color: var(--v2-rust);
border-color: color-mix(in srgb, var(--v2-rust) 40%, transparent);
}
@media (max-width: 768px) {
/* One field per line, and both controls full width: two half-width
buttons at the top of a 390px card are two small targets. */
.time-log-form {
grid-template-columns: 1fr;
}
.time-title,
.time-timer,
.time-log {
flex: 1 0 100%;
}
.time-timer .v2-btn,
.time-log > summary {
width: 100%;
justify-content: center;
min-height: 44px;
}
/* A row's controls are the smallest things on the panel and the ones
pressed with a thumb, so they get 44px in both directions, the delete
button included: it holds an icon and nothing else, and 12px of padding
around a 11px trash can is a 40px target. */
.time-row-actions {
justify-content: flex-start;
margin-top: 8px;
}
.time-row-actions .v2-btn {
min-height: 44px;
min-width: 44px;
padding-inline: 12px;
}
.time-log-form .v2-btn,
.time-check {
min-height: 44px;
}
}
</style>

View file

@ -18,7 +18,7 @@
import PageHeader from '$lib/v2/components/PageHeader.svelte';
import StatCard from '$lib/v2/components/StatCard.svelte';
import Pill from '$lib/v2/components/Pill.svelte';
import { money, count, shortDate } from '$lib/v2/format.js';
import { money, count, shortDate, hoursMinutes as hm } from '$lib/v2/format.js';
import { ChevronLeft, ChevronRight, Square, Receipt } from '@lucide/svelte';
/** @type {{ data: any, form: any }} */
@ -52,13 +52,6 @@
const liveMinutes = (e) =>
e.is_running ? e.live_duration_minutes + sinceLoad : e.duration_minutes;
/** Minutes → "1h 48m". Timesheets are read in hours, never in minutes. */
function hm(mins) {
const m = Math.max(0, Math.round(mins));
const h = Math.floor(m / 60);
return h ? `${h}h ${m % 60}m` : `${m}m`;
}
const WEEKDAY = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const todayISO = new Date().toISOString().slice(0, 10);
@ -124,6 +117,9 @@
<button class="v2-btn" aria-label="Next week" onclick={() => shiftWeek(7)}>
<ChevronRight />
</button>
<!-- This page is one person's week. The report is every window and every
grouping of the same entries, and where the CSV comes from. -->
<a class="v2-btn" href={resolve('/timesheet/report')}>Report</a>
{/snippet}
</PageHeader>

View file

@ -0,0 +1,31 @@
import { getTimeReport } from '$lib/server/v2/timesheet.js';
/**
* Where the time went, over a window the URL carries.
*
* Every filter is a query parameter and the filter bar is a plain GET form, so
* a report is a link: it can be bookmarked, sent to whoever asked for it, and
* reloaded without re-picking three dropdowns. That is also what lets the
* "Export CSV" anchor be the same query string pointed at the proxy.
*
* Nothing is validated here beyond what `getTimeReport` checks, because the
* API decides what this caller may see: an agent gets their own time, an admin
* gets the org's, and asking for someone else's is refused there.
*
* @type {import('./$types').PageServerLoad}
*/
export async function load({ cookies, url }) {
const filters = {
start: url.searchParams.get('start') || undefined,
end: url.searchParams.get('end') || undefined,
group_by: url.searchParams.get('group_by') || undefined,
billable: url.searchParams.get('billable') || undefined
};
const { report } = await getTimeReport({ cookies }, filters);
// The window comes back from the API rather than being echoed from the
// request: asked for without one, it picks the last 30 days, and the date
// inputs have to show the days actually reported on.
return { report, filters: { ...filters, start: report.start, end: report.end } };
}

View file

@ -0,0 +1,217 @@
<script>
/**
* Where the time went: totals by agent, ticket or account over a window.
*
* The timesheet answers "what did I do this week". This answers the two
* questions asked of that data afterwards, which are somebody else's: how
* much of the month went to this account, and what of it can be billed.
*
* The filter bar is a plain GET form, so the report is a URL. That is what
* makes it shareable, reloadable, and what lets the CSV link be the same
* query string pointed at the export proxy. No JavaScript is needed for any
* of it.
*/
import { resolve } from '$app/paths';
import PageHeader from '$lib/v2/components/PageHeader.svelte';
import StatCard from '$lib/v2/components/StatCard.svelte';
import { money, count, shortDate, hoursMinutes as hm } from '$lib/v2/format.js';
import { Download } from '@lucide/svelte';
/** @type {{ data: any }} */
let { data } = $props();
let report = $derived(data.report);
let filters = $derived(data.filters);
const GROUPS = [
{ value: 'agent', label: 'Agent' },
{ value: 'ticket', label: 'Ticket' },
{ value: 'account', label: 'Account' }
];
/** What each row's name means, said once above the table rather than per row. */
const GROUP_NOUN = { agent: 'Agent', ticket: 'Ticket', account: 'Account' };
let billableShare = $derived(
report.totals.total_minutes
? Math.round((report.totals.billable_minutes / report.totals.total_minutes) * 100)
: 0
);
/**
* The window and filters as a query string, for the CSV link.
*
* Built from what the API reported on, not from the form's current state:
* the link has to export the table being looked at, and an unsubmitted
* change to a date input has not reached the table yet.
*/
let exportQuery = $derived.by(() => {
const qs = new URLSearchParams({
start: report.start,
end: report.end,
group_by: report.group_by
});
if (filters.billable === 'true' || filters.billable === 'false') {
qs.set('billable', filters.billable);
}
return qs.toString();
});
/** Currency for the value column. One symbol cannot label a mixed sum. */
let currency = $derived(
report.currencies?.length === 1 ? report.currencies[0] : data.org.currency
);
</script>
<PageHeader title="Time report">
{#snippet crumb()}
<a href={resolve('/timesheet')}>Timesheet</a>
{/snippet}
{#snippet sub()}
{shortDate(report.start)} - {shortDate(report.end)} · by {GROUP_NOUN[
report.group_by
].toLowerCase()}
{/snippet}
{#snippet actions()}
<!-- An anchor, not a fetch: the browser's own download, and the proxy
behind it adds the token the httpOnly cookie will not hand over. -->
<a
class="v2-btn export"
href="/api/time-entries/report/export/?{exportQuery}"
data-sveltekit-reload
>
<Download size={12} />Export CSV
</a>
{/snippet}
</PageHeader>
<div class="v2-pad" style="padding-top:16px;flex:none">
<form method="GET" class="filters">
<div class="v2-field">
<label for="r-start">From</label>
<input id="r-start" class="v2-input" type="date" name="start" value={report.start} />
</div>
<div class="v2-field">
<label for="r-end">To</label>
<input id="r-end" class="v2-input" type="date" name="end" value={report.end} />
</div>
<div class="v2-field">
<label for="r-group">Group by</label>
<select id="r-group" class="v2-input" name="group_by" value={report.group_by}>
{#each GROUPS as group (group.value)}
<option value={group.value}>{group.label}</option>
{/each}
</select>
</div>
<div class="v2-field">
<label for="r-billable">Show</label>
<select id="r-billable" class="v2-input" name="billable" value={filters.billable ?? ''}>
<option value="">All time</option>
<option value="true">Billable only</option>
<option value="false">Non-billable only</option>
</select>
</div>
<button class="v2-btn v2-btn-primary filters-go">Apply</button>
</form>
<div class="v2-stats" style="margin-top:14px">
<StatCard label="Logged" value={hm(report.totals.total_minutes)} tone="ink" />
<StatCard
label="Billable"
value={hm(report.totals.billable_minutes)}
tone="moss"
detail="{billableShare}% of logged time"
/>
<StatCard
label="Billable value"
value={money(report.totals.billable_value, currency)}
tone="slate"
detail={report.currencies?.length > 1
? `Mixed currencies: ${report.currencies.join(', ')}`
: 'At the rate saved on each entry'}
/>
<StatCard label="Entries" value={count(report.totals.entry_count)} tone="slate" />
</div>
</div>
<div class="v2-scroll">
<div class="v2-pad" style="padding-bottom:32px">
{#if report.rows.length === 0}
<p class="v2-sub" style="font-size:12.5px">
No time logged in this window. Widen the dates, or clear the billable filter.
</p>
{:else}
<div class="v2-table-wrap">
<table class="v2-table">
<thead>
<tr>
<th>{GROUP_NOUN[report.group_by]}</th>
<th class="v2-r">Entries</th>
<th class="v2-r">Billable</th>
<th class="v2-r">Value</th>
<th class="v2-r">Logged</th>
</tr>
</thead>
<tbody>
{#each report.rows as row (row.key ?? row.name)}
<tr>
<td data-m="title">
{#if report.group_by === 'ticket' && row.key}
<a href={resolve(`/tickets/${row.key}`)} class="v2-table-primary">{row.name}</a>
{:else if report.group_by === 'account' && row.key}
<a href={resolve(`/accounts/${row.key}`)} class="v2-table-primary">{row.name}</a
>
{:else}
{row.name}
{/if}
</td>
<td class="v2-num v2-r" data-m="meta" data-l="entries">{count(row.entry_count)}</td>
<td class="v2-num v2-r" data-m="meta" data-l="billable">
{hm(row.billable_minutes)}
</td>
<td class="v2-num v2-r" data-m="meta" data-l="worth">
{money(row.billable_value, currency)}
</td>
<td class="v2-num v2-r" data-m="tag">{hm(row.total_minutes)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
<style>
.filters {
display: flex;
align-items: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.filters .v2-field {
margin-bottom: 0;
flex: 1 1 150px;
}
.filters-go {
height: 36px;
}
@media (max-width: 768px) {
/* Two fields a row on a phone: four full-width inputs stacked would put
Apply below the fold on every load. The dates pair, the pickers pair. */
.filters .v2-field {
flex: 1 1 calc(50% - 5px);
}
.filters-go {
flex: 1 0 100%;
min-height: 44px;
}
/* Thumb-sized. The shared control is 40px, which is fine under a mouse
and short of the 44px this app holds phone targets to. */
.filters :global(.v2-input),
.export {
min-height: 44px;
}
}
</style>

View file

@ -0,0 +1,49 @@
/**
* CSV export proxy for the time report.
*
* Streams the upstream `text/csv` straight through rather than buffering it,
* so a year of entries starts downloading immediately, and the same for the
* reason `cases/analytics/export` does it: this is the pattern, not a second
* opinion about it.
*
* It exists because the access token lives in an httpOnly cookie. A plain
* `<a download>` from the page cannot attach it, so the link points here and
* this handler adds the Authorization header on the server. The query string
* is forwarded as given; the API validates every parameter and decides what
* this caller may see, which is where that decision belongs.
*/
import { env } from '$env/dynamic/public';
const API_BASE_URL = `${env.PUBLIC_DJANGO_API_URL}/api`;
/** @type {import('./$types').RequestHandler} */
export async function GET({ cookies, request, url }) {
const accessToken = cookies.get('jwt_access');
if (!accessToken) return new Response('Unauthorized', { status: 401 });
const qs = url.searchParams.toString();
const upstream = await fetch(`${API_BASE_URL}/time-entries/report/export/${qs ? `?${qs}` : ''}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'text/csv'
},
signal: request.signal
});
if (!upstream.ok || !upstream.body) {
return new Response(`Upstream error: ${upstream.status}`, {
status: upstream.status || 502
});
}
return new Response(upstream.body, {
status: 200,
headers: {
'Content-Type': upstream.headers.get('Content-Type') || 'text/csv',
'Content-Disposition':
upstream.headers.get('Content-Disposition') || 'attachment; filename="time.csv"'
}
});
}

View file

@ -338,6 +338,30 @@ class ApiConfig {
static String timesheet({required String start, required String end}) =>
'$apiBaseUrl/time-entries/timesheet/?start=$start&end=$end';
/// Where the time went over a window: totals by agent, ticket or account.
///
/// `groupBy` is one of agent/ticket/account and `billable`, when given, is
/// the string 'true' or 'false'; the API rejects anything else rather than
/// guessing, so the caller passes what the picker chose and nothing else.
///
/// The CSV export beside this endpoint is deliberately not wired up here.
/// Ticket analytics made the same call: a download belongs on the web, and
/// a phone has nowhere useful to put a spreadsheet.
static String timeReport({
required String start,
required String end,
required String groupBy,
String? billable,
}) {
final query = StringBuffer(
'start=$start&end=$end&group_by=${Uri.encodeQueryComponent(groupBy)}',
);
if (billable != null) {
query.write('&billable=${Uri.encodeQueryComponent(billable)}');
}
return '$apiBaseUrl/time-entries/report/?$query';
}
/// Solutions (Knowledge Base). List/create.
static String get solutions => '$apiBaseUrl/cases/solutions/';

View file

@ -0,0 +1,125 @@
/// Where the time went, as `/api/time-entries/report/` returns it.
///
/// The timesheet answers "what did I do this week". This answers the question
/// asked of that data afterwards, usually by somebody else: how much of the
/// month went to this account, and how much of it can be billed.
///
/// Money arrives as a string, not a double, because the API builds it from
/// Decimal columns and a JSON number would round it on the way. It is parsed
/// once here rather than at every call site.
library;
/// One grouped row: an agent, a ticket or an account.
class TimeReportRow {
const TimeReportRow({
required this.name,
this.key,
this.totalMinutes = 0,
this.billableMinutes = 0,
this.billableValue = 0,
this.entryCount = 0,
});
/// The id of the thing this row groups, or null where there is none: time
/// on tickets with no account lands in a named bucket rather than being
/// dropped, and unattributed time is exactly what a report is run to find.
final String? key;
final String name;
final int totalMinutes;
final int billableMinutes;
final double billableValue;
final int entryCount;
bool get isBillableOnly => billableMinutes == totalMinutes;
factory TimeReportRow.fromJson(Map<String, dynamic> json) {
return TimeReportRow(
key: json['key'] as String?,
name: json['name'] as String? ?? '',
totalMinutes: json['total_minutes'] as int? ?? 0,
billableMinutes: json['billable_minutes'] as int? ?? 0,
billableValue: _money(json['billable_value']),
entryCount: json['entry_count'] as int? ?? 0,
);
}
}
/// The whole report: the window the server actually reported on, the rows,
/// and the totals under them.
class TimeReport {
const TimeReport({
required this.start,
required this.end,
this.groupBy = 'agent',
this.rows = const [],
this.totalMinutes = 0,
this.billableMinutes = 0,
this.billableValue = 0,
this.entryCount = 0,
this.currencies = const [],
});
/// Read back from the response rather than kept from the request: asked for
/// without a window, the API picks the last 30 days, and the header has to
/// name the days actually reported on.
final DateTime start;
final DateTime end;
final String groupBy;
final List<TimeReportRow> rows;
final int totalMinutes;
final int billableMinutes;
final double billableValue;
final int entryCount;
/// Every currency present in the window. The value totals add across all of
/// them, so more than one here means no single symbol can label the figure
/// honestly and the screen says so instead of picking one.
final List<String> currencies;
bool get isEmpty => rows.isEmpty;
bool get isMixedCurrency => currencies.length > 1;
String? get currency => currencies.length == 1 ? currencies.first : null;
int get billableShare =>
totalMinutes == 0 ? 0 : ((billableMinutes / totalMinutes) * 100).round();
factory TimeReport.fromJson(Map<String, dynamic> json) {
final totals = (json['totals'] as Map<String, dynamic>?) ?? const {};
return TimeReport(
start: _date(json['start']),
end: _date(json['end']),
groupBy: json['group_by'] as String? ?? 'agent',
rows: ((json['rows'] as List<dynamic>?) ?? const [])
.whereType<Map<String, dynamic>>()
.map(TimeReportRow.fromJson)
.toList(),
totalMinutes: totals['total_minutes'] as int? ?? 0,
billableMinutes: totals['billable_minutes'] as int? ?? 0,
billableValue: _money(totals['billable_value']),
entryCount: totals['entry_count'] as int? ?? 0,
currencies: ((json['currencies'] as List<dynamic>?) ?? const [])
.map((c) => c.toString())
.toList(),
);
}
}
/// A decimal string from the API as a double. Absent or unparseable is 0,
/// never null: a missing total is nothing logged, and every caller would
/// otherwise write the same `?? 0`.
double _money(dynamic value) =>
value == null ? 0 : (double.tryParse(value.toString()) ?? 0);
/// A YYYY-MM-DD string as a local calendar date.
///
/// Parsed with an explicit midnight rather than `DateTime.parse` on the bare
/// date, so it is local midnight and not UTC midnight: reading a UTC midnight
/// in a zone behind Greenwich lands on the previous day and mislabels the
/// window by one.
DateTime _date(dynamic value) {
final text = value?.toString() ?? '';
return DateTime.tryParse('${text}T00:00:00') ?? DateTime.now();
}

View file

@ -0,0 +1,127 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../config/api_config.dart';
import '../data/models/time_report.dart';
import '../services/api_service.dart';
/// What the report is being asked for: a window, a grouping, a billable
/// filter.
///
/// Held apart from the loaded data for the same reason the timesheet's range
/// is: the filters the user picked have to survive a failed load, and folding
/// them into the response would snap the pickers back on every network error.
class TimeReportFilters {
const TimeReportFilters({
required this.start,
required this.end,
this.groupBy = 'agent',
this.billable,
});
final DateTime start;
final DateTime end;
/// One of agent/ticket/account. The API rejects anything else, so the
/// picker's values and this string are the same short list.
final String groupBy;
/// 'true', 'false', or null for everything. Null is not the same as 'false':
/// one asks for all time, the other for non-billable time only.
final String? billable;
/// The last 30 days, matching what the API picks when asked without a
/// window, so the screen opens on the same report either way.
factory TimeReportFilters.recent() {
final now = DateTime.now();
final end = DateTime(now.year, now.month, now.day);
return TimeReportFilters(
start: end.subtract(const Duration(days: 29)),
end: end,
);
}
TimeReportFilters copyWith({
DateTime? start,
DateTime? end,
String? groupBy,
String? billable,
bool clearBillable = false,
}) {
return TimeReportFilters(
start: start ?? this.start,
end: end ?? this.end,
groupBy: groupBy ?? this.groupBy,
billable: clearBillable ? null : (billable ?? this.billable),
);
}
String get startParam => _param(start);
String get endParam => _param(end);
static String _param(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
}
/// The filters the report screen is on. Changing these refetches, because
/// [TimeReportNotifier.build] watches them.
class TimeReportFiltersNotifier extends Notifier<TimeReportFilters> {
@override
TimeReportFilters build() => TimeReportFilters.recent();
void setGroupBy(String groupBy) => state = state.copyWith(groupBy: groupBy);
void setBillable(String? billable) => state = billable == null
? state.copyWith(clearBillable: true)
: state.copyWith(billable: billable);
void setWindow(DateTime start, DateTime end) =>
state = state.copyWith(start: start, end: end);
}
final timeReportFiltersProvider =
NotifierProvider<TimeReportFiltersNotifier, TimeReportFilters>(
TimeReportFiltersNotifier.new,
);
/// Totals over a window, grouped.
///
/// Read-only. Who may see what is the server's call: an agent's report covers
/// their own time and an admin's covers the org, which is why nothing here
/// sends a `profile` and nothing here filters by one.
class TimeReportNotifier extends AsyncNotifier<TimeReport> {
final ApiService _apiService = ApiService();
@override
Future<TimeReport> build() {
final filters = ref.watch(timeReportFiltersProvider);
return _fetch(filters);
}
Future<TimeReport> _fetch(TimeReportFilters filters) async {
final response = await _apiService.get(
ApiConfig.timeReport(
start: filters.startParam,
end: filters.endParam,
groupBy: filters.groupBy,
billable: filters.billable,
),
);
if (!response.success || response.data == null) {
throw Exception(response.message ?? 'Could not load the time report.');
}
return TimeReport.fromJson(response.data!);
}
Future<void> refresh() async {
final filters = ref.read(timeReportFiltersProvider);
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _fetch(filters));
}
}
final timeReportProvider =
AsyncNotifierProvider<TimeReportNotifier, TimeReport>(
TimeReportNotifier.new,
);

View file

@ -57,6 +57,7 @@ import '../screens/solutions/solutions_list_screen.dart';
import '../screens/solutions/solution_detail_screen.dart';
import '../screens/tickets/approvals_inbox_screen.dart';
import '../screens/tickets/ticket_analytics_screen.dart';
import '../screens/timesheet/time_report_screen.dart';
import '../screens/timesheet/timesheet_screen.dart';
import '../screens/notifications/notifications_screen.dart';
import '../screens/documents/documents_list_screen.dart';
@ -151,6 +152,10 @@ class AppRoutes {
/// path that says where it lives is the one that survives being linked to.
static const String timesheet = '/timesheet';
/// Totals over a window, grouped. Nested under the timesheet on both
/// clients, because it is the same entries asked a different question.
static const String timeReport = '/timesheet/report';
/// The notification feed. Matches the web app's `/notifications`.
static const String notifications = '/notifications';
@ -403,6 +408,12 @@ final appRouterProvider = Provider<GoRouter>((ref) {
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const TimesheetScreen(),
),
GoRoute(
path: AppRoutes.timeReport,
name: 'timeReport',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const TimeReportScreen(),
),
GoRoute(
path: AppRoutes.notifications,
name: 'notifications',

View file

@ -0,0 +1,385 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../core/theme/theme.dart';
import '../../data/models/time_report.dart';
import '../../providers/time_report_provider.dart';
/// Where the time went: totals by agent, ticket or account over a window.
///
/// The timesheet screen is one person's week. This is every window and every
/// grouping of the same entries, and it is what someone opens when the
/// question is about an account's month rather than their own Tuesday.
///
/// Read-only, and no CSV export: the same call the ticket analytics screen
/// made. A phone has nowhere useful to put a spreadsheet, and the web page
/// carries the download.
class TimeReportScreen extends ConsumerWidget {
const TimeReportScreen({super.key});
static const _groups = [
('agent', 'Agent'),
('ticket', 'Ticket'),
('account', 'Account'),
];
static const _billableFilters = [
(null, 'All time'),
('true', 'Billable'),
('false', 'Non-billable'),
];
@override
Widget build(BuildContext context, WidgetRef ref) {
final filters = ref.watch(timeReportFiltersProvider);
final reportAsync = ref.watch(timeReportProvider);
return Scaffold(
backgroundColor: AppColors.surfaceDim,
appBar: AppBar(
title: const Text('Time report'),
backgroundColor: AppColors.surface,
elevation: 0,
scrolledUnderElevation: 1,
leading: IconButton(
icon: const Icon(LucideIcons.chevronLeft),
tooltip: 'Back',
onPressed: () => context.pop(),
),
),
body: Column(
children: [
_FilterBar(filters: filters),
Expanded(
child: reportAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => _ErrorState(
message: '$error',
onRetry: () => ref.read(timeReportProvider.notifier).refresh(),
),
data: (report) => RefreshIndicator(
onRefresh: () =>
ref.read(timeReportProvider.notifier).refresh(),
child: _Body(report: report),
),
),
),
],
),
);
}
}
/// The window, the grouping and the billable filter.
///
/// Its own bar under the app bar rather than app-bar actions: three controls
/// and a date range do not fit beside a title at 390px, and the range is the
/// label that says which report is on screen.
class _FilterBar extends ConsumerWidget {
const _FilterBar({required this.filters});
final TimeReportFilters filters;
Future<void> _pickWindow(BuildContext context, WidgetRef ref) async {
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(2000),
lastDate: DateTime.now().add(const Duration(days: 1)),
initialDateRange: DateTimeRange(start: filters.start, end: filters.end),
);
if (picked == null) return;
ref
.read(timeReportFiltersProvider.notifier)
.setWindow(picked.start, picked.end);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final label =
'${DateFormat('d MMM').format(filters.start)} - '
'${DateFormat('d MMM').format(filters.end)}';
return Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border(bottom: BorderSide(color: AppColors.border)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 44px tall, which is the smallest a thumb reliably hits.
SizedBox(
height: 44,
child: OutlinedButton.icon(
onPressed: () => _pickWindow(context, ref),
icon: const Icon(LucideIcons.calendar, size: 16),
label: Text(label),
),
),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final (value, label) in TimeReportScreen._groups)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(label),
selected: filters.groupBy == value,
onSelected: (_) => ref
.read(timeReportFiltersProvider.notifier)
.setGroupBy(value),
),
),
Container(
width: 1,
height: 24,
margin: const EdgeInsets.only(right: 8),
color: AppColors.border,
),
for (final (value, label) in TimeReportScreen._billableFilters)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(label),
selected: filters.billable == value,
onSelected: (_) => ref
.read(timeReportFiltersProvider.notifier)
.setBillable(value),
),
),
],
),
),
],
),
);
}
}
class _Body extends StatelessWidget {
const _Body({required this.report});
final TimeReport report;
@override
Widget build(BuildContext context) {
return ListView(
// Always scrollable, so pull-to-refresh works on an empty report too.
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 96),
children: [
_summary(),
if (report.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 0),
child: Text(
'No time logged in this window. Widen the dates, or clear the '
'billable filter.',
style: AppTypography.body.copyWith(
color: AppColors.textSecondary,
),
),
)
else
...report.rows.map((row) => _row(context, row)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
child: Text(
'Value is what the billable time is worth at the rate saved on '
'each entry, so changing a rate does not rewrite what past months '
'were worth. To export these entries as a spreadsheet, open the '
'report on the web.',
style: AppTypography.caption.copyWith(
color: AppColors.textSecondary,
height: 1.5,
),
),
),
],
);
}
Widget _summary() {
return Container(
margin: const EdgeInsets.fromLTRB(12, 12, 12, 0),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: AppLayout.borderRadiusMd,
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_hm(report.totalMinutes),
style: AppTypography.h1.copyWith(fontSize: 28, height: 1.1),
),
const SizedBox(height: 2),
Text(
'logged, by ${report.groupBy}',
style: AppTypography.caption.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 16,
runSpacing: 6,
children: [
_stat(
_hm(report.billableMinutes),
'billable (${report.billableShare}%)',
),
// One symbol cannot label a sum of two currencies, so a mixed
// window says which ones it holds instead of picking one.
if (report.isMixedCurrency)
_stat(report.currencies.join(', '), 'mixed currencies')
else if (report.billableValue > 0)
_stat(
NumberFormat.simpleCurrency(
name: report.currency,
).format(report.billableValue),
'at the saved rates',
),
_stat('${report.entryCount}', 'entries'),
],
),
],
),
);
}
/// One figure and its label as a single Text, not a Row: a Row inside the
/// Wrap cannot shrink and overflows once the system font is scaled up.
Widget _stat(String value, String label) {
return Text.rich(
TextSpan(
children: [
TextSpan(text: value, style: AppTypography.labelSmall),
TextSpan(
text: ' $label',
style: AppTypography.caption.copyWith(
color: AppColors.textSecondary,
),
),
],
),
);
}
/// One grouped row. Tappable through to the ticket or the account it names;
/// an agent row and the unattributed bucket have nowhere to go.
Widget _row(BuildContext context, TimeReportRow row) {
final destination = _destination(row);
return Container(
margin: const EdgeInsets.fromLTRB(12, 12, 12, 0),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: AppLayout.borderRadiusMd,
border: Border.all(color: AppColors.border),
),
child: InkWell(
onTap: destination == null ? null : () => context.push(destination),
borderRadius: AppLayout.borderRadiusMd,
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
row.name,
style: AppTypography.labelSmall.copyWith(fontSize: 14),
),
),
const SizedBox(width: 10),
Text(_hm(row.totalMinutes), style: AppTypography.labelSmall),
],
),
const SizedBox(height: 6),
Wrap(
spacing: 14,
runSpacing: 4,
children: [
_stat('${row.entryCount}', 'entries'),
if (row.billableMinutes > 0)
_stat(_hm(row.billableMinutes), 'billable'),
if (row.billableValue > 0 && !report.isMixedCurrency)
_stat(
NumberFormat.simpleCurrency(
name: report.currency,
).format(row.billableValue),
'worth',
),
],
),
],
),
),
),
);
}
/// Interpolated the way every other screen builds a detail path; the
/// constants in `AppRoutes` carry the `:id` placeholder, not a value.
String? _destination(TimeReportRow row) {
if (row.key == null) return null;
if (report.groupBy == 'ticket') return '/tickets/${row.key}';
if (report.groupBy == 'account') return '/accounts/${row.key}';
return null;
}
}
/// Minutes as "1h 48m". Reports are read in hours, never in minutes.
String _hm(int minutes) {
final m = minutes < 0 ? 0 : minutes;
final h = m ~/ 60;
return h > 0 ? '${h}h ${m % 60}m' : '${m}m';
}
class _ErrorState extends StatelessWidget {
const _ErrorState({required this.message, required this.onRetry});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.circleAlert,
size: 36,
color: AppColors.textTertiary,
),
const SizedBox(height: 12),
Text(
message,
textAlign: TextAlign.center,
style: AppTypography.body.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 16),
OutlinedButton(onPressed: onRetry, child: const Text('Try again')),
],
),
),
);
}
}

View file

@ -10,6 +10,7 @@ import '../../core/theme/theme.dart';
import '../../data/models/time_entry.dart';
import '../../data/models/timesheet.dart';
import '../../providers/timesheet_provider.dart';
import '../../routes/app_router.dart';
/// A week of your own logged time.
///
@ -81,6 +82,16 @@ class _TimesheetScreenState extends ConsumerState<TimesheetScreen> {
backgroundColor: AppColors.surface,
elevation: 0,
scrolledUnderElevation: 1,
actions: [
// This screen is one person's week. The report is every window and
// every grouping of the same entries.
IconButton(
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
icon: const Icon(LucideIcons.chartColumn, size: 20),
tooltip: 'Time report',
onPressed: () => context.push(AppRoutes.timeReport),
),
],
),
body: Column(
children: [

View file

@ -505,11 +505,16 @@ class _ManualEntrySheetState extends State<_ManualEntrySheet> {
}).toList(),
),
const SizedBox(height: 16),
// Required, not optional: `TimeEntryCreateSerializer` rejects a
// blank description on a manual entry. The hint used to say
// optional, so leaving it empty got a 400 and a "Failed to add
// entry" snackbar with nothing pointing at the empty box.
TextField(
controller: _descController,
maxLines: 2,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
hintText: 'What did you work on? (optional)',
hintText: 'What did you work on?',
filled: true,
fillColor: AppColors.gray50,
border: OutlineInputBorder(
@ -540,19 +545,26 @@ class _ManualEntrySheetState extends State<_ManualEntrySheet> {
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () {
final end = DateTime.now();
final start = end.subtract(Duration(minutes: _minutes));
Navigator.pop(
context,
_ManualEntryResult(
startedAt: start,
endedAt: end,
billable: _billable,
description: _descController.text.trim(),
),
);
},
// Disabled until there is something to send, so the
// refusal happens under the empty field rather than as a
// snackbar after a round trip.
onPressed: _descController.text.trim().isEmpty
? null
: () {
final end = DateTime.now();
final start = end.subtract(
Duration(minutes: _minutes),
);
Navigator.pop(
context,
_ManualEntryResult(
startedAt: start,
endedAt: end,
billable: _billable,
description: _descController.text.trim(),
),
);
},
child: const Text('Add entry'),
),
),

View file

@ -0,0 +1,170 @@
import 'package:bottle_crm/config/api_config.dart';
import 'package:bottle_crm/data/models/time_report.dart';
import 'package:bottle_crm/providers/time_report_provider.dart';
import 'package:flutter_test/flutter_test.dart';
/// The time report: what the API sends, and what the screen asks it for.
///
/// The money is the part worth pinning. It arrives as a decimal string,
/// because the API builds it from Decimal columns and a JSON number would
/// round it in transit, so anything that parses it has to say so out loud.
Map<String, dynamic> payload({
List<Map<String, dynamic>>? rows,
Map<String, dynamic>? totals,
List<String> currencies = const ['USD'],
String groupBy = 'agent',
}) => {
'start': '2026-08-01',
'end': '2026-08-31',
'group_by': groupBy,
'rows':
rows ??
[
{
'key': 'p1',
'name': 'Aswani Kumar',
'total_minutes': 135,
'billable_minutes': 105,
'billable_value': '135.50',
'entry_count': 3,
},
],
'totals':
totals ??
{
'total_minutes': 135,
'billable_minutes': 105,
'billable_value': '135.50',
'entry_count': 3,
},
'currencies': currencies,
};
void main() {
group('parsing a report', () {
test('reads the window, the rows and the totals', () {
final report = TimeReport.fromJson(payload());
expect(report.start, DateTime(2026, 8, 1));
expect(report.end, DateTime(2026, 8, 31));
expect(report.groupBy, 'agent');
expect(report.totalMinutes, 135);
expect(report.billableMinutes, 105);
expect(report.billableValue, 135.5);
expect(report.entryCount, 3);
expect(report.rows.single.name, 'Aswani Kumar');
expect(report.rows.single.billableValue, 135.5);
});
test('parses a date as local midnight, not UTC midnight', () {
// A UTC midnight read in a zone behind Greenwich lands on the previous
// day and mislabels the window by one.
final report = TimeReport.fromJson(payload());
expect(report.start.hour, 0);
expect(report.start.day, 1);
expect(report.start.isUtc, isFalse);
});
test('an empty report is empty rather than broken', () {
final report = TimeReport.fromJson({
'start': '2026-08-01',
'end': '2026-08-31',
'group_by': 'ticket',
'rows': <Map<String, dynamic>>[],
'totals': <String, dynamic>{},
'currencies': <String>[],
});
expect(report.isEmpty, isTrue);
expect(report.totalMinutes, 0);
expect(report.billableValue, 0);
expect(report.billableShare, 0);
expect(report.currency, isNull);
});
test('a row with no key is the unattributed bucket, not a broken row', () {
final report = TimeReport.fromJson(
payload(
groupBy: 'account',
rows: [
{
'key': null,
'name': 'No account',
'total_minutes': 20,
'billable_minutes': 0,
'billable_value': '0.00',
'entry_count': 1,
},
],
),
);
expect(report.rows.single.key, isNull);
expect(report.rows.single.name, 'No account');
});
test('two currencies in the window are flagged, not averaged into one', () {
final report = TimeReport.fromJson(payload(currencies: ['EUR', 'USD']));
expect(report.isMixedCurrency, isTrue);
// Null, so nothing can put one symbol in front of a sum of two.
expect(report.currency, isNull);
});
test('the billable share is a percentage of logged time', () {
final report = TimeReport.fromJson(payload());
expect(report.billableShare, 78);
});
});
group('what the screen asks for', () {
test('the default window is the last 30 days, inclusive', () {
final filters = TimeReportFilters.recent();
expect(filters.end.difference(filters.start).inDays, 29);
expect(filters.groupBy, 'agent');
expect(filters.billable, isNull);
});
test('the URL carries the window, the grouping and nothing else', () {
final url = ApiConfig.timeReport(
start: '2026-08-01',
end: '2026-08-31',
groupBy: 'account',
);
expect(url, contains('start=2026-08-01'));
expect(url, contains('end=2026-08-31'));
expect(url, contains('group_by=account'));
// The API rejects an unknown `billable`, so it is sent only when picked.
expect(url, isNot(contains('billable')));
});
test('the billable filter is sent when it is picked', () {
final url = ApiConfig.timeReport(
start: '2026-08-01',
end: '2026-08-31',
groupBy: 'agent',
billable: 'true',
);
expect(url, contains('billable=true'));
});
test('clearing the billable filter is not the same as choosing false', () {
// 'false' asks for non-billable time; null asks for all of it. Folding
// the two together would quietly hide every billable hour.
final filters = TimeReportFilters(
start: DateTime(2026, 1, 1),
end: DateTime(2026, 2, 1),
billable: 'false',
);
expect(filters.copyWith(clearBillable: true).billable, isNull);
expect(filters.copyWith(groupBy: 'ticket').billable, 'false');
});
});
}