crowdtime-cli 0.18.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.18.0 → crowdtime_cli-0.20.0}/PKG-INFO +1 -1
  2. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/pyproject.toml +1 -1
  3. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/__init__.py +1 -1
  4. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/client.py +19 -0
  5. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/auth_cmd.py +3 -0
  6. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/clients_cmd.py +70 -18
  7. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/log_cmd.py +31 -5
  8. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/pto_cmd.py +122 -13
  9. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/team_cmd.py +176 -3
  10. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/timesheet_cmd.py +25 -4
  11. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/formatters.py +60 -8
  12. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/models.py +7 -7
  13. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +14 -6
  14. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +44 -18
  15. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md +0 -19
  16. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +3 -0
  17. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/.gitignore +0 -0
  18. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/LICENSE +0 -0
  19. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/README.md +0 -0
  20. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/auth.py +0 -0
  21. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/__init__.py +0 -0
  22. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
  23. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
  24. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/calendar_cmd.py +0 -0
  25. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
  26. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
  27. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
  28. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
  29. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/invoice_cmd.py +0 -0
  30. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
  31. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
  32. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/projects_cmd.py +0 -0
  33. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
  34. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
  35. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
  36. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
  37. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/version_cmd.py +0 -0
  38. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/config.py +0 -0
  39. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/main.py +0 -0
  40. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/oauth.py +0 -0
  41. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/resolvers.py +0 -0
  42. {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/utils.py +0 -0
  43. {crowdtime_cli-0.18.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.18.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.18.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.18.0"
3
+ __version__ = "0.20.0"
@@ -133,6 +133,25 @@ class CrowdTimeClient:
133
133
  response = self._request("DELETE", url)
134
134
  return response
135
135
 
136
+ def current_user_id(self) -> str | None:
137
+ """The authenticated user's id, cached in the config after the first lookup.
138
+
139
+ ``ct auth login`` refreshes the cache and ``ct auth logout`` clears it,
140
+ so the cached id always belongs to the stored token.
141
+ """
142
+ cached = self.config.get("user.id")
143
+ if cached:
144
+ return str(cached)
145
+ try:
146
+ me = self.get("/auth/me/", org_scoped=False)
147
+ except APIError:
148
+ return None
149
+ user_id = me.get("id") if isinstance(me, dict) else None
150
+ if not user_id:
151
+ return None
152
+ self.config.set("user.id", str(user_id))
153
+ return str(user_id)
154
+
136
155
  def _request(self, method: str, url: str, **kwargs: Any) -> Any:
137
156
  """Execute an HTTP request with error handling."""
138
157
  if _DEBUG:
@@ -59,6 +59,7 @@ def _login_with_token(token: str, output_json: bool) -> None:
59
59
  client = CrowdTimeClient(require_auth=True, require_org=False)
60
60
  data = client.get("/api/v1/auth/me/", org_scoped=False)
61
61
  user = User(**data)
62
+ get_config().set("user.id", user.id) # read back by current_user_id()
62
63
 
63
64
  if output_json:
64
65
  print_json(user)
@@ -118,6 +119,7 @@ def _login_with_browser(output_json: bool) -> None:
118
119
  client = CrowdTimeClient(require_auth=True, require_org=False)
119
120
  data = client.get("/api/v1/auth/me/", org_scoped=False)
120
121
  user = User(**data)
122
+ config.set("user.id", user.id) # read back by current_user_id()
121
123
 
122
124
  if output_json:
123
125
  print_json(user)
@@ -140,6 +142,7 @@ def logout() -> None:
140
142
  return
141
143
 
142
144
  clear_token()
145
+ get_config().set("user.id", "")
143
146
  format_success("Logged out. Token removed.")
144
147
 
145
148
 
@@ -84,7 +84,9 @@ def list_clients(
84
84
  for c in clients_list:
85
85
  client_id = str(c.get("id", ""))
86
86
  name = c.get("name", "")
87
- contact = c.get("contact_name", "") or "-"
87
+ # The primary contact record; contact_name is a legacy field (CRO-83).
88
+ primary = c.get("primary_contact") or {}
89
+ contact = primary.get("name") or c.get("contact_name", "") or "-"
88
90
  project_count = str(c.get("project_count", 0))
89
91
  is_archived = c.get("is_archived", False)
90
92
  status = "[red]Archived[/red]" if is_archived else "[green]Active[/green]"
@@ -117,11 +119,30 @@ def show(
117
119
  console.print(f"\n[bold]{data.get('name')}[/bold]")
118
120
  console.print(f" ID: {data.get('id')}")
119
121
  console.print(f" Currency: {data.get('currency', '-')}")
120
- console.print(f" Contact: {data.get('contact_name', '-')}")
121
- if data.get("contact_email"):
122
- console.print(f" Email: {data['contact_email']}")
123
- if data.get("contact_phone"):
124
- console.print(f" Phone: {data['contact_phone']}")
122
+ contacts = data.get("contacts") or []
123
+ if contacts:
124
+ for contact in contacts:
125
+ line = f" Contact: {contact.get('name', '')}"
126
+ if contact.get("email"):
127
+ line += f" <{contact['email']}>"
128
+ if contact.get("phone"):
129
+ line += f" {contact['phone']}"
130
+ if contact.get("role"):
131
+ line += f" — {contact['role']}"
132
+ flags = [
133
+ label for key, label in (("is_primary", "primary"), ("receives_invoices", "invoices"))
134
+ if contact.get(key)
135
+ ]
136
+ if flags:
137
+ line += f" [dim]({', '.join(flags)})[/dim]"
138
+ console.print(line)
139
+ else:
140
+ # Legacy contact fields, only on clients that never got a contact record.
141
+ console.print(f" Contact: {data.get('contact_name') or '-'}")
142
+ if data.get("contact_email"):
143
+ console.print(f" Email: {data['contact_email']}")
144
+ if data.get("contact_phone"):
145
+ console.print(f" Phone: {data['contact_phone']}")
125
146
  if data.get("address"):
126
147
  console.print(f" Address: {data['address']}")
127
148
  status = "Archived" if data.get("is_archived") else "Active"
@@ -149,7 +170,7 @@ def create(
149
170
  country: Optional[str] = typer.Option(None, "--country", help="Country."),
150
171
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
151
172
  ) -> None:
152
- """Create a new client.
173
+ """Create a new client. --contact/--email add its primary contact.
153
174
 
154
175
  Examples:
155
176
  ct clients create "Acme Corp"
@@ -157,13 +178,13 @@ def create(
157
178
  """
158
179
  api_client = CrowdTimeClient(require_auth=True, require_org=True)
159
180
 
181
+ if contact_email and not contact_name:
182
+ format_error("--email needs --contact (the contact's name).")
183
+ raise typer.Exit(1)
184
+
160
185
  payload: dict = {"name": name}
161
186
  if currency:
162
187
  payload["currency"] = currency
163
- if contact_name:
164
- payload["contact_name"] = contact_name
165
- if contact_email:
166
- payload["contact_email"] = contact_email
167
188
  if address_line1:
168
189
  payload["address_line1"] = address_line1
169
190
  if address_line2:
@@ -180,10 +201,22 @@ def create(
180
201
  try:
181
202
  data = api_client.post("/clients/", data=payload)
182
203
 
204
+ # Contacts are records of their own, shown on the client page and used
205
+ # for invoicing — not the legacy contact_* fields on the client (CRO-83).
206
+ if contact_name:
207
+ contact = api_client.post(f"/clients/{data.get('id')}/contacts/", data={
208
+ "name": contact_name,
209
+ "email": contact_email or "",
210
+ "is_primary": True,
211
+ })
212
+ data["contacts"] = [contact]
213
+
183
214
  if output_json:
184
215
  print_json(data)
185
216
  else:
186
217
  format_success(f"Client '{data.get('name')}' created (ID: {data.get('id')})")
218
+ if contact_name:
219
+ console.print(f" Primary contact: {contact_name}" + (f" <{contact_email}>" if contact_email else ""))
187
220
  except APIError as e:
188
221
  format_error(e.message)
189
222
  raise typer.Exit(1)
@@ -203,7 +236,7 @@ def edit(
203
236
  country: Optional[str] = typer.Option(None, "--country", help="Country."),
204
237
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
205
238
  ) -> None:
206
- """Edit an existing client.
239
+ """Edit an existing client. --contact/--email update its primary contact.
207
240
 
208
241
  Examples:
209
242
  ct clients edit <client-id> --name "New Name"
@@ -216,10 +249,6 @@ def edit(
216
249
  payload["name"] = name
217
250
  if currency is not None:
218
251
  payload["currency"] = currency
219
- if contact_name is not None:
220
- payload["contact_name"] = contact_name
221
- if contact_email is not None:
222
- payload["contact_email"] = contact_email
223
252
  if address_line1 is not None:
224
253
  payload["address_line1"] = address_line1
225
254
  if city is not None:
@@ -231,12 +260,35 @@ def edit(
231
260
  if country is not None:
232
261
  payload["country"] = country
233
262
 
234
- if not payload:
263
+ contact_payload: dict = {}
264
+ if contact_name is not None:
265
+ contact_payload["name"] = contact_name
266
+ if contact_email is not None:
267
+ contact_payload["email"] = contact_email
268
+
269
+ if not payload and not contact_payload:
235
270
  format_error("No changes specified. Use --name, --currency, --contact, etc.")
236
271
  raise typer.Exit(1)
237
272
 
238
273
  try:
239
- data = api_client.patch(f"/clients/{client_id}/", data=payload)
274
+ if payload:
275
+ data = api_client.patch(f"/clients/{client_id}/", data=payload)
276
+ else:
277
+ data = api_client.get(f"/clients/{client_id}/")
278
+
279
+ # Contacts are records of their own, shown on the client page and used
280
+ # for invoicing (CRO-83): update the primary one, or create it.
281
+ if contact_payload:
282
+ contacts = extract_results(api_client.get(f"/clients/{client_id}/contacts/"))
283
+ primary = next((c for c in contacts if c.get("is_primary")), contacts[0] if contacts else None)
284
+ if primary:
285
+ api_client.patch(f"/clients/{client_id}/contacts/{primary['id']}/", data=contact_payload)
286
+ elif contact_payload.get("name"):
287
+ api_client.post(f"/clients/{client_id}/contacts/", data={**contact_payload, "is_primary": True})
288
+ else:
289
+ format_error("This client has no contact yet — pass --contact (the contact's name) with --email.")
290
+ raise typer.Exit(1)
291
+ data = api_client.get(f"/clients/{client_id}/")
240
292
 
241
293
  if output_json:
242
294
  print_json(data)
@@ -381,11 +381,23 @@ def list_entries(
381
381
  task: Optional[str] = typer.Option(None, "--task", "-t", help="Filter by task name or ID."),
382
382
  billable: Optional[bool] = typer.Option(None, "--billable/--no-billable", "-b/-B",
383
383
  help="Filter by billable status."),
384
- user: Optional[str] = typer.Option(None, "--user", "-u", help="Filter by user ID (manager+ only)."),
384
+ user: Optional[str] = typer.Option(
385
+ None, "--user", "-u",
386
+ help="Filter by user ID. Only people whose time you can see: yourself, your direct "
387
+ "reports (manager), and anyone on projects you manage (project manager).",
388
+ ),
389
+ mine: bool = typer.Option(
390
+ False, "--mine",
391
+ help="Only your own entries. Managers and project managers otherwise also see "
392
+ "the time of the people and projects they manage.",
393
+ ),
385
394
  format_output: str = typer.Option("table", "--format", help="Output format: table, json, csv."),
386
395
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
387
396
  ) -> None:
388
- """List time entries with optional filters."""
397
+ """List time entries with optional filters.
398
+
399
+ The table and CSV show a User column whenever an entry isn't yours.
400
+ """
389
401
  effective_date = date
390
402
  if today_flag:
391
403
  effective_date = "today"
@@ -402,6 +414,7 @@ def list_entries(
402
414
  task=task,
403
415
  billable=billable,
404
416
  user=user,
417
+ mine=mine,
405
418
  format_output=format_output,
406
419
  output_json=output_json,
407
420
  )
@@ -417,6 +430,7 @@ def _list_entries(
417
430
  task: str | None = None,
418
431
  billable: bool | None = None,
419
432
  user: str | None = None,
433
+ mine: bool = False,
420
434
  format_output: str = "table",
421
435
  output_json: bool = False,
422
436
  ) -> None:
@@ -425,6 +439,14 @@ def _list_entries(
425
439
 
426
440
  client = CrowdTimeClient(require_auth=True, require_org=True)
427
441
 
442
+ # Known up front so other people's entries can be told apart (CRO-85).
443
+ me = client.current_user_id()
444
+ if mine:
445
+ if not me:
446
+ format_error("Could not resolve your user id. Run ct auth login again.")
447
+ raise typer.Exit(1)
448
+ user = me
449
+
428
450
  params: dict = {}
429
451
 
430
452
  if date_str:
@@ -484,7 +506,10 @@ def _list_entries(
484
506
  elif format_output == "csv":
485
507
  _print_csv(entries)
486
508
  else:
487
- format_entries_table(entries, show_date=True)
509
+ # Someone else's entry with no owner column looks like your own;
510
+ # the column appears as soon as the list holds one (CRO-85).
511
+ foreign = any(e.user and e.user != me for e in entries)
512
+ format_entries_table(entries, show_date=True, show_user=bool(user) or foreign)
488
513
  except APIError as e:
489
514
  format_error(e.message)
490
515
  raise typer.Exit(1)
@@ -492,13 +517,14 @@ def _list_entries(
492
517
 
493
518
  def _print_csv(entries: list[TimeEntry]) -> None:
494
519
  """Print entries as CSV (plain output, safe for piping)."""
495
- print("id,date,project,task,description,duration,billable,hourly_rate,billable_amount")
520
+ print("id,date,user,project,task,description,duration,billable,hourly_rate,billable_amount")
496
521
  for e in entries:
497
522
  desc = (e.description or "").replace('"', '""')
523
+ user_name = (e.user_name or "").replace('"', '""')
498
524
  rate = getattr(e, "hourly_rate", None) or ""
499
525
  amount = getattr(e, "billable_amount", None) or ""
500
526
  print(
501
- f'{e.id},{e.date or ""},'
527
+ f'{e.id},{e.date or ""},"{user_name}",'
502
528
  f'{e.project_name or ""},{e.task_name or ""},'
503
529
  f'"{desc}",{e.duration or 0},'
504
530
  f'{"yes" if e.is_billable else "no"},'
@@ -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)