crowdtime-cli 0.19.0__tar.gz → 0.20.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/PKG-INFO +1 -1
  2. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/pyproject.toml +1 -1
  3. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/__init__.py +1 -1
  4. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/pto_cmd.py +122 -13
  5. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/team_cmd.py +176 -3
  6. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/formatters.py +35 -4
  7. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/models.py +7 -7
  8. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +6 -2
  9. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +24 -4
  10. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md +0 -19
  11. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +3 -0
  12. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/.gitignore +0 -0
  13. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/LICENSE +0 -0
  14. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/README.md +0 -0
  15. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/auth.py +0 -0
  16. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/client.py +0 -0
  17. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/__init__.py +0 -0
  18. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
  19. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
  20. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
  21. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/calendar_cmd.py +0 -0
  22. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
  23. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
  24. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
  25. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
  26. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
  27. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/invoice_cmd.py +0 -0
  28. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/log_cmd.py +0 -0
  29. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
  30. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
  31. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/projects_cmd.py +0 -0
  32. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
  33. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
  34. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
  35. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
  36. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/timesheet_cmd.py +0 -0
  37. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/version_cmd.py +0 -0
  38. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/config.py +0 -0
  39. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/main.py +0 -0
  40. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/oauth.py +0 -0
  41. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/resolvers.py +0 -0
  42. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/utils.py +0 -0
  43. {crowdtime_cli-0.19.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/version_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crowdtime-cli
3
- Version: 0.19.0
3
+ Version: 0.20.0
4
4
  Summary: AI-powered time tracking CLI — a modern, developer-friendly alternative to Harvest
5
5
  Project-URL: Homepage, https://crowdtime.lat
6
6
  Project-URL: Documentation, https://crowdtime.lat/docs
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "crowdtime-cli"
3
- version = "0.19.0"
3
+ version = "0.20.0"
4
4
  description = "AI-powered time tracking CLI — a modern, developer-friendly alternative to Harvest"
5
5
  readme = "README.md"
6
6
  license = {text = "Proprietary"}
@@ -1,3 +1,3 @@
1
1
  """CrowdTime CLI - AI-powered time tracking from the command line."""
2
2
 
3
- __version__ = "0.19.0"
3
+ __version__ = "0.20.0"
@@ -46,7 +46,11 @@ from ..models import (
46
46
  )
47
47
  from ..utils import format_date, format_duration, parse_date, parse_date_range
48
48
 
49
- app = typer.Typer(name="pto", help="Manage PTO (paid time off) requests.")
49
+ app = typer.Typer(name="pto", help="Manage time off requests.")
50
+ # Hidden from help: nothing may tell someone without paid time off that it
51
+ # exists (CRO-84). Admins and people who have it know where to look.
52
+ allowance_app = typer.Typer(name="allowance", help="Paid time off allowances (admin+).")
53
+ app.add_typer(allowance_app, name="allowance", hidden=True)
50
54
  console = Console()
51
55
 
52
56
 
@@ -258,12 +262,6 @@ def add_cmd(
258
262
  help="Decimal hours per day. Omit for full-day from membership.",
259
263
  ),
260
264
  notes: Optional[str] = typer.Option(None, "--notes", "-n", help="Optional free-text notes."),
261
- paid: Optional[bool] = typer.Option(
262
- None, "--paid/--unpaid",
263
- help="Whether these hours are paid. Omit to follow the organization's "
264
- "policy for this kind. Account managers and above only — the API "
265
- "rejects the request from anyone below, it is not ignored.",
266
- ),
267
265
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
268
266
  ) -> None:
269
267
  """On-behalf PTO entry, auto-approved. Project manager+, within their scope."""
@@ -289,18 +287,13 @@ def add_cmd(
289
287
  payload["hours_per_day"] = str(hpd)
290
288
  if notes is not None:
291
289
  payload["notes"] = notes
292
- # Declared since the flag was added but never sent, so --unpaid silently
293
- # booked paid leave. The backend defaults every kind to paid, so the
294
- # omission was not neutral.
295
- if paid is not None:
296
- payload["is_paid"] = paid
297
290
 
298
291
  try:
299
292
  data = client.post("/time-off/", data=payload)
300
293
  except APIError as e:
301
294
  # Surface the rejection as a friendly error — the backend refuses
302
295
  # `user` values outside the caller's visible set, refuses it entirely
303
- # below project manager, and refuses `is_paid` below account manager.
296
+ # below project manager.
304
297
  format_error(e.message)
305
298
  raise typer.Exit(1)
306
299
 
@@ -721,3 +714,119 @@ def summary_cmd(
721
714
 
722
715
  summary = TimeOffSummary(**data)
723
716
  console.print(format_timeoff_summary_panel(summary))
717
+
718
+
719
+ # ── Paid time off (CRO-84) ──────────────────────────────────────────────
720
+ # Whether someone is paid for leave follows the allowance an admin gave them;
721
+ # nobody flags a request as paid. The API answers 404 to anyone who may not
722
+ # know, so these commands print the same thing for "none" and "not allowed".
723
+
724
+
725
+ def _print_balance(data: dict) -> None:
726
+ b = data.get("balance")
727
+ name = data.get("name") or data.get("email")
728
+ days = data.get("paid_time_off_days_per_year")
729
+ if not b:
730
+ console.print(f"[bold]{name}[/bold]: no paid time off — their leave is unpaid.")
731
+ return
732
+ console.print(
733
+ f"[bold]{name}[/bold]: {b['remaining_days']} of {b['allowance_days']} paid days left "
734
+ f"({b['year_start']} → {b['year_end']})"
735
+ )
736
+ console.print(
737
+ f" used {b['used_days']} · requested {b['pending_days']} · unpaid {b['unpaid_days']}"
738
+ f" [dim]{days} days/year from {data.get('paid_time_off_start_date')}[/dim]"
739
+ )
740
+
741
+
742
+ @app.command("balance", hidden=True)
743
+ def balance_cmd(
744
+ user: Optional[str] = typer.Option(None, "--user", "-u", help="Someone whose leave you approve (email or UUID). Default: you."),
745
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
746
+ ) -> None:
747
+ """Your paid time off balance, or that of someone whose leave you approve."""
748
+ client = CrowdTimeClient(require_auth=True, require_org=True)
749
+ try:
750
+ user_id = _resolve_user_to_uuid(client, user) if user else client.current_user_id()
751
+ data = client.get(f"/members/{user_id}/paid-time-off/")
752
+ except APIError as e:
753
+ if e.status_code == 404:
754
+ console.print("[dim]No balance to show.[/dim]")
755
+ return
756
+ format_error(e.message)
757
+ raise typer.Exit(1)
758
+ if output_json:
759
+ print_json(data)
760
+ return
761
+ _print_balance(data)
762
+
763
+
764
+ @allowance_app.command("list")
765
+ def allowance_list_cmd(
766
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
767
+ ) -> None:
768
+ """Every member's paid time off allowance and remaining days. Admin+."""
769
+ client = CrowdTimeClient(require_auth=True, require_org=True)
770
+ try:
771
+ rows = client.get("/members/paid-time-off/").get("results", [])
772
+ except APIError as e:
773
+ format_error("Only admins can list allowances." if e.status_code == 404 else e.message)
774
+ raise typer.Exit(1)
775
+ if output_json:
776
+ print_json(rows)
777
+ return
778
+ from rich.table import Table
779
+
780
+ table = Table(show_header=True, header_style="bold")
781
+ table.add_column("User ID", style="dim")
782
+ table.add_column("Name")
783
+ table.add_column("Days/year", justify="right")
784
+ table.add_column("Counting from")
785
+ table.add_column("Left this year", justify="right")
786
+ for r in rows:
787
+ b = r.get("balance")
788
+ table.add_row(
789
+ r["user_id"], r.get("name") or r.get("email"),
790
+ str(r["paid_time_off_days_per_year"]) if r.get("paid_time_off_days_per_year") else "—",
791
+ str(r.get("paid_time_off_start_date") or "—"),
792
+ str(b["remaining_days"]) if b else "—",
793
+ )
794
+ console.print(table)
795
+
796
+
797
+ @allowance_app.command("set")
798
+ def allowance_set_cmd(
799
+ user: str = typer.Argument(..., help="Member (email or UUID)."),
800
+ days: str = typer.Option(..., "--days", help="Paid days per allowance year; 0 for none."),
801
+ start: Optional[str] = typer.Option(
802
+ None, "--from",
803
+ help="When the allowance starts counting (YYYY-MM-DD): the day they joined, or the day "
804
+ "this arrangement started. Defaults to the day they joined.",
805
+ ),
806
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
807
+ ) -> None:
808
+ """Set someone's paid time off. Admin+, never your own.
809
+
810
+ Applies to leave approved from now on; leave already approved keeps what it was paid.
811
+ """
812
+ try:
813
+ value = Decimal(days)
814
+ except InvalidOperation:
815
+ format_error(f"--days must be a number, got '{days}'.")
816
+ raise typer.Exit(1)
817
+ payload: dict = {"paid_time_off_days_per_year": str(value)}
818
+ if start:
819
+ payload["paid_time_off_start_date"] = format_date(_parse_date_or_exit(start))
820
+
821
+ client = CrowdTimeClient(require_auth=True, require_org=True)
822
+ try:
823
+ user_id = _resolve_user_to_uuid(client, user)
824
+ data = client.patch(f"/members/{user_id}/paid-time-off/", data=payload)
825
+ except APIError as e:
826
+ format_error("Member not found." if e.status_code == 404 else e.message)
827
+ raise typer.Exit(1)
828
+ if output_json:
829
+ print_json(data)
830
+ return
831
+ format_success("Paid time off updated.")
832
+ _print_balance(data)
@@ -1,4 +1,4 @@
1
- """Team commands: manage manager → direct-report assignments.
1
+ """Team commands: member profiles and manager → direct-report assignments.
2
2
 
3
3
  Mirrors Harvest's manager model. Admins assign members as direct reports
4
4
  to a manager; the manager can see those members' hours across any project
@@ -11,6 +11,7 @@ concurrent edits from clobbering each other.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import re
14
15
  from typing import List, Optional
15
16
 
16
17
  import typer
@@ -18,9 +19,9 @@ from rich.console import Console
18
19
  from rich.table import Table
19
20
 
20
21
  from ..client import APIError, CrowdTimeClient
21
- from ..formatters import extract_results, format_error, format_success, print_json
22
+ from ..formatters import extract_results, format_currency, format_error, format_success, print_json
22
23
 
23
- app = typer.Typer(name="team", help="Manage direct-report assignments.")
24
+ app = typer.Typer(name="team", help="Member profiles and direct-report assignments.")
24
25
  console = Console()
25
26
 
26
27
 
@@ -413,3 +414,175 @@ def reports(
413
414
  )
414
415
 
415
416
  console.print(table)
417
+
418
+
419
+ _UUID_RE = re.compile(r"^[0-9a-fA-F-]{36}$")
420
+
421
+
422
+ def _resolve_user_id(client: CrowdTimeClient, ref: str) -> str:
423
+ """User id for ``me``, a user UUID, or an email.
424
+
425
+ Emails go through the member list, which project managers and up can read
426
+ (each within their reach) — everyone else passes ``me`` or the user ID.
427
+ """
428
+ if ref.lower() == "me":
429
+ user_id = client.current_user_id()
430
+ if not user_id:
431
+ raise APIError("Could not determine the current user.", status_code=401)
432
+ return user_id
433
+ if _UUID_RE.match(ref):
434
+ return ref
435
+ try:
436
+ return _resolve_membership(client, ref)["user"]["id"]
437
+ except APIError as e:
438
+ if e.status_code == 403:
439
+ raise APIError(
440
+ "Looking members up by email needs project manager access. Use 'me' or the user ID.",
441
+ status_code=403,
442
+ )
443
+ raise
444
+
445
+
446
+ def _hours(value) -> str:
447
+ return f"{float(value or 0):.2f}h"
448
+
449
+
450
+ @app.command("show")
451
+ def show(
452
+ member: str = typer.Argument(..., help="Member: 'me', user ID, or email (email lookup needs project_manager+)."),
453
+ date_from: Optional[str] = typer.Option(None, "--from", help="Start date YYYY-MM-DD (default: 30 days ago)."),
454
+ date_to: Optional[str] = typer.Option(None, "--to", help="End date YYYY-MM-DD (default: today)."),
455
+ output_json: bool = typer.Option(False, "--json", help="Output profile and overview as JSON."),
456
+ ) -> None:
457
+ """Show a member's profile and activity, as far as your role allows.
458
+
459
+ Account managers and up can open anyone; others can open themselves and
460
+ the people on projects they run. Sections you may not see are left out:
461
+ project managers see only time on their projects, utilization and
462
+ timesheets are for the person's managers, rates are manager+.
463
+
464
+ Examples:
465
+ ct team show me
466
+ ct team show alice@acme.com --from 2026-09-01 --to 2026-09-30
467
+ """
468
+ client = CrowdTimeClient(require_auth=True, require_org=True)
469
+ params = {k: v for k, v in (("from", date_from), ("to", date_to)) if v}
470
+ try:
471
+ user_id = _resolve_user_id(client, member)
472
+ profile = client.get(f"/members/{user_id}/")
473
+ overview = client.get(f"/members/{user_id}/overview/", params=params)
474
+ except APIError as e:
475
+ format_error(e.message)
476
+ raise typer.Exit(1)
477
+
478
+ if output_json:
479
+ print_json({"profile": profile, "overview": overview})
480
+ return
481
+
482
+ user = profile.get("user", {})
483
+ access = profile.get("access", {})
484
+ console.print(f"[bold]{user.get('full_name') or user.get('email')}[/bold] [dim]{profile.get('role')}[/dim]")
485
+ console.print(f"[dim]User ID:[/dim] {user.get('id')}")
486
+ console.print(f"[dim]Email:[/dim] {user.get('email')} [dim]Timezone:[/dim] {user.get('timezone')}")
487
+ line = f"[dim]Capacity:[/dim] {profile.get('weekly_capacity_hours')}h/week [dim]Joined:[/dim] {str(profile.get('joined_at', ''))[:10]}"
488
+ if access.get("money") and profile.get("hourly_rate") is not None:
489
+ line += f" [dim]Rate:[/dim] {format_currency(profile['hourly_rate'])}/hr"
490
+ console.print(line)
491
+ for label, key in (("Reports to", "managers"), ("Direct reports", "direct_reports")):
492
+ people = profile.get(key) or []
493
+ if people:
494
+ console.print(f"[dim]{label}:[/dim] " + ", ".join(p["name"] for p in people))
495
+
496
+ projects = profile.get("projects") or []
497
+ if projects:
498
+ table = Table(show_header=True, header_style="bold", title="Assigned projects")
499
+ table.add_column("Project ID", style="dim")
500
+ table.add_column("Project")
501
+ table.add_column("Client", style="dim")
502
+ table.add_column("Role on project")
503
+ for p in projects:
504
+ role = (p.get("effective_role") or p.get("project_role") or "-").replace("_", " ")
505
+ table.add_row(p["id"], p["name"], p.get("client_name") or "-", role)
506
+ console.print(table)
507
+
508
+ timer = overview.get("running_timer")
509
+ if timer:
510
+ task = f" / {timer['task_name']}" if timer.get("task_name") else ""
511
+ console.print(f"[green]Tracking now:[/green] {timer['project_name']}{task} (since {timer['started_at']})")
512
+
513
+ if access.get("level") == "profile":
514
+ console.print("\n[dim]Profile only — time details are for this person's managers and project managers.[/dim]")
515
+ else:
516
+ if access.get("level") == "projects":
517
+ console.print("\n[dim]Showing time on the projects you run.[/dim]")
518
+ summary = overview.get("summary") or {}
519
+ console.print(f"\n[bold]{overview.get('from')} → {overview.get('to')}[/bold]")
520
+ stats = [
521
+ f"Hours {_hours(summary.get('total_hours'))}",
522
+ f"Billable {summary.get('billable_percent', 0)}%",
523
+ f"Entries {summary.get('entry_count', 0)}",
524
+ f"Days logged {summary.get('days_logged', 0)}",
525
+ ]
526
+ if "utilization_percent" in summary and summary["utilization_percent"] is not None:
527
+ stats.append(f"Utilization {summary['utilization_percent']}% of {summary.get('capacity_hours')}h")
528
+ if access.get("money") and summary.get("billable_amount") is not None:
529
+ stats.append(f"Billable amount {format_currency(summary['billable_amount'])}")
530
+ console.print(" ".join(stats))
531
+
532
+ by_project = overview.get("by_project") or []
533
+ if by_project:
534
+ table = Table(show_header=True, header_style="bold", title="By project")
535
+ table.add_column("Project ID", style="dim")
536
+ table.add_column("Project")
537
+ table.add_column("Client", style="dim")
538
+ table.add_column("Hours", justify="right")
539
+ table.add_column("Billable", justify="right")
540
+ if access.get("money"):
541
+ table.add_column("Amount", justify="right")
542
+ for p in by_project:
543
+ row = [p["project_id"], p["project_name"], p.get("client_name") or "-",
544
+ _hours(p["hours"]), _hours(p["billable_hours"])]
545
+ if access.get("money"):
546
+ row.append(format_currency(p.get("billable_amount")))
547
+ table.add_row(*row)
548
+ console.print(table)
549
+
550
+ recent = overview.get("recent_entries") or []
551
+ if recent:
552
+ table = Table(show_header=True, header_style="bold", title="Recent entries")
553
+ table.add_column("Date")
554
+ table.add_column("Project")
555
+ table.add_column("Task", style="dim")
556
+ table.add_column("Hours", justify="right")
557
+ table.add_column("Notes", style="dim")
558
+ for e in recent:
559
+ table.add_row(e["date"], e["project_name"], e.get("task_name") or "-", _hours(e["hours"]), e.get("notes") or "")
560
+ console.print(table)
561
+
562
+ timesheets = overview.get("timesheets")
563
+ if timesheets:
564
+ table = Table(show_header=True, header_style="bold", title="Timesheets")
565
+ table.add_column("Period")
566
+ table.add_column("Status")
567
+ table.add_column("Hours", justify="right")
568
+ for p in timesheets["periods"]:
569
+ hours = _hours(p["total_hours"]) if p.get("total_hours") is not None else "-"
570
+ table.add_row(f"{p['period_start']} → {p['period_end']}", p["status"], hours)
571
+ console.print(table)
572
+ if timesheets.get("submitted_rate") is not None:
573
+ console.print(f"[dim]Submitted on {timesheets['submitted_rate']}% of recent weeks, {timesheets.get('missing_count', 0)} missing.[/dim]")
574
+
575
+ time_off = overview.get("time_off")
576
+ if time_off:
577
+ kinds = ", ".join(f"{k} {v}d" for k, v in time_off.get("days_by_kind", {}).items()) or "none"
578
+ console.print(f"\n[bold]Time off {time_off.get('year')}:[/bold] {time_off.get('total_days', 0)} approved days ({kinds})")
579
+ bal = time_off.get("balance") # only present for those allowed to see paid leave
580
+ if bal:
581
+ console.print(
582
+ f" paid time off: {bal['remaining_days']} of {bal['allowance_days']} days left "
583
+ f"({bal['year_start']} → {bal['year_end']}), {bal['unpaid_days']} unpaid"
584
+ )
585
+ elif "balance" in time_off:
586
+ console.print(" no paid time off: their leave is unpaid")
587
+ for row in time_off.get("upcoming", []):
588
+ console.print(f" upcoming: {row['start_date']} → {row['end_date']} {row['kind']} [{row['status']}]")
@@ -710,6 +710,9 @@ def format_timeoff_detail(entry: TimeOffEntry) -> Panel:
710
710
 
711
711
  table.add_row("Kind", _pto_kind_label(entry))
712
712
  table.add_row("Status", _pto_status_label(entry))
713
+ pay = _pto_pay_label(entry) # empty unless the API sent the split (CRO-84)
714
+ if pay:
715
+ table.add_row("Paid", pay)
713
716
  table.add_row("Notes", entry.notes or "[dim](none)[/dim]")
714
717
 
715
718
  table.add_row("", "") # spacer
@@ -729,7 +732,7 @@ def format_timeoff_detail(entry: TimeOffEntry) -> Panel:
729
732
  table.add_row("Updated", format_iso_datetime(str(entry.updated_at)))
730
733
 
731
734
  border = _pto_status_style(entry.status)
732
- title = f"PTO {entry.id}"
735
+ title = f"Time off {entry.id}"
733
736
  return Panel(table, title=title, border_style=border, padding=(0, 1))
734
737
 
735
738
 
@@ -740,7 +743,7 @@ def format_timeoff_table(
740
743
  ) -> Table:
741
744
  """Tabular view. Returns the Rich Table for caller-side printing."""
742
745
  table = Table(show_header=True, header_style="bold", title=None)
743
- table.add_column("ID", style="dim", width=36) # full UUID
746
+ table.add_column("ID", style="dim", min_width=36, no_wrap=True) # full UUID, never truncated
744
747
  if show_user:
745
748
  table.add_column("User", width=20)
746
749
  table.add_column("Dates", width=25)
@@ -748,7 +751,15 @@ def format_timeoff_table(
748
751
  table.add_column("Hours", justify="right", width=8)
749
752
  table.add_column("Kind", width=10)
750
753
  table.add_column("Status", width=10)
751
- table.add_column("Notes", width=40)
754
+ # Paid/unpaid split, only when the API sent it — it never does to someone
755
+ # who isn't allowed to know paid leave exists (CRO-84).
756
+ show_pay = any(
757
+ getattr(e, "paid_days", None) is not None or getattr(e, "pay_preview", None)
758
+ for e in entries
759
+ )
760
+ if show_pay:
761
+ table.add_column("Paid", width=16)
762
+ table.add_column("Notes", width=24 if show_pay else 40)
752
763
 
753
764
  for e in entries:
754
765
  start = e.start_date.isoformat() if e.start_date else "—"
@@ -763,12 +774,32 @@ def format_timeoff_table(
763
774
  format_duration(hours),
764
775
  _pto_kind_label(e),
765
776
  _pto_status_label(e),
766
- truncate(e.notes or "", 40),
767
777
  ])
778
+ if show_pay:
779
+ row.append(_pto_pay_label(e))
780
+ row.append(truncate(e.notes or "", 24 if show_pay else 40))
768
781
  table.add_row(*row)
769
782
  return table
770
783
 
771
784
 
785
+ def _pto_pay_label(entry: Any) -> str:
786
+ """'3 paid · 2 unpaid' for approved entries, 'if approved: …' for pending."""
787
+ def label(paid, unpaid) -> str:
788
+ parts = []
789
+ if paid and Decimal(str(paid)) > 0:
790
+ parts.append(f"{paid} paid")
791
+ if unpaid and Decimal(str(unpaid)) > 0:
792
+ parts.append(f"{unpaid} unpaid")
793
+ return " · ".join(parts) or "unpaid"
794
+
795
+ if getattr(entry, "paid_days", None) is not None:
796
+ return label(entry.paid_days, entry.unpaid_days)
797
+ preview = getattr(entry, "pay_preview", None)
798
+ if preview:
799
+ return "if approved: " + label(preview.get("paid_days"), preview.get("unpaid_days"))
800
+ return ""
801
+
802
+
772
803
  def format_timeoff_calendar(
773
804
  items: list[TimeOffCalendarItem],
774
805
  from_date: date,
@@ -282,11 +282,10 @@ class TimeOffEntryListItem(BaseModel):
282
282
  days_count: int = 0
283
283
  kind: str = "vacation"
284
284
  kind_display: str = ""
285
- # Explicit override; None follows the org policy for the kind.
286
- is_paid: bool | None = None
287
- # Effective paid/unpaid after resolution. Unpaid hours explain the absence
288
- # but do not count towards the hours the person is paid for.
289
- resolved_is_paid: bool = True
285
+ # Paid/unpaid split (CRO-84) — only sent to those allowed to see it.
286
+ paid_days: Decimal | None = None
287
+ unpaid_days: Decimal | None = None
288
+ pay_preview: dict | None = None
290
289
  status: str = "pending"
291
290
  status_display: str = ""
292
291
  resolved_hours_per_day: Decimal | None = None
@@ -312,8 +311,9 @@ class TimeOffEntry(BaseModel):
312
311
  total_hours: Decimal | None = None
313
312
  kind: str = "vacation"
314
313
  kind_display: str = ""
315
- is_paid: bool | None = None
316
- resolved_is_paid: bool = True
314
+ paid_days: Decimal | None = None
315
+ unpaid_days: Decimal | None = None
316
+ pay_preview: dict | None = None
317
317
  status: str = "pending"
318
318
  status_display: str = ""
319
319
  notes: str = ""
@@ -432,7 +432,6 @@ Per-user time-off entries with a kind (vacation, sick, holiday, personal, other)
432
432
  ```bash
433
433
  ct pto request <dates> # Submit your own PTO request (status=pending)
434
434
  ct pto add <user> <dates> # Add PTO on behalf of user, auto-approved (project manager+, in scope)
435
- ct pto add <user> <dates> --unpaid # ... marked unpaid (account manager+; 400 for anyone below, not ignored)
436
435
  ct pto list [--mine|--team] # List PTO entries with optional filters
437
436
  ct pto show <id> # Detail view of a single PTO entry (NEVER truncate the ID)
438
437
  ct pto edit <id> [fields] # Edit date range, kind, notes, hours/day (your own: pending only)
@@ -472,8 +471,11 @@ ct payroll payments --period 2026-03 --status paid
472
471
 
473
472
  **Workflow**: Configure compensation → Run liquidation → Approve → Mark paid.
474
473
 
475
- ### Team (Direct Reports)
474
+ ### Team (Member Profiles and Direct Reports)
476
475
  ```bash
476
+ ct team show me # Your own profile, hours, utilization, timesheets, time off
477
+ ct team show <user-id> --from 2026-09-01 --to 2026-09-30 # A member's page; sections follow your access (see below)
478
+ ct team show alice@co.com # Email lookup needs project_manager+; others use the user ID
477
479
  ct team list # All direct-report assignments (admin+) or your own
478
480
  ct team list --manager alice@company.com # Filter to a specific manager
479
481
  ct team reports # Show YOUR direct reports
@@ -488,6 +490,8 @@ ct team set alice@co.com # Clear all (zero --membe
488
490
  ct team set alice@co.com --all # Set her reports to the full org
489
491
  ```
490
492
 
493
+ `ct team show` returns only what you may see: its `access.level` is `full` (yourself, your direct reports, or everyone for admins), `projects` (people on projects you run — their time on those projects only), or `profile` (profile only). Missing sections mean no access, not no data — don't report them as zero.
494
+
491
495
  All mutating commands (`assign`, `unassign`, `set`) hit the atomic `bulk-set` endpoint — changes apply in a single transaction and the response reports `added` / `removed` / `skipped` buckets.
492
496
 
493
497
  **Visibility rule** — what a `manager` or `project_manager` sees of other people:
@@ -1562,7 +1562,7 @@ Switches active organization. Saves to config `defaults.organization`.
1562
1562
  ct org members [--json]
1563
1563
  ```
1564
1564
 
1565
- Shows: Name, Email, Role, Projects, Status. Requires manager+ role. Each member carries `projects[]` in JSON — `{id, name, code, status, project_role}` for every project they are on; `project_role` is `"project_manager"` when they run it (shown as "(PM)" in the table). Endpoint: `GET /members/`
1565
+ Shows: Name, Email, Role, Projects, Status. Requires project_manager+. Account managers and up see every member; a project manager sees the people they reach (themselves, their direct reports, the people on projects they run), and of each person only the projects they share with them — rates are null below manager. Each member carries `projects[]` in JSON — `{id, name, code, status, project_role}` for every project they are on; `project_role` is `"project_manager"` when they run it (shown as "(PM)" in the table). Endpoint: `GET /members/`
1566
1566
 
1567
1567
  ### ct org invite
1568
1568
 
@@ -2586,6 +2586,29 @@ Endpoint: `GET /insights/profitability/`
2586
2586
 
2587
2587
  Manage manager → direct-report assignments. Mirrors Harvest's manager model: assign people to a manager so the manager can see their hours across any project without being a PM on each project individually. Orthogonal to per-project PM assignments; visibility is the union of both.
2588
2588
 
2589
+ ### ct team show
2590
+
2591
+ ```
2592
+ ct team show MEMBER [--from DATE] [--to DATE] [--json]
2593
+ ```
2594
+
2595
+ One member's profile and activity — the CLI view of the web member page. `MEMBER` is `me`, a user ID, or an email; email lookup reads the member list (project_manager+, within their reach), so members and viewers pass `me` or the user ID. Defaults to the last 30 days; the range can be at most 366 days.
2596
+
2597
+ What comes back depends on who is asking, and sections the caller may not see are **left out** of the response, not nulled. `access.level` says which case applies:
2598
+ - `full` — themselves, their people manager (direct reports via `ct team`), admins/owners. Hours, billable %, weekly trend, breakdown by project/client/task, recent entries, running timer, plus utilization against capacity and the last 8 timesheet periods (`missing` = never submitted).
2599
+ - `projects` — someone who runs (explicit `project_role=project_manager`) a project the person works on. The same time sections, limited to the caller's projects; no utilization or timesheets.
2600
+ - `profile` — anyone else who can reach the person (account managers see the whole roster). Name, role, capacity, joined date, and projects (below manager: only projects shared with the caller).
2601
+
2602
+ Every level includes `profile.projects[]`: the projects the person is assigned to, each with `effective_role` — what the assignment grants on that project (`project_manager`, `member`, `viewer`; an org-level project manager without an explicit project role is a plain `member` there). Below manager, only projects shared with the caller are listed.
2603
+
2604
+ `time_off` (upcoming, recent, approved days by kind this year) is included for the person themselves and for org-level people managers who reach them. Rates and billable amounts are manager+ and never shown on your own profile; pay is not part of this view — use `ct payroll`. A person outside your reach answers `404`.
2605
+
2606
+ **Options:**
2607
+ - `--from DATE` / `--to DATE` — period (YYYY-MM-DD)
2608
+ - `--json` — `{"profile": ..., "overview": ...}`
2609
+
2610
+ Endpoints: `GET /members/<user_id>/`, `GET /members/<user_id>/overview/?from=&to=`
2611
+
2589
2612
  ### ct team list
2590
2613
 
2591
2614
  ```
@@ -2766,7 +2789,6 @@ Add a PTO entry on behalf of another user (project manager and above). The entry
2766
2789
  | `--kind`, `-k` | string | no | `vacation` (default), `sick`, `holiday`, `personal`, `other` |
2767
2790
  | `--hours-per-day`, `-h` | decimal | no | Hours/day. Empty = capacity-based default |
2768
2791
  | `--notes`, `-n` | string | no | Reason or context notes |
2769
- | `--paid` / `--unpaid` | flag | no | Whether the hours are paid. **Account manager+ only** — sending it as a project manager returns `400`, it is NOT ignored. Omit to follow the org policy for the kind |
2770
2792
  | `--json` | flag | no | JSON output |
2771
2793
 
2772
2794
  **Response (201):** full entry serializer with `status=approved`, `decided_by=<your user>`, `decided_at=<now>`.
@@ -2777,8 +2799,6 @@ Add a PTO entry on behalf of another user (project manager and above). The entry
2777
2799
  - `400` on the `user` field — caller is below `project_manager`, or the named
2778
2800
  user is outside the caller's visibility scope. This is a `400`, not a `403`:
2779
2801
  the check lives in the serializer, so treat it as a validation error.
2780
- - `400` on the `is_paid` field — caller is below account manager and sent
2781
- `--paid`/`--unpaid`.
2782
2802
 
2783
2803
  **Usage examples:**
2784
2804
 
@@ -31,25 +31,6 @@ If the user doesn't specify a kind, default to `vacation` but **confirm before s
31
31
 
32
32
  ---
33
33
 
34
- ## Paid vs unpaid
35
-
36
- Every entry resolves to paid or unpaid, exposed as `resolved_is_paid`:
37
-
38
- 1. The entry's own `is_paid`, when set.
39
- 2. Otherwise the organization's policy for that kind (`time_off_paid_kinds`).
40
- 3. Otherwise paid.
41
-
42
- **Paid** hours count towards the hours a person is paid for that month. **Unpaid**
43
- hours still explain the absence — reports and anomaly checks treat the person as
44
- away either way — but do not count towards what they are paid.
45
-
46
- Only account managers and above can set `is_paid`, via `ct pto add --paid/--unpaid`. A project manager passing the flag gets a `400` on the `is_paid` field — the request is refused, not silently booked under the org default. Omit the flag and the organization's policy for the kind applies. The same holds on update: changing `is_paid` on an existing entry is refused (`400`) below account manager.
47
- A member submitting their own request never chooses; it follows the org policy.
48
- Do not tell a user their leave is paid because they asked for it — read
49
- `resolved_is_paid` back from the entry.
50
-
51
- ---
52
-
53
34
  ## Status lifecycle
54
35
 
55
36
  ```
@@ -444,6 +444,9 @@ ct report team --week
444
444
  # Specific member
445
445
  ct report team --month --member john@company.com
446
446
 
447
+ # One person in depth: hours trend, projects, timesheet history, time off
448
+ ct team show john@company.com --from 2026-09-01 --to 2026-09-30
449
+
447
450
  # List all members
448
451
  ct org members
449
452
  ```
File without changes
File without changes