diff options
author | T <t@tjp.lol> | 2025-08-04 15:34:23 -0600 |
---|---|---|
committer | T <t@tjp.lol> | 2025-08-04 19:49:08 -0600 |
commit | dc895cec9d8a84af89ce2501db234dff33c757e2 (patch) | |
tree | 8c961466f0769616b3a82da91f4cde4d3a881b73 /internal/commands | |
parent | 56e0af3b41742876b471332aeb943a5a2ca8dfbf (diff) |
timesheet and unified reports
Diffstat (limited to 'internal/commands')
-rw-r--r-- | internal/commands/import.go | 4 | ||||
-rw-r--r-- | internal/commands/report.go | 423 | ||||
-rw-r--r-- | internal/commands/root.go | 2 |
3 files changed, 422 insertions, 7 deletions
diff --git a/internal/commands/import.go b/internal/commands/import.go index a1d0d8f..acbb0d6 100644 --- a/internal/commands/import.go +++ b/internal/commands/import.go @@ -65,7 +65,7 @@ For Clockify exports: cmd.Flags().StringP("timezone", "t", "Local", "Timezone of the CSV data (e.g., 'America/New_York', 'UTC', or 'Local')") cmd.Flags().StringP("source", "s", "", "Source format of the import file (supported: clockify)") - cmd.MarkFlagRequired("source") + _ = cmd.MarkFlagRequired("source") return cmd } @@ -85,7 +85,7 @@ func importClockifyCSV(queries *queries.Queries, filepath, timezone string) erro if err != nil { return fmt.Errorf("failed to open file: %w", err) } - defer file.Close() + defer func() { _ = file.Close() }() reader := csv.NewReader(file) records, err := reader.ReadAll() diff --git a/internal/commands/report.go b/internal/commands/report.go index 25e0483..0beb6ae 100644 --- a/internal/commands/report.go +++ b/internal/commands/report.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "path/filepath" + "time" punchctx "punchcard/internal/context" "punchcard/internal/database" @@ -23,6 +24,7 @@ func NewReportCmd() *cobra.Command { cmd.AddCommand(NewReportInvoiceCmd()) cmd.AddCommand(NewReportTimesheetCmd()) + cmd.AddCommand(NewReportUnifiedCmd()) return cmd } @@ -231,13 +233,426 @@ func generateProjectInvoice(q *queries.Queries, projectName string, dateRange re } func NewReportTimesheetCmd() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "timesheet", Short: "Generate a PDF timesheet", - Long: "Generate a PDF timesheet report from tracked time.", + Long: `Generate a PDF timesheet report from tracked time. Either --client or --project must be specified. + +Examples: + # Generate timesheet for last month (default) + punch report timesheet -c "Acme Corp" + + # Generate timesheet for last week + punch report timesheet -c "Acme Corp" -d "last week" + + # Generate timesheet for custom date range + punch report timesheet -c "Acme Corp" -d "2025-06-01 to 2025-06-30" + + # Generate timesheet for specific project + punch report timesheet -p "Website Redesign" -d "2025-01-01 to 2025-01-31"`, RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Timesheet generation (placeholder)") - return nil + return runTimesheetCommand(cmd, args) }, } + + cmd.Flags().StringP("client", "c", "", "Generate timesheet for specific client") + cmd.Flags().StringP("project", "p", "", "Generate timesheet for specific project") + cmd.Flags().StringP("dates", "d", "last month", "Date range ('last week', 'last month', or 'YYYY-MM-DD to YYYY-MM-DD')") + cmd.Flags().StringP("output", "o", "", "Output file path (default: auto-generated filename)") + cmd.Flags().StringP("timezone", "t", "Local", "Timezone for displaying times (e.g., 'America/New_York', 'UTC', or 'Local')") + + return cmd +} + +func runTimesheetCommand(cmd *cobra.Command, args []string) error { + // Get flag values + clientName, _ := cmd.Flags().GetString("client") + projectName, _ := cmd.Flags().GetString("project") + dateStr, _ := cmd.Flags().GetString("dates") + outputPath, _ := cmd.Flags().GetString("output") + timezone, _ := cmd.Flags().GetString("timezone") + + // Validate flags + if clientName == "" && projectName == "" { + return fmt.Errorf("either --client or --project must be specified") + } + if clientName != "" && projectName != "" { + return fmt.Errorf("--client and --project are mutually exclusive") + } + + // Parse date range + dateRange, err := reports.ParseDateRange(dateStr) + if err != nil { + return fmt.Errorf("invalid date range: %w", err) + } + + // Parse timezone + var loc *time.Location + if timezone == "Local" { + loc = time.Local + } else { + loc, err = time.LoadLocation(timezone) + if err != nil { + return fmt.Errorf("invalid timezone '%s': %w", timezone, err) + } + } + + // Get database connection + q := punchctx.GetDB(cmd.Context()) + if q == nil { + var err error + q, err = database.GetDB() + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + } + + // Generate timesheet based on client or project + var timesheetData *reports.TimesheetData + if clientName != "" { + timesheetData, err = generateClientTimesheet(q, clientName, dateRange, loc) + } else { + timesheetData, err = generateProjectTimesheet(q, projectName, dateRange, loc) + } + if err != nil { + return err + } + + // Generate output filename if not specified + if outputPath == "" { + outputPath = reports.GenerateDefaultTimesheetFilename(timesheetData.ClientName, timesheetData.ProjectName, dateRange) + } + + // Convert to absolute path + outputPath, err = filepath.Abs(outputPath) + if err != nil { + return fmt.Errorf("failed to resolve output path: %w", err) + } + + // Generate PDF + err = reports.GenerateTimesheetPDF(timesheetData, outputPath) + if err != nil { + return fmt.Errorf("failed to generate timesheet PDF: %w", err) + } + + fmt.Printf("Timesheet generated successfully: %s\n", outputPath) + fmt.Printf("Total hours: %.2f\n", timesheetData.TotalHours) + fmt.Printf("Total entries: %d\n", len(timesheetData.Entries)) + + return nil +} + +func generateClientTimesheet(q *queries.Queries, clientName string, dateRange reports.DateRange, loc *time.Location) (*reports.TimesheetData, error) { + // Find client + client, err := findClient(context.Background(), q, clientName) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("client not found: %s", clientName) + } + return nil, fmt.Errorf("failed to find client: %w", err) + } + + // Get contractor data + contractor, err := q.GetContractor(context.Background()) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("no contractor information found - please add contractor details first") + } + return nil, fmt.Errorf("failed to get contractor information: %w", err) + } + + // Get timesheet data + entries, err := q.GetTimesheetDataByClient(context.Background(), queries.GetTimesheetDataByClientParams{ + ClientID: client.ID, + StartTime: dateRange.Start, + EndTime: dateRange.End, + }) + if err != nil { + return nil, fmt.Errorf("failed to get timesheet data: %w", err) + } + + if len(entries) == 0 { + return nil, fmt.Errorf("no completed time entries found for client %s in the specified date range", client.Name) + } + + return reports.GenerateTimesheetData(entries, client.ID, client.Name, "", contractor, dateRange, loc) +} + +func generateProjectTimesheet(q *queries.Queries, projectName string, dateRange reports.DateRange, loc *time.Location) (*reports.TimesheetData, error) { + // Find project + project, err := findProject(context.Background(), q, projectName) + if err != nil { + return nil, fmt.Errorf("failed to find project: %w", err) + } + + // Get client info + clients, err := q.FindClient(context.Background(), queries.FindClientParams{ + ID: project.ClientID, + Name: "", + }) + if err != nil || len(clients) == 0 { + return nil, fmt.Errorf("failed to find client for project") + } + client := clients[0] + + // Get contractor data + contractor, err := q.GetContractor(context.Background()) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("no contractor information found - please add contractor details first") + } + return nil, fmt.Errorf("failed to get contractor information: %w", err) + } + + // Get timesheet data + entries, err := q.GetTimesheetDataByProject(context.Background(), queries.GetTimesheetDataByProjectParams{ + ProjectID: project.ID, + StartTime: dateRange.Start, + EndTime: dateRange.End, + }) + if err != nil { + return nil, fmt.Errorf("failed to get timesheet data: %w", err) + } + + if len(entries) == 0 { + return nil, fmt.Errorf("no completed time entries found for project %s in the specified date range", projectName) + } + + return reports.GenerateTimesheetData(entries, client.ID, client.Name, projectName, contractor, dateRange, loc) +} + +func NewReportUnifiedCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "unified", + Short: "Generate a unified PDF report (invoice + timesheet)", + Long: `Generate a unified PDF report combining invoice and timesheet on separate pages. Either --client or --project must be specified. + +Examples: + # Generate unified report for last month (default) + punch report unified -c "Acme Corp" + + # Generate unified report for last week + punch report unified -c "Acme Corp" -d "last week" + + # Generate unified report for custom date range + punch report unified -c "Acme Corp" -d "2025-06-01 to 2025-06-30" + + # Generate unified report for specific project + punch report unified -p "Website Redesign" -d "2025-01-01 to 2025-01-31"`, + RunE: func(cmd *cobra.Command, args []string) error { + return runUnifiedCommand(cmd, args) + }, + } + + cmd.Flags().StringP("client", "c", "", "Generate unified report for specific client") + cmd.Flags().StringP("project", "p", "", "Generate unified report for specific project") + cmd.Flags().StringP("dates", "d", "last month", "Date range ('last week', 'last month', or 'YYYY-MM-DD to YYYY-MM-DD')") + cmd.Flags().StringP("output", "o", "", "Output file path (default: auto-generated filename)") + cmd.Flags().StringP("timezone", "t", "Local", "Timezone for displaying times (e.g., 'America/New_York', 'UTC', or 'Local')") + + return cmd +} + +func runUnifiedCommand(cmd *cobra.Command, args []string) error { + // Get flag values + clientName, _ := cmd.Flags().GetString("client") + projectName, _ := cmd.Flags().GetString("project") + dateStr, _ := cmd.Flags().GetString("dates") + outputPath, _ := cmd.Flags().GetString("output") + timezone, _ := cmd.Flags().GetString("timezone") + + // Validate flags + if clientName == "" && projectName == "" { + return fmt.Errorf("either --client or --project must be specified") + } + if clientName != "" && projectName != "" { + return fmt.Errorf("--client and --project are mutually exclusive") + } + + // Parse date range + dateRange, err := reports.ParseDateRange(dateStr) + if err != nil { + return fmt.Errorf("invalid date range: %w", err) + } + + // Parse timezone + var loc *time.Location + if timezone == "Local" { + loc = time.Local + } else { + loc, err = time.LoadLocation(timezone) + if err != nil { + return fmt.Errorf("invalid timezone '%s': %w", timezone, err) + } + } + + // Get database connection + q := punchctx.GetDB(cmd.Context()) + if q == nil { + var err error + q, err = database.GetDB() + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + } + + // Generate unified report based on client or project + var unifiedData *reports.UnifiedReportData + if clientName != "" { + unifiedData, err = generateClientUnified(q, clientName, dateRange, loc) + } else { + unifiedData, err = generateProjectUnified(q, projectName, dateRange, loc) + } + if err != nil { + return err + } + + // Generate output filename if not specified + if outputPath == "" { + outputPath = reports.GenerateDefaultUnifiedFilename(unifiedData.InvoiceData.ClientName, unifiedData.InvoiceData.ProjectName, dateRange) + } + + // Convert to absolute path + outputPath, err = filepath.Abs(outputPath) + if err != nil { + return fmt.Errorf("failed to resolve output path: %w", err) + } + + // Generate PDF + err = reports.GenerateUnifiedPDF(unifiedData, outputPath) + if err != nil { + return fmt.Errorf("failed to generate unified PDF: %w", err) + } + + // Record invoice in database + if _, err := q.CreateInvoice(cmd.Context(), queries.CreateInvoiceParams{ + Year: int64(unifiedData.InvoiceData.DateRange.Start.Year()), + Month: int64(unifiedData.InvoiceData.DateRange.Start.Month()), + Number: unifiedData.InvoiceData.InvoiceNumber, + ClientID: unifiedData.InvoiceData.ClientID, + TotalAmount: int64(unifiedData.InvoiceData.TotalAmount * 100), + }); err != nil { + return fmt.Errorf("failed to record invoice in database: %w", err) + } + + fmt.Printf("Unified report generated successfully: %s\n", outputPath) + fmt.Printf("Invoice total: $%.2f (%d hours)\n", unifiedData.InvoiceData.TotalAmount, int(unifiedData.InvoiceData.TotalHours)) + fmt.Printf("Timesheet total: %.2f hours (%d entries)\n", unifiedData.TimesheetData.TotalHours, len(unifiedData.TimesheetData.Entries)) + + return nil +} + +func generateClientUnified(q *queries.Queries, clientName string, dateRange reports.DateRange, loc *time.Location) (*reports.UnifiedReportData, error) { + // Find client + client, err := findClient(context.Background(), q, clientName) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("client not found: %s", clientName) + } + return nil, fmt.Errorf("failed to find client: %w", err) + } + + // Get contractor data + contractor, err := q.GetContractor(context.Background()) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("no contractor information found - please add contractor details first") + } + return nil, fmt.Errorf("failed to get contractor information: %w", err) + } + + // Get invoice number + highestNumber, err := q.GetHighestInvoiceNumber(context.Background(), queries.GetHighestInvoiceNumberParams{ + Year: int64(dateRange.Start.Year()), + Month: int64(dateRange.Start.Month()), + }) + if err != nil { + return nil, fmt.Errorf("failed to get highest invoice number: %w", err) + } + + // Get data for both invoice and timesheet + invoiceEntries, err := q.GetInvoiceDataByClient(context.Background(), queries.GetInvoiceDataByClientParams{ + ClientID: client.ID, + StartTime: dateRange.Start, + EndTime: dateRange.End, + }) + if err != nil { + return nil, fmt.Errorf("failed to get invoice data: %w", err) + } + + timesheetEntries, err := q.GetTimesheetDataByClient(context.Background(), queries.GetTimesheetDataByClientParams{ + ClientID: client.ID, + StartTime: dateRange.Start, + EndTime: dateRange.End, + }) + if err != nil { + return nil, fmt.Errorf("failed to get timesheet data: %w", err) + } + + if len(invoiceEntries) == 0 || len(timesheetEntries) == 0 { + return nil, fmt.Errorf("no completed time entries found for client %s in the specified date range", client.Name) + } + + return reports.GenerateUnifiedReportData(invoiceEntries, client.ID, client.Name, "", contractor, highestNumber+1, dateRange, loc) +} + +func generateProjectUnified(q *queries.Queries, projectName string, dateRange reports.DateRange, loc *time.Location) (*reports.UnifiedReportData, error) { + // Find project + project, err := findProject(context.Background(), q, projectName) + if err != nil { + return nil, fmt.Errorf("failed to find project: %w", err) + } + + // Get client info + clients, err := q.FindClient(context.Background(), queries.FindClientParams{ + ID: project.ClientID, + Name: "", + }) + if err != nil || len(clients) == 0 { + return nil, fmt.Errorf("failed to find client for project") + } + client := clients[0] + + // Get contractor data + contractor, err := q.GetContractor(context.Background()) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("no contractor information found - please add contractor details first") + } + return nil, fmt.Errorf("failed to get contractor information: %w", err) + } + + // Get invoice number + highestNumber, err := q.GetHighestInvoiceNumber(context.Background(), queries.GetHighestInvoiceNumberParams{ + Year: int64(dateRange.Start.Year()), + Month: int64(dateRange.Start.Month()), + }) + if err != nil { + return nil, fmt.Errorf("failed to get highest invoice number: %w", err) + } + + // Get data for both invoice and timesheet + invoiceEntries, err := q.GetInvoiceDataByProject(context.Background(), queries.GetInvoiceDataByProjectParams{ + ProjectID: project.ID, + StartTime: dateRange.Start, + EndTime: dateRange.End, + }) + if err != nil { + return nil, fmt.Errorf("failed to get invoice data: %w", err) + } + + timesheetEntries, err := q.GetTimesheetDataByProject(context.Background(), queries.GetTimesheetDataByProjectParams{ + ProjectID: project.ID, + StartTime: dateRange.Start, + EndTime: dateRange.End, + }) + if err != nil { + return nil, fmt.Errorf("failed to get timesheet data: %w", err) + } + + if len(invoiceEntries) == 0 || len(timesheetEntries) == 0 { + return nil, fmt.Errorf("no completed time entries found for project %s in the specified date range", projectName) + } + + return reports.GenerateUnifiedReportData(invoiceEntries, client.ID, client.Name, projectName, contractor, highestNumber+1, dateRange, loc) } diff --git a/internal/commands/root.go b/internal/commands/root.go index 04f1203..553d0b4 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -37,7 +37,7 @@ func Execute() error { } defer func() { if db, ok := q.DBTX().(*sql.DB); ok { - db.Close() + _ = db.Close() } }() |