crowdtime-cli 0.18.0__tar.gz → 0.19.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.
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/PKG-INFO +1 -1
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/pyproject.toml +1 -1
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/__init__.py +1 -1
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/client.py +19 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/auth_cmd.py +3 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/clients_cmd.py +70 -18
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/log_cmd.py +31 -5
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/timesheet_cmd.py +25 -4
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/formatters.py +25 -4
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +8 -4
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +21 -15
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/.gitignore +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/LICENSE +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/README.md +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/auth.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/__init__.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/calendar_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/invoice_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/projects_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/pto_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/team_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/commands/version_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/config.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/main.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/models.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/oauth.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/resolvers.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/utils.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.19.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.
|
|
3
|
+
Version: 0.19.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
|
|
@@ -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
|
|
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
|
-
|
|
121
|
-
if
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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"},'
|
|
@@ -181,22 +181,42 @@ def timesheet_default(ctx: typer.Context) -> None:
|
|
|
181
181
|
|
|
182
182
|
@app.command("list")
|
|
183
183
|
def list_timesheets(
|
|
184
|
-
user: Optional[str] = typer.Option(
|
|
184
|
+
user: Optional[str] = typer.Option(
|
|
185
|
+
None, "--user", "-u",
|
|
186
|
+
help="User ID to view. Only people whose timesheets you can see: yourself, your "
|
|
187
|
+
"direct reports (manager), and people on projects you manage (project manager).",
|
|
188
|
+
),
|
|
189
|
+
mine: bool = typer.Option(
|
|
190
|
+
False, "--mine",
|
|
191
|
+
help="Only your own timesheets. Managers and project managers otherwise also see "
|
|
192
|
+
"the timesheets of the people and projects they manage.",
|
|
193
|
+
),
|
|
185
194
|
status_filter: Optional[str] = typer.Option(
|
|
186
195
|
None, "--status", "-s",
|
|
187
196
|
help="Filter by status: draft, submitted, approved, rejected.",
|
|
188
197
|
),
|
|
189
198
|
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
190
199
|
) -> None:
|
|
191
|
-
"""List timesheets.
|
|
200
|
+
"""List timesheets. The table shows a User column whenever one isn't yours.
|
|
201
|
+
|
|
202
|
+
A project manager sees a teammate's timesheet trimmed to the projects they
|
|
203
|
+
manage; a manager sees their direct reports' whole timesheets.
|
|
192
204
|
|
|
193
205
|
Examples:
|
|
194
206
|
ct timesheet list
|
|
207
|
+
ct timesheet list --mine
|
|
195
208
|
ct timesheet list --user <user-id>
|
|
196
209
|
ct timesheet list --status submitted
|
|
197
210
|
"""
|
|
198
211
|
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
199
212
|
|
|
213
|
+
me = client.current_user_id()
|
|
214
|
+
if mine:
|
|
215
|
+
if not me:
|
|
216
|
+
format_error("Could not resolve your user id. Run ct auth login again.")
|
|
217
|
+
raise typer.Exit(1)
|
|
218
|
+
user = me
|
|
219
|
+
|
|
200
220
|
params: dict = {}
|
|
201
221
|
if user:
|
|
202
222
|
params["user_id"] = user
|
|
@@ -210,8 +230,9 @@ def list_timesheets(
|
|
|
210
230
|
if output_json:
|
|
211
231
|
print_json(results)
|
|
212
232
|
else:
|
|
213
|
-
#
|
|
214
|
-
|
|
233
|
+
# Someone else's timesheet with no owner column looks like yours (CRO-85).
|
|
234
|
+
foreign = any(ts.get("user") and ts.get("user") != me for ts in results)
|
|
235
|
+
_print_timesheets_table(results, show_user=bool(user or status_filter) or foreign)
|
|
215
236
|
except APIError as e:
|
|
216
237
|
format_error(e.message)
|
|
217
238
|
raise typer.Exit(1)
|
|
@@ -195,8 +195,14 @@ def format_favorite_detail(fav: FavoriteEntry) -> None:
|
|
|
195
195
|
console.print(table)
|
|
196
196
|
|
|
197
197
|
|
|
198
|
-
def format_entries_table(
|
|
199
|
-
|
|
198
|
+
def format_entries_table(
|
|
199
|
+
entries: list[TimeEntry], show_date: bool = True, show_user: bool = False,
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Display time entries in a Rich table.
|
|
202
|
+
|
|
203
|
+
``show_user`` adds the owner column — needed whenever the list can hold
|
|
204
|
+
someone else's entries, which otherwise look like your own (CRO-85).
|
|
205
|
+
"""
|
|
200
206
|
if not entries:
|
|
201
207
|
console.print("[dim]No entries found.[/dim]")
|
|
202
208
|
return
|
|
@@ -205,6 +211,8 @@ def format_entries_table(entries: list[TimeEntry], show_date: bool = True) -> No
|
|
|
205
211
|
table.add_column("ID", style="dim")
|
|
206
212
|
if show_date:
|
|
207
213
|
table.add_column("Date", width=12)
|
|
214
|
+
if show_user:
|
|
215
|
+
table.add_column("User", width=18)
|
|
208
216
|
table.add_column("Project", style="cyan", width=15)
|
|
209
217
|
table.add_column("Task", style="dim", width=15)
|
|
210
218
|
table.add_column("Description", width=35)
|
|
@@ -215,6 +223,8 @@ def format_entries_table(entries: list[TimeEntry], show_date: bool = True) -> No
|
|
|
215
223
|
row: list[str] = [str(entry.id)]
|
|
216
224
|
if show_date:
|
|
217
225
|
row.append(str(entry.date or ""))
|
|
226
|
+
if show_user:
|
|
227
|
+
row.append(entry.user_name or "")
|
|
218
228
|
row.extend([
|
|
219
229
|
entry.project_name or "",
|
|
220
230
|
entry.task_name or "",
|
|
@@ -540,16 +550,27 @@ def format_members_table(members: list[dict[str, Any]]) -> None:
|
|
|
540
550
|
table.add_column("Name", width=20)
|
|
541
551
|
table.add_column("Email", width=25)
|
|
542
552
|
table.add_column("Role", width=18)
|
|
553
|
+
table.add_column("Projects", width=30)
|
|
543
554
|
table.add_column("Status", width=10)
|
|
544
555
|
|
|
545
556
|
for m in members:
|
|
557
|
+
# The API nests the user; older callers passed flattened user_* keys.
|
|
558
|
+
user = m.get("user") if isinstance(m.get("user"), dict) else {}
|
|
559
|
+
name = m.get("user_name") or user.get("full_name") or user.get("email", "")
|
|
560
|
+
email = m.get("user_email") or user.get("email", "")
|
|
546
561
|
status = "[green]Active[/green]" if m.get("is_active", True) else "[dim]Inactive[/dim]"
|
|
547
562
|
role = m.get("role", "member")
|
|
548
563
|
role_label = ROLE_LABELS.get(role, role)
|
|
564
|
+
# Project assignments, the ones they run marked (CRO-84).
|
|
565
|
+
projects = ", ".join(
|
|
566
|
+
p.get("name", "") + (" (PM)" if p.get("project_role") == "project_manager" else "")
|
|
567
|
+
for p in (m.get("projects") or [])
|
|
568
|
+
)
|
|
549
569
|
table.add_row(
|
|
550
|
-
|
|
551
|
-
|
|
570
|
+
name,
|
|
571
|
+
email,
|
|
552
572
|
role_label,
|
|
573
|
+
truncate(projects, 30) if projects else "[dim]-[/dim]",
|
|
553
574
|
status,
|
|
554
575
|
)
|
|
555
576
|
|
|
@@ -183,9 +183,13 @@ IDs — it lists every active org member not already on the project. Do not use
|
|
|
183
183
|
a different and narrower set.
|
|
184
184
|
|
|
185
185
|
**Per-project roles:** setting `--role project_manager` on a project member
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
186
|
+
makes them run *that project only* — editing it, adding members, managing its
|
|
187
|
+
tasks, seeing everyone's time on it and approving its timesheet portions —
|
|
188
|
+
whatever their organization role is (an org `member` included). It is also
|
|
189
|
+
the **only** way an org-level `project_manager` gets any of that on a project:
|
|
190
|
+
being added as a plain member grants nothing beyond logging time there
|
|
191
|
+
(CRO-85). Whole-timesheet review, team overviews and leave approval stay with
|
|
192
|
+
org-level managers and project managers.
|
|
189
193
|
|
|
190
194
|
#### Bulk Task Assignment
|
|
191
195
|
```bash
|
|
@@ -488,7 +492,7 @@ All mutating commands (`assign`, `unassign`, `set`) hit the atomic `bulk-set` en
|
|
|
488
492
|
|
|
489
493
|
**Visibility rule** — what a `manager` or `project_manager` sees of other people:
|
|
490
494
|
1. **Direct reports** (assigned via `ct team assign`) — everything: all their time entries on any project, their whole timesheets, utilization, compliance and anomalies.
|
|
491
|
-
2. **Projects they
|
|
495
|
+
2. **Projects they run** (set as `project_role = project_manager` on the project explicitly — whatever their org role, an org `member` included — or an org-level manager/admin who is a member of it; an org-level `project_manager` added as a plain member does **not** count) — every entry logged on those projects, by anyone, and that project's portion of each person's timesheet. **Not** the rest of those people's work: time a teammate logged on other projects or clients is hidden, and people-level reports (`ct report team`, `ct insights team|allocations`, `ct timesheet compliance`) leave them out.
|
|
492
496
|
3. **Time off** is the exception: a project manager still sees, creates and approves PTO for the people on their projects (see PTO).
|
|
493
497
|
|
|
494
498
|
A `manager` with **zero** direct reports and **zero** managed projects sees only their own entries. `admin` and `owner` always see everything. `member` sees only their own data. `viewer` sees entries on their assigned projects, but nobody else's timesheets or expenses. To give someone cross-project visibility of a person, make that person their direct report; for project-scoped visibility, make them PM of the project.
|
|
@@ -300,6 +300,8 @@ ct l -p crowdtime-dev -t "Design" 1:30 "wireframes"
|
|
|
300
300
|
| Days | `0.25d` | 2.0 hours (1d = 8h) |
|
|
301
301
|
| Decimal | `2.5` | 2.5 hours |
|
|
302
302
|
|
|
303
|
+
Any precision is accepted and rounded half-up to 2 decimals (`1:20` → 1.33h). `0` is valid and logs a zero-hour entry; negative durations are refused.
|
|
304
|
+
|
|
303
305
|
**Date formats:**
|
|
304
306
|
| Format | Example |
|
|
305
307
|
|--------|---------|
|
|
@@ -382,10 +384,13 @@ ct log list [options]
|
|
|
382
384
|
| `--project`, `-p` | string | Filter by project |
|
|
383
385
|
| `--task`, `-t` | string | Filter by task name or ID |
|
|
384
386
|
| `--billable/-B` | flag | Filter billable / non-billable entries |
|
|
385
|
-
| `--user`, `-u` | string | Filter by user ID (manager
|
|
387
|
+
| `--user`, `-u` | string | Filter by user ID. Only people whose time you can see: yourself, your direct reports (manager), anyone on projects you manage (explicit project manager) |
|
|
388
|
+
| `--mine` | flag | Only your own entries |
|
|
386
389
|
| `--format` | string | Output: `table`, `json`, `csv` |
|
|
387
390
|
| `--json` | flag | JSON output |
|
|
388
391
|
|
|
392
|
+
**Whose entries come back.** `member` and `viewer`-free lists are your own. A `manager` also gets their direct reports' entries; a project manager also gets everything logged on the projects where they are set as `project_manager` explicitly; `admin`/`owner` get everyone's. Every row carries `user` / `user_name` in JSON, the table adds a **User** column as soon as one row isn't yours, and CSV always has a `user` column (`id,date,user,project,task,description,duration,billable,hourly_rate,billable_amount`). Never assume a listed entry is the caller's — check `user`, or pass `--mine`.
|
|
393
|
+
|
|
389
394
|
CSV output includes financial columns: `hourly_rate`, `billable_amount`. They are empty for viewers, members and project managers (rates and amounts are manager+ only).
|
|
390
395
|
|
|
391
396
|
Endpoint: `GET /time/`
|
|
@@ -435,7 +440,7 @@ ct clients list [--archived/-a] [--json]
|
|
|
435
440
|
| `--archived`, `-a` | flag | Include archived clients |
|
|
436
441
|
| `--json` | flag | JSON output |
|
|
437
442
|
|
|
438
|
-
Shows: ID, Name, Contact, Projects count, Status.
|
|
443
|
+
Shows: ID, Name, Contact, Projects count, Status. The Contact column is the client's primary contact record (`primary_contact` in JSON: `{id, name, email}` or `null`); the `contact_name` field is legacy and no longer edited anywhere.
|
|
439
444
|
Endpoint: `GET /clients/`
|
|
440
445
|
|
|
441
446
|
### ct clients show
|
|
@@ -444,7 +449,7 @@ Endpoint: `GET /clients/`
|
|
|
444
449
|
ct clients show CLIENT_ID [--json]
|
|
445
450
|
```
|
|
446
451
|
|
|
447
|
-
Shows: ID, name, currency, contact
|
|
452
|
+
Shows: ID, name, currency, every contact (`contacts[]` in JSON, with `is_primary` / `receives_invoices`), address, status. Contacts are managed with `ct clients contacts ...`.
|
|
448
453
|
Endpoint: `GET /clients/{id}/`
|
|
449
454
|
|
|
450
455
|
### ct clients create
|
|
@@ -456,8 +461,8 @@ ct clients create NAME [options]
|
|
|
456
461
|
| Option | Type | Description |
|
|
457
462
|
|--------|------|-------------|
|
|
458
463
|
| `--currency` | string | Currency code (e.g. USD) |
|
|
459
|
-
| `--contact` | string |
|
|
460
|
-
| `--email` | string |
|
|
464
|
+
| `--contact` | string | Name of the primary contact — created as a contact record (`POST /clients/{id}/contacts/`) |
|
|
465
|
+
| `--email` | string | Email of that contact (needs `--contact`) |
|
|
461
466
|
| `--address-line1` | string | Street address |
|
|
462
467
|
| `--address-line2` | string | Suite, unit, etc. |
|
|
463
468
|
| `--city` | string | City |
|
|
@@ -478,8 +483,8 @@ ct clients edit CLIENT_ID [options]
|
|
|
478
483
|
|--------|------|-------------|
|
|
479
484
|
| `--name` | string | New client name |
|
|
480
485
|
| `--currency` | string | Currency code (e.g. EUR) |
|
|
481
|
-
| `--contact` | string |
|
|
482
|
-
| `--email` | string |
|
|
486
|
+
| `--contact` | string | Primary contact's name — updates the primary contact record, or creates it |
|
|
487
|
+
| `--email` | string | Primary contact's email (with `--contact` when the client has no contact yet) |
|
|
483
488
|
| `--address-line1` | string | Street address |
|
|
484
489
|
| `--city` | string | City |
|
|
485
490
|
| `--state` | string | State or province |
|
|
@@ -1115,7 +1120,7 @@ ct projects update-member PROJECT_ID USER_ID [options]
|
|
|
1115
1120
|
| `--rate` | float | Hourly rate override for this project member (manager+; ignored for other roles) |
|
|
1116
1121
|
| `--json` | flag | JSON output |
|
|
1117
1122
|
|
|
1118
|
-
Updates a member's project-specific role and/or rate. The `--role` sets a `project_role` that overrides the member's org-level role for this project only. Response includes `effective_role`, `org_role`, and `org_hourly_rate` for context.
|
|
1123
|
+
Updates a member's project-specific role and/or rate. The `--role` sets a `project_role` that overrides the member's org-level role for this project only. An org-level `project_manager` manages (and sees other people's time on) only the projects where this is set to `project_manager` explicitly — without it they are a plain member of the project. Response includes `effective_role`, `org_role`, and `org_hourly_rate` for context.
|
|
1119
1124
|
|
|
1120
1125
|
**Usage examples:**
|
|
1121
1126
|
```bash
|
|
@@ -1137,7 +1142,7 @@ ct projects assignable-users PROJECT_ID [--json]
|
|
|
1137
1142
|
|
|
1138
1143
|
Lists every **active organization member not already on the project**, with their user IDs and org roles. This is the correct source for the user IDs that `ct projects add-members` needs.
|
|
1139
1144
|
|
|
1140
|
-
It is deliberately not filtered by review visibility — `ct team list` and the timesheet endpoints answer "whose time can I review", which is a narrower set and the wrong question when staffing a project. Requires permission to manage the project (
|
|
1145
|
+
It is deliberately not filtered by review visibility — `ct team list` and the timesheet endpoints answer "whose time can I review", which is a narrower set and the wrong question when staffing a project. Requires permission to manage the project (an account manager or above, or a per-project `project_role` of `project_manager` — an org-level project manager needs that explicit role too). Endpoint: `GET /projects/<id>/assignable-users/`
|
|
1141
1146
|
|
|
1142
1147
|
### ct projects add-members
|
|
1143
1148
|
|
|
@@ -1557,7 +1562,7 @@ Switches active organization. Saves to config `defaults.organization`.
|
|
|
1557
1562
|
ct org members [--json]
|
|
1558
1563
|
```
|
|
1559
1564
|
|
|
1560
|
-
Shows: Name, Email, Role, Status. Requires manager+ role. Endpoint: `GET /members/`
|
|
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/`
|
|
1561
1566
|
|
|
1562
1567
|
### ct org invite
|
|
1563
1568
|
|
|
@@ -1589,7 +1594,7 @@ ct org update-member USER_ID [options]
|
|
|
1589
1594
|
| `USER_ID` | string (required) | User UUID |
|
|
1590
1595
|
| `--role`, `-r` | string | New org role: admin, manager, project_manager, member, viewer |
|
|
1591
1596
|
| `--rate` | string | Hourly rate |
|
|
1592
|
-
| `--capacity` | float | Weekly capacity hours |
|
|
1597
|
+
| `--capacity` | float | Weekly capacity hours (0–168, rounded to 1 decimal; 0 is allowed) |
|
|
1593
1598
|
| `--json` | flag | Output as JSON |
|
|
1594
1599
|
|
|
1595
1600
|
Updates an organization member's role, hourly rate, or weekly capacity (admin+). Nobody can grant a role above their own or edit someone who outranks them (`403`); the `owner` role cannot be assigned and the owner's role cannot be changed (`400`); only the owner can change the owner's membership at all. Deactivating a member this way also revokes their API tokens and pending invitations.
|
|
@@ -1715,16 +1720,17 @@ compact = false
|
|
|
1715
1720
|
### ct timesheet list
|
|
1716
1721
|
|
|
1717
1722
|
```
|
|
1718
|
-
ct timesheet list [--user USER_ID] [--status STATUS] [--json]
|
|
1723
|
+
ct timesheet list [--user USER_ID] [--mine] [--status STATUS] [--json]
|
|
1719
1724
|
```
|
|
1720
1725
|
|
|
1721
1726
|
| Option | Type | Description |
|
|
1722
1727
|
|--------|------|-------------|
|
|
1723
|
-
| `--user`, `-u` | string | User ID to view (manager
|
|
1728
|
+
| `--user`, `-u` | string | User ID to view. Only people whose timesheets you can see: yourself, your direct reports (manager), people on projects you manage (explicit project manager) |
|
|
1729
|
+
| `--mine` | flag | Only your own timesheets |
|
|
1724
1730
|
| `--status`, `-s` | string | Filter: `draft`, `submitted`, `approved`, `rejected` |
|
|
1725
1731
|
| `--json` | flag | JSON output |
|
|
1726
1732
|
|
|
1727
|
-
Lists your timesheets
|
|
1733
|
+
Lists your timesheets, plus — for managers and project managers — the timesheets of the people they manage. A project manager sees a teammate's timesheet trimmed to the projects they manage (`project_approvals`, `total_hours`, `entry_count` cover only those). Rows carry `user` / `user_name`; the table adds a **User** column as soon as one row isn't yours. Never assume a listed timesheet is the caller's — check `user`, or pass `--mine`. Endpoint: `GET /timesheets/`
|
|
1728
1734
|
|
|
1729
1735
|
### ct timesheet submit
|
|
1730
1736
|
|
|
@@ -2658,7 +2664,7 @@ Endpoint: `POST /manager-assignments/bulk-set/` with both `add` and `remove` com
|
|
|
2658
2664
|
A `manager` or `project_manager` sees time entries, timesheets, and team reports only for users in their **visible set**, which is the union of:
|
|
2659
2665
|
|
|
2660
2666
|
1. **Direct reports** — members in `ct team list --manager me`.
|
|
2661
|
-
2. **PM-project members** — users of projects where the caller holds `project_role = project_manager` (see `ct projects update-member`).
|
|
2667
|
+
2. **PM-project members** — users of projects where the caller holds `project_role = project_manager` (see `ct projects update-member`). This works for any org role — an org `member` promoted on one project sees its time and approves its portions (`ct timesheet approve --project`) — and being on a project without that explicit role grants nothing, whatever the org role (CRO-85).
|
|
2662
2668
|
|
|
2663
2669
|
A `manager` with **zero** assignments and **zero** PM projects sees only their own entries. `admin` and `owner` always see everything.
|
|
2664
2670
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{crowdtime_cli-0.18.0 → crowdtime_cli-0.19.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|