crowdtime-cli 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- crowdtime_cli/auth.py +69 -0
- crowdtime_cli/client.py +177 -0
- crowdtime_cli/commands/__init__.py +1 -0
- crowdtime_cli/commands/ai_cmd.py +211 -0
- crowdtime_cli/commands/auth_cmd.py +160 -0
- crowdtime_cli/commands/clients_cmd.py +150 -0
- crowdtime_cli/commands/config_cmd.py +91 -0
- crowdtime_cli/commands/favorites_cmd.py +128 -0
- crowdtime_cli/commands/log_cmd.py +298 -0
- crowdtime_cli/commands/org_cmd.py +134 -0
- crowdtime_cli/commands/projects_cmd.py +175 -0
- crowdtime_cli/commands/report_cmd.py +242 -0
- crowdtime_cli/commands/skill_cmd.py +266 -0
- crowdtime_cli/commands/tasks_cmd.py +101 -0
- crowdtime_cli/commands/timer_cmd.py +207 -0
- crowdtime_cli/config.py +125 -0
- crowdtime_cli/formatters.py +395 -0
- crowdtime_cli/main.py +334 -0
- crowdtime_cli/models.py +146 -0
- crowdtime_cli/oauth.py +107 -0
- crowdtime_cli/resolvers.py +80 -0
- crowdtime_cli/skills/crowdtime/SKILL.md +193 -0
- crowdtime_cli/skills/crowdtime/references/commands.md +659 -0
- crowdtime_cli/skills/crowdtime/references/workflows.md +286 -0
- crowdtime_cli/utils.py +166 -0
- crowdtime_cli-0.1.0.dist-info/METADATA +140 -0
- crowdtime_cli-0.1.0.dist-info/RECORD +31 -0
- crowdtime_cli-0.1.0.dist-info/WHEEL +4 -0
- crowdtime_cli-0.1.0.dist-info/entry_points.txt +3 -0
- crowdtime_cli-0.1.0.dist-info/licenses/LICENSE +77 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Organization commands: list, switch, members, invite."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from ..client import APIError, CrowdTimeClient
|
|
12
|
+
from ..config import get_config
|
|
13
|
+
from ..formatters import format_error, format_members_table, format_success, print_json
|
|
14
|
+
from ..models import Organization
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(name="org", help="Manage organizations.")
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command("list")
|
|
21
|
+
def list_orgs(
|
|
22
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
23
|
+
) -> None:
|
|
24
|
+
"""List organizations you belong to."""
|
|
25
|
+
client = CrowdTimeClient(require_auth=True, require_org=False)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
data = client.get("/api/v1/organizations/", org_scoped=False)
|
|
29
|
+
orgs_list = data if isinstance(data, list) else data.get("results", [])
|
|
30
|
+
orgs = [Organization(**item) for item in orgs_list]
|
|
31
|
+
|
|
32
|
+
if output_json:
|
|
33
|
+
print_json(orgs)
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
if not orgs:
|
|
37
|
+
console.print("[dim]No organizations found.[/dim]")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
current_slug = client.config.organization
|
|
41
|
+
|
|
42
|
+
table = Table(show_header=True, header_style="bold")
|
|
43
|
+
table.add_column("", width=3)
|
|
44
|
+
table.add_column("Name", width=20)
|
|
45
|
+
table.add_column("Slug", width=15)
|
|
46
|
+
table.add_column("Role", width=10)
|
|
47
|
+
table.add_column("Members", justify="right", width=8)
|
|
48
|
+
|
|
49
|
+
for org in orgs:
|
|
50
|
+
marker = "[green]*[/green]" if org.slug == current_slug else " "
|
|
51
|
+
table.add_row(
|
|
52
|
+
marker,
|
|
53
|
+
org.name,
|
|
54
|
+
org.slug,
|
|
55
|
+
org.role or "",
|
|
56
|
+
str(org.member_count or ""),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
console.print(table)
|
|
60
|
+
if current_slug:
|
|
61
|
+
console.print(f"\n[dim]* = current organization ({current_slug})[/dim]")
|
|
62
|
+
except APIError as e:
|
|
63
|
+
format_error(e.message)
|
|
64
|
+
raise typer.Exit(1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command("switch")
|
|
68
|
+
def switch_org(
|
|
69
|
+
slug: str = typer.Argument(..., help="Organization slug to switch to."),
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Switch the active organization."""
|
|
72
|
+
# Verify the org exists
|
|
73
|
+
client = CrowdTimeClient(require_auth=True, require_org=False)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
data = client.get("/api/v1/organizations/", org_scoped=False)
|
|
77
|
+
orgs_list = data if isinstance(data, list) else data.get("results", [])
|
|
78
|
+
slugs = [o.get("slug", "") for o in orgs_list]
|
|
79
|
+
|
|
80
|
+
if slug not in slugs:
|
|
81
|
+
format_error(f"Organization '{slug}' not found. Available: {', '.join(slugs)}")
|
|
82
|
+
raise typer.Exit(1)
|
|
83
|
+
|
|
84
|
+
except APIError as e:
|
|
85
|
+
format_error(e.message)
|
|
86
|
+
raise typer.Exit(1)
|
|
87
|
+
|
|
88
|
+
config = get_config()
|
|
89
|
+
config.set("defaults.organization", slug)
|
|
90
|
+
format_success(f"Switched to organization '{slug}'")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command()
|
|
94
|
+
def members(
|
|
95
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
96
|
+
) -> None:
|
|
97
|
+
"""List members of the current organization."""
|
|
98
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
data = client.get("/members/")
|
|
102
|
+
members_list = data if isinstance(data, list) else data.get("results", [])
|
|
103
|
+
|
|
104
|
+
if output_json:
|
|
105
|
+
print_json(members_list)
|
|
106
|
+
else:
|
|
107
|
+
format_members_table(members_list)
|
|
108
|
+
except APIError as e:
|
|
109
|
+
format_error(e.message)
|
|
110
|
+
raise typer.Exit(1)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.command()
|
|
114
|
+
def invite(
|
|
115
|
+
email: str = typer.Argument(..., help="Email address to invite."),
|
|
116
|
+
role: str = typer.Option("member", "--role", "-r", help="Role: admin, manager, member."),
|
|
117
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Invite a member to the current organization."""
|
|
120
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
data = client.post("/members/invite/", data={
|
|
124
|
+
"email": email,
|
|
125
|
+
"role": role,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
if output_json:
|
|
129
|
+
print_json(data)
|
|
130
|
+
else:
|
|
131
|
+
format_success(f"Invitation sent to {email} (role: {role})")
|
|
132
|
+
except APIError as e:
|
|
133
|
+
format_error(e.message)
|
|
134
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Project commands: list, show, create, archive, switch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from ..client import APIError, CrowdTimeClient
|
|
11
|
+
from ..config import get_config
|
|
12
|
+
from ..formatters import format_error, format_projects_table, format_success, print_json
|
|
13
|
+
from ..models import Project
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(name="projects", help="Manage projects.")
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _resolve_or_create_client(api_client: CrowdTimeClient, client_name: str) -> str:
|
|
20
|
+
"""Look up a client by name; create one if it doesn't exist. Returns the client UUID."""
|
|
21
|
+
# Search for existing clients matching the name
|
|
22
|
+
data = api_client.get("/clients/", params={"search": client_name})
|
|
23
|
+
clients_list = data if isinstance(data, list) else data.get("results", [])
|
|
24
|
+
|
|
25
|
+
# Exact match (case-insensitive)
|
|
26
|
+
for c in clients_list:
|
|
27
|
+
if c.get("name", "").lower() == client_name.lower():
|
|
28
|
+
return c["id"]
|
|
29
|
+
|
|
30
|
+
# No match — auto-create the client
|
|
31
|
+
console.print(f"[dim]Client '{client_name}' not found — creating it...[/dim]")
|
|
32
|
+
new_client = api_client.post("/clients/", data={"name": client_name})
|
|
33
|
+
return new_client["id"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command("list")
|
|
37
|
+
def list_projects(
|
|
38
|
+
archived: bool = typer.Option(False, "--archived", "-a", help="Include archived projects."),
|
|
39
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
40
|
+
) -> None:
|
|
41
|
+
"""List all projects in the current organization."""
|
|
42
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
43
|
+
|
|
44
|
+
params: dict = {}
|
|
45
|
+
if archived:
|
|
46
|
+
params["status"] = "archived"
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
data = client.get("/projects/", params=params)
|
|
50
|
+
projects_list = data if isinstance(data, list) else data.get("results", [])
|
|
51
|
+
projects = [Project(**item) for item in projects_list]
|
|
52
|
+
|
|
53
|
+
if output_json:
|
|
54
|
+
print_json(projects)
|
|
55
|
+
else:
|
|
56
|
+
# Show which is default
|
|
57
|
+
default_proj = client.config.default_project
|
|
58
|
+
format_projects_table(projects)
|
|
59
|
+
if default_proj:
|
|
60
|
+
console.print(f"\n[dim]Default project: {default_proj}[/dim]")
|
|
61
|
+
except APIError as e:
|
|
62
|
+
format_error(e.message)
|
|
63
|
+
raise typer.Exit(1)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command()
|
|
67
|
+
def show(
|
|
68
|
+
project_id: str = typer.Argument(..., help="Project ID or slug."),
|
|
69
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Show details for a specific project."""
|
|
72
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
data = client.get(f"/projects/{project_id}/")
|
|
76
|
+
project = Project(**data)
|
|
77
|
+
|
|
78
|
+
if output_json:
|
|
79
|
+
print_json(project)
|
|
80
|
+
else:
|
|
81
|
+
console.print(f"\n[bold]{project.name}[/bold]")
|
|
82
|
+
console.print(f" ID: {project.id}")
|
|
83
|
+
console.print(f" Code: {project.code}")
|
|
84
|
+
console.print(f" Client: {project.client or '-'}")
|
|
85
|
+
console.print(f" Status: {project.status}")
|
|
86
|
+
console.print(f" Billable: {'Yes' if project.is_billable else 'No'}")
|
|
87
|
+
if project.budget_hours:
|
|
88
|
+
console.print(f" Budget: {project.budget_hours}h")
|
|
89
|
+
if project.description:
|
|
90
|
+
console.print(f" Notes: {project.description}")
|
|
91
|
+
console.print()
|
|
92
|
+
except APIError as e:
|
|
93
|
+
if e.status_code == 404:
|
|
94
|
+
format_error(f"Project '{project_id}' not found.")
|
|
95
|
+
else:
|
|
96
|
+
format_error(e.message)
|
|
97
|
+
raise typer.Exit(1)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command()
|
|
101
|
+
def create(
|
|
102
|
+
name: str = typer.Argument(..., help="Project name."),
|
|
103
|
+
client_name: Optional[str] = typer.Option(None, "--client", "-c", help="Client name."),
|
|
104
|
+
billable: bool = typer.Option(True, "--billable/--no-billable", "-b/-B",
|
|
105
|
+
help="Mark as billable."),
|
|
106
|
+
color: Optional[str] = typer.Option(None, "--color", help="Project color (hex)."),
|
|
107
|
+
budget: Optional[float] = typer.Option(None, "--budget", help="Budget in hours."),
|
|
108
|
+
code: Optional[str] = typer.Option(None, "--code", help="Short project code."),
|
|
109
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Create a new project."""
|
|
112
|
+
api_client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
113
|
+
|
|
114
|
+
payload: dict = {"name": name, "is_billable": billable}
|
|
115
|
+
if color:
|
|
116
|
+
payload["color"] = color
|
|
117
|
+
if budget:
|
|
118
|
+
payload["budget_hours"] = budget
|
|
119
|
+
if code:
|
|
120
|
+
payload["code"] = code
|
|
121
|
+
|
|
122
|
+
# Resolve client name to UUID (or auto-create)
|
|
123
|
+
if client_name:
|
|
124
|
+
try:
|
|
125
|
+
client_id = _resolve_or_create_client(api_client, client_name)
|
|
126
|
+
payload["client"] = str(client_id)
|
|
127
|
+
except APIError as e:
|
|
128
|
+
format_error(f"Failed to resolve client '{client_name}': {e.message}")
|
|
129
|
+
raise typer.Exit(1)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
data = api_client.post("/projects/", data=payload)
|
|
133
|
+
project = Project(**data)
|
|
134
|
+
|
|
135
|
+
if output_json:
|
|
136
|
+
print_json(project)
|
|
137
|
+
else:
|
|
138
|
+
format_success(f"Project '{project.name}' created (ID: {project.id})")
|
|
139
|
+
except APIError as e:
|
|
140
|
+
format_error(e.message)
|
|
141
|
+
raise typer.Exit(1)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@app.command()
|
|
145
|
+
def archive(
|
|
146
|
+
project_id: str = typer.Argument(..., help="Project ID or slug to archive."),
|
|
147
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Archive a project."""
|
|
150
|
+
if not force:
|
|
151
|
+
if not typer.confirm(f"Archive project '{project_id}'?"):
|
|
152
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
data = client.patch(f"/projects/{project_id}/", data={"status": "archived"})
|
|
159
|
+
format_success(f"Project '{project_id}' archived.")
|
|
160
|
+
except APIError as e:
|
|
161
|
+
if e.status_code == 404:
|
|
162
|
+
format_error(f"Project '{project_id}' not found.")
|
|
163
|
+
else:
|
|
164
|
+
format_error(e.message)
|
|
165
|
+
raise typer.Exit(1)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.command("switch")
|
|
169
|
+
def switch_project(
|
|
170
|
+
slug: str = typer.Argument(..., help="Project slug or name to set as default."),
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Set a project as the default for new entries."""
|
|
173
|
+
config = get_config()
|
|
174
|
+
config.set("defaults.project", slug)
|
|
175
|
+
format_success(f"Default project set to '{slug}'")
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Report commands: report, projects, team."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date as date_type, timedelta
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from ..client import APIError, CrowdTimeClient
|
|
12
|
+
from ..formatters import format_error, format_report, print_json
|
|
13
|
+
from ..utils import format_date, parse_date
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(name="report", help="Generate time reports.")
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _resolve_period(
|
|
20
|
+
today_flag: bool = False,
|
|
21
|
+
yesterday_flag: bool = False,
|
|
22
|
+
week: bool = False,
|
|
23
|
+
last_week: bool = False,
|
|
24
|
+
month: bool = False,
|
|
25
|
+
from_date: str | None = None,
|
|
26
|
+
to_date: str | None = None,
|
|
27
|
+
) -> tuple[str, str]:
|
|
28
|
+
"""Resolve date range from flags."""
|
|
29
|
+
today = date_type.today()
|
|
30
|
+
|
|
31
|
+
if today_flag:
|
|
32
|
+
d = format_date(today)
|
|
33
|
+
return d, d
|
|
34
|
+
if yesterday_flag:
|
|
35
|
+
d = format_date(today - timedelta(days=1))
|
|
36
|
+
return d, d
|
|
37
|
+
if week:
|
|
38
|
+
start = today - timedelta(days=today.weekday())
|
|
39
|
+
return format_date(start), format_date(today)
|
|
40
|
+
if last_week:
|
|
41
|
+
start = today - timedelta(days=today.weekday() + 7)
|
|
42
|
+
end = start + timedelta(days=6)
|
|
43
|
+
return format_date(start), format_date(end)
|
|
44
|
+
if month:
|
|
45
|
+
start = today.replace(day=1)
|
|
46
|
+
return format_date(start), format_date(today)
|
|
47
|
+
if from_date and to_date:
|
|
48
|
+
try:
|
|
49
|
+
return format_date(parse_date(from_date)), format_date(parse_date(to_date))
|
|
50
|
+
except ValueError as e:
|
|
51
|
+
format_error(str(e))
|
|
52
|
+
raise typer.Exit(1)
|
|
53
|
+
if from_date:
|
|
54
|
+
try:
|
|
55
|
+
return format_date(parse_date(from_date)), format_date(today)
|
|
56
|
+
except ValueError as e:
|
|
57
|
+
format_error(str(e))
|
|
58
|
+
raise typer.Exit(1)
|
|
59
|
+
|
|
60
|
+
# Default: this week
|
|
61
|
+
start = today - timedelta(days=today.weekday())
|
|
62
|
+
return format_date(start), format_date(today)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.callback(invoke_without_command=True)
|
|
66
|
+
def report(
|
|
67
|
+
ctx: typer.Context,
|
|
68
|
+
today_flag: bool = typer.Option(False, "--today", help="Report for today."),
|
|
69
|
+
yesterday_flag: bool = typer.Option(False, "--yesterday", help="Report for yesterday."),
|
|
70
|
+
week: bool = typer.Option(False, "--week", "-w", help="Report for this week."),
|
|
71
|
+
last_week: bool = typer.Option(False, "--last-week", help="Report for last week."),
|
|
72
|
+
month: bool = typer.Option(False, "--month", "-m", help="Report for this month."),
|
|
73
|
+
from_date: Optional[str] = typer.Option(None, "--from", help="Start date."),
|
|
74
|
+
to_date: Optional[str] = typer.Option(None, "--to", help="End date."),
|
|
75
|
+
project: Optional[str] = typer.Option(None, "--project", "-p", help="Filter by project."),
|
|
76
|
+
group_by: str = typer.Option("project", "--group-by", "-g",
|
|
77
|
+
help="Group by: project, task, day, client."),
|
|
78
|
+
format_output: str = typer.Option("table", "--format", "-f",
|
|
79
|
+
help="Output format: table, json, csv, markdown."),
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Generate a time report.
|
|
82
|
+
|
|
83
|
+
Examples:
|
|
84
|
+
ct report --week
|
|
85
|
+
ct report --month --group-by task
|
|
86
|
+
ct report --from 2026-03-01 --to 2026-03-10 -p myproject
|
|
87
|
+
"""
|
|
88
|
+
if ctx.invoked_subcommand is not None:
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
period_from, period_to = _resolve_period(
|
|
92
|
+
today_flag, yesterday_flag, week, last_week, month, from_date, to_date
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
96
|
+
|
|
97
|
+
params: dict = {
|
|
98
|
+
"from": period_from,
|
|
99
|
+
"to": period_to,
|
|
100
|
+
"group_by": group_by,
|
|
101
|
+
}
|
|
102
|
+
if project:
|
|
103
|
+
params["project"] = project
|
|
104
|
+
|
|
105
|
+
endpoint = "/reports/project-summary/" if group_by == "project" else "/reports/time-detail/"
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
data = client.get(endpoint, params=params)
|
|
109
|
+
results = data if isinstance(data, list) else data.get("results", [])
|
|
110
|
+
|
|
111
|
+
if format_output == "json":
|
|
112
|
+
print_json(results)
|
|
113
|
+
elif format_output == "csv":
|
|
114
|
+
_print_report_csv(results, group_by)
|
|
115
|
+
elif format_output == "markdown":
|
|
116
|
+
_print_report_markdown(results, group_by, period_from, period_to)
|
|
117
|
+
else:
|
|
118
|
+
console.print(f"\n[dim]Period: {period_from} to {period_to}[/dim]")
|
|
119
|
+
format_report(results, group_by=group_by)
|
|
120
|
+
except APIError as e:
|
|
121
|
+
format_error(e.message)
|
|
122
|
+
raise typer.Exit(1)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.command("projects")
|
|
126
|
+
def project_report(
|
|
127
|
+
week: bool = typer.Option(False, "--week", "-w", help="This week."),
|
|
128
|
+
month: bool = typer.Option(True, "--month", "-m", help="This month (default)."),
|
|
129
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Project breakdown report."""
|
|
132
|
+
period_from, period_to = _resolve_period(week=week, month=month)
|
|
133
|
+
|
|
134
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
data = client.get("/reports/project-summary/", params={
|
|
138
|
+
"from": period_from,
|
|
139
|
+
"to": period_to,
|
|
140
|
+
})
|
|
141
|
+
results = data if isinstance(data, list) else data.get("results", [])
|
|
142
|
+
|
|
143
|
+
if output_json:
|
|
144
|
+
print_json(results)
|
|
145
|
+
else:
|
|
146
|
+
console.print(f"\n[dim]Period: {period_from} to {period_to}[/dim]")
|
|
147
|
+
format_report(results, group_by="project")
|
|
148
|
+
except APIError as e:
|
|
149
|
+
format_error(e.message)
|
|
150
|
+
raise typer.Exit(1)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@app.command("team")
|
|
154
|
+
def team_report(
|
|
155
|
+
week: bool = typer.Option(False, "--week", "-w", help="This week."),
|
|
156
|
+
month: bool = typer.Option(True, "--month", "-m", help="This month (default)."),
|
|
157
|
+
member: Optional[str] = typer.Option(None, "--member", help="Filter by team member."),
|
|
158
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Team utilization report."""
|
|
161
|
+
period_from, period_to = _resolve_period(week=week, month=month)
|
|
162
|
+
|
|
163
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
164
|
+
|
|
165
|
+
params: dict = {
|
|
166
|
+
"from": period_from,
|
|
167
|
+
"to": period_to,
|
|
168
|
+
}
|
|
169
|
+
if member:
|
|
170
|
+
params["member"] = member
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
data = client.get("/reports/team-utilization/", params=params)
|
|
174
|
+
results = data if isinstance(data, list) else data.get("results", [])
|
|
175
|
+
|
|
176
|
+
if output_json:
|
|
177
|
+
print_json(results)
|
|
178
|
+
else:
|
|
179
|
+
console.print(f"\n[dim]Team Report: {period_from} to {period_to}[/dim]")
|
|
180
|
+
_print_team_table(results)
|
|
181
|
+
except APIError as e:
|
|
182
|
+
format_error(e.message)
|
|
183
|
+
raise typer.Exit(1)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _print_team_table(data: list[dict]) -> None:
|
|
187
|
+
"""Print team utilization table."""
|
|
188
|
+
from rich.table import Table
|
|
189
|
+
from ..utils import format_duration
|
|
190
|
+
from decimal import Decimal
|
|
191
|
+
|
|
192
|
+
if not data:
|
|
193
|
+
console.print("[dim]No data.[/dim]")
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
table = Table(show_header=True, header_style="bold", title="Team Utilization")
|
|
197
|
+
table.add_column("Member", width=20)
|
|
198
|
+
table.add_column("Hours", justify="right", width=10)
|
|
199
|
+
table.add_column("Billable", justify="right", width=10)
|
|
200
|
+
table.add_column("Utilization", justify="right", width=12)
|
|
201
|
+
|
|
202
|
+
for row in data:
|
|
203
|
+
hours = Decimal(str(row.get("hours", 0)))
|
|
204
|
+
billable = Decimal(str(row.get("billable_hours", 0)))
|
|
205
|
+
utilization = row.get("utilization", 0)
|
|
206
|
+
table.add_row(
|
|
207
|
+
row.get("member", ""),
|
|
208
|
+
format_duration(hours),
|
|
209
|
+
format_duration(billable),
|
|
210
|
+
f"{utilization}%",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
console.print(table)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _print_report_csv(data: list[dict], group_by: str) -> None:
|
|
217
|
+
"""Print report as CSV."""
|
|
218
|
+
console.print(f"{group_by},hours,billable_hours,entries_count")
|
|
219
|
+
for row in data:
|
|
220
|
+
console.print(
|
|
221
|
+
f'{row.get(group_by, row.get("project", ""))},'
|
|
222
|
+
f'{row.get("hours", 0)},'
|
|
223
|
+
f'{row.get("billable_hours", 0)},'
|
|
224
|
+
f'{row.get("entries_count", 0)}'
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _print_report_markdown(data: list[dict], group_by: str, from_d: str, to_d: str) -> None:
|
|
229
|
+
"""Print report as Markdown."""
|
|
230
|
+
from ..utils import format_duration
|
|
231
|
+
from decimal import Decimal
|
|
232
|
+
|
|
233
|
+
console.print(f"## Time Report ({from_d} to {to_d})\n")
|
|
234
|
+
console.print(f"| {group_by.capitalize()} | Hours | Billable | Entries |")
|
|
235
|
+
console.print("|---|---|---|---|")
|
|
236
|
+
for row in data:
|
|
237
|
+
hours = format_duration(Decimal(str(row.get("hours", 0))))
|
|
238
|
+
billable = format_duration(Decimal(str(row.get("billable_hours", 0))))
|
|
239
|
+
console.print(
|
|
240
|
+
f"| {row.get(group_by, row.get('project', ''))} "
|
|
241
|
+
f"| {hours} | {billable} | {row.get('entries_count', 0)} |"
|
|
242
|
+
)
|