diff options
author | T <t@tjp.lol> | 2025-08-13 15:25:23 -0600 |
---|---|---|
committer | T <t@tjp.lol> | 2025-08-13 16:02:05 -0600 |
commit | e9e6eb4e456ee53da5a6ef743251410d4d3d8381 (patch) | |
tree | d722145a5f7a3dd07623e96045078983b3a14e4c /internal | |
parent | d6781f3e5b431057c23b2deaa943f273699e37f5 (diff) |
report generation modal
Diffstat (limited to 'internal')
-rw-r--r-- | internal/commands/report.go | 440 | ||||
-rw-r--r-- | internal/reports/api.go | 500 | ||||
-rw-r--r-- | internal/tui/app.go | 44 | ||||
-rw-r--r-- | internal/tui/commands.go | 59 | ||||
-rw-r--r-- | internal/tui/form.go | 77 | ||||
-rw-r--r-- | internal/tui/keys.go | 5 | ||||
-rw-r--r-- | internal/tui/modal.go | 13 |
7 files changed, 725 insertions, 413 deletions
diff --git a/internal/commands/report.go b/internal/commands/report.go index bcc0e5f..30972b4 100644 --- a/internal/commands/report.go +++ b/internal/commands/report.go @@ -1,15 +1,11 @@ package commands import ( - "context" - "database/sql" "fmt" - "path/filepath" "time" punchctx "punchcard/internal/context" "punchcard/internal/database" - "punchcard/internal/queries" "punchcard/internal/reports" "github.com/spf13/cobra" @@ -97,147 +93,32 @@ func runInvoiceCommand(cmd *cobra.Command, args []string) error { } } - // Generate invoice based on client or project - var invoiceData *reports.InvoiceData - if clientName != "" { - invoiceData, err = generateClientInvoice(q, clientName, dateRange) - } else { - invoiceData, err = generateProjectInvoice(q, projectName, dateRange) - } - if err != nil { - return err - } - - // Generate output filename if not specified - if outputPath == "" { - outputPath = reports.GenerateDefaultInvoiceFilename(invoiceData.ClientName, invoiceData.ProjectName, dateRange) + // Create report parameters + params := reports.ReportParams{ + ClientName: clientName, + ProjectName: projectName, + DateRange: dateRange, + OutputPath: outputPath, } - // Convert to absolute path - outputPath, err = filepath.Abs(outputPath) - if err != nil { - return fmt.Errorf("failed to resolve output path: %w", err) + // Generate invoice using high-level API + var result *reports.ReportResult + if projectName == "" { + result, err = reports.GenerateClientInvoice(cmd.Context(), q, params) + } else { + result, err = reports.GenerateProjectInvoice(cmd.Context(), q, params) } - - // Generate PDF - err = reports.GenerateInvoicePDF(invoiceData, outputPath) if err != nil { - return fmt.Errorf("failed to generate invoice PDF: %w", err) - } - - if _, err := q.CreateInvoice(cmd.Context(), queries.CreateInvoiceParams{ - Year: int64(invoiceData.DateRange.Start.Year()), - Month: int64(invoiceData.DateRange.Start.Month()), - Number: invoiceData.InvoiceNumber, - ClientID: invoiceData.ClientID, - TotalAmount: int64(invoiceData.TotalAmount * 100), - }); err != nil { - return fmt.Errorf("failed to record invoice in database: %w", err) + return err } - fmt.Printf("Invoice generated successfully: %s\n", outputPath) - fmt.Printf("Total hours: %.2f\n", invoiceData.TotalHours) - fmt.Printf("Total amount: $%.2f\n", invoiceData.TotalAmount) + fmt.Printf("Invoice generated successfully: %s\n", result.OutputPath) + fmt.Printf("Total hours: %.2f\n", result.TotalHours) + fmt.Printf("Total amount: $%.2f\n", result.TotalAmount) return nil } -func generateClientInvoice(q *queries.Queries, clientName string, dateRange reports.DateRange) (*reports.InvoiceData, 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) - } - - 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 invoice data - entries, 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) - } - - 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.GenerateInvoiceData(entries, client.ID, client.Name, "", contractor, highestNumber+1, dateRange) -} - -func generateProjectInvoice(q *queries.Queries, projectName string, dateRange reports.DateRange) (*reports.InvoiceData, error) { - // We need to find the project, but we need the client info too - // Let's first find all projects with this name - 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) - } - - 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 invoice data - entries, 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) - } - - if len(entries) == 0 { - return nil, fmt.Errorf("no completed time entries found for project %s in the specified date range", projectName) - } - - return reports.GenerateInvoiceData(entries, client.ID, client.Name, projectName, contractor, highestNumber+1, dateRange) -} - func NewReportTimesheetCmd() *cobra.Command { cmd := &cobra.Command{ Use: "timesheet", @@ -319,120 +200,33 @@ func runTimesheetCommand(cmd *cobra.Command, args []string) error { } } - // 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) + // Create report parameters + params := reports.ReportParams{ + ClientName: clientName, + ProjectName: projectName, + DateRange: dateRange, + OutputPath: outputPath, + Timezone: loc, } - // Convert to absolute path - outputPath, err = filepath.Abs(outputPath) - if err != nil { - return fmt.Errorf("failed to resolve output path: %w", err) + // Generate timesheet using high-level API + var result *reports.ReportResult + if projectName == "" { + result, err = reports.GenerateClientTimesheet(cmd.Context(), q, params) + } else { + result, err = reports.GenerateProjectTimesheet(cmd.Context(), q, params) } - - // Generate PDF - err = reports.GenerateTimesheetPDF(timesheetData, outputPath) if err != nil { - return fmt.Errorf("failed to generate timesheet PDF: %w", err) + return 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)) + fmt.Printf("Timesheet generated successfully: %s\n", result.OutputPath) + fmt.Printf("Total hours: %.2f\n", result.TotalHours) + fmt.Printf("Total entries: %d\n", result.TotalEntries) 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", @@ -514,163 +308,29 @@ func runUnifiedCommand(cmd *cobra.Command, args []string) error { } } - // 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) + // Create report parameters + params := reports.ReportParams{ + ClientName: clientName, + ProjectName: projectName, + DateRange: dateRange, + OutputPath: outputPath, + Timezone: loc, } - // Convert to absolute path - outputPath, err = filepath.Abs(outputPath) - if err != nil { - return fmt.Errorf("failed to resolve output path: %w", err) + // Generate unified report using high-level API + var result *reports.ReportResult + if projectName == "" { + result, err = reports.GenerateClientUnifiedReport(cmd.Context(), q, params) + } else { + result, err = reports.GenerateProjectUnifiedReport(cmd.Context(), q, params) } - - // 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) + return 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)) + fmt.Printf("Unified report generated successfully: %s\n", result.OutputPath) + fmt.Printf("Invoice total: $%.2f (%.0f hours)\n", result.TotalAmount, result.TotalHours) + fmt.Printf("Timesheet total: %.2f hours (%d entries)\n", result.TotalHours, result.TotalEntries) 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/reports/api.go b/internal/reports/api.go new file mode 100644 index 0000000..0db8672 --- /dev/null +++ b/internal/reports/api.go @@ -0,0 +1,500 @@ +package reports + +import ( + "context" + "database/sql" + "fmt" + "path/filepath" + "time" + + "punchcard/internal/queries" +) + +type ReportParams struct { + ClientName string + ProjectName string + DateRange DateRange + OutputPath string + Timezone *time.Location +} + +type ReportResult struct { + OutputPath string + TotalHours float64 + TotalAmount float64 + TotalEntries int +} + +func GenerateInvoice(ctx context.Context, q *queries.Queries, params ReportParams) (*ReportResult, error) { + if params.ProjectName == "" { + return GenerateClientInvoice(ctx, q, params) + } + return GenerateProjectInvoice(ctx, q, params) +} + +func GenerateTimesheet(ctx context.Context, q *queries.Queries, params ReportParams) (*ReportResult, error) { + if params.ProjectName == "" { + return GenerateClientTimesheet(ctx, q, params) + } + return GenerateProjectTimesheet(ctx, q, params) +} + +func GenerateUnifiedReport(ctx context.Context, q *queries.Queries, params ReportParams) (*ReportResult, error) { + if params.ProjectName == "" { + return GenerateClientUnifiedReport(ctx, q, params) + } + return GenerateProjectUnifiedReport(ctx, q, params) +} + +func GenerateClientInvoice(ctx context.Context, q *queries.Queries, params ReportParams) (*ReportResult, error) { + client, err := findClientByName(ctx, q, params.ClientName) + if err != nil { + return nil, fmt.Errorf("client not found: %s", params.ClientName) + } + + contractor, err := q.GetContractor(ctx) + 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) + } + + highestNumber, err := q.GetHighestInvoiceNumber(ctx, queries.GetHighestInvoiceNumberParams{ + Year: int64(params.DateRange.Start.Year()), + Month: int64(params.DateRange.Start.Month()), + }) + if err != nil { + return nil, fmt.Errorf("failed to get highest invoice number: %w", err) + } + + entries, err := q.GetInvoiceDataByClient(ctx, queries.GetInvoiceDataByClientParams{ + ClientID: client.ID, + StartTime: params.DateRange.Start, + EndTime: params.DateRange.End, + }) + if err != nil { + return nil, fmt.Errorf("failed to get invoice 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) + } + + invoiceData, err := GenerateInvoiceData(entries, client.ID, client.Name, "", contractor, highestNumber+1, params.DateRange) + if err != nil { + return nil, fmt.Errorf("failed to generate invoice data: %w", err) + } + + outputPath := params.OutputPath + if outputPath == "" { + outputPath = GenerateDefaultInvoiceFilename(invoiceData.ClientName, invoiceData.ProjectName, params.DateRange) + } + + outputPath, err = filepath.Abs(outputPath) + if err != nil { + return nil, fmt.Errorf("failed to resolve output path: %w", err) + } + + if err := GenerateInvoicePDF(invoiceData, outputPath); err != nil { + return nil, fmt.Errorf("failed to generate invoice PDF: %w", err) + } + + if err := RecordInvoice(q, + int64(invoiceData.DateRange.Start.Year()), + int64(invoiceData.DateRange.Start.Month()), + invoiceData.InvoiceNumber, + invoiceData.ClientID, + int64(invoiceData.TotalAmount*100), + ); err != nil { + return nil, err + } + + return &ReportResult{ + OutputPath: outputPath, + TotalHours: invoiceData.TotalHours, + TotalAmount: invoiceData.TotalAmount, + TotalEntries: len(invoiceData.LineItems), + }, nil +} + +func GenerateProjectInvoice(ctx context.Context, q *queries.Queries, params ReportParams) (*ReportResult, error) { + project, err := findProjectByName(ctx, q, params.ProjectName) + if err != nil { + return nil, fmt.Errorf("failed to find project: %w", err) + } + + clients, err := q.FindClient(ctx, 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] + + contractor, err := q.GetContractor(ctx) + 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) + } + + highestNumber, err := q.GetHighestInvoiceNumber(ctx, queries.GetHighestInvoiceNumberParams{ + Year: int64(params.DateRange.Start.Year()), + Month: int64(params.DateRange.Start.Month()), + }) + if err != nil { + return nil, fmt.Errorf("failed to get highest invoice number: %w", err) + } + + entries, err := q.GetInvoiceDataByProject(ctx, queries.GetInvoiceDataByProjectParams{ + ProjectID: project.ID, + StartTime: params.DateRange.Start, + EndTime: params.DateRange.End, + }) + if err != nil { + return nil, fmt.Errorf("failed to get invoice data: %w", err) + } + + if len(entries) == 0 { + return nil, fmt.Errorf("no completed time entries found for project %s in the specified date range", params.ProjectName) + } + + invoiceData, err := GenerateInvoiceData(entries, client.ID, client.Name, params.ProjectName, contractor, highestNumber+1, params.DateRange) + if err != nil { + return nil, fmt.Errorf("failed to generate invoice data: %w", err) + } + + outputPath := params.OutputPath + if outputPath == "" { + outputPath = GenerateDefaultInvoiceFilename(invoiceData.ClientName, invoiceData.ProjectName, params.DateRange) + } + + outputPath, err = filepath.Abs(outputPath) + if err != nil { + return nil, fmt.Errorf("failed to resolve output path: %w", err) + } + + if err := GenerateInvoicePDF(invoiceData, outputPath); err != nil { + return nil, fmt.Errorf("failed to generate invoice PDF: %w", err) + } + + if err := RecordInvoice(q, + int64(invoiceData.DateRange.Start.Year()), + int64(invoiceData.DateRange.Start.Month()), + invoiceData.InvoiceNumber, + invoiceData.ClientID, + int64(invoiceData.TotalAmount*100), + ); err != nil { + return nil, err + } + + return &ReportResult{ + OutputPath: outputPath, + TotalHours: invoiceData.TotalHours, + TotalAmount: invoiceData.TotalAmount, + TotalEntries: len(invoiceData.LineItems), + }, nil +} + +func GenerateClientTimesheet(ctx context.Context, q *queries.Queries, params ReportParams) (*ReportResult, error) { + client, err := findClientByName(ctx, q, params.ClientName) + if err != nil { + return nil, fmt.Errorf("client not found: %s", params.ClientName) + } + + contractor, err := q.GetContractor(ctx) + 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) + } + + entries, err := q.GetTimesheetDataByClient(ctx, queries.GetTimesheetDataByClientParams{ + ClientID: client.ID, + StartTime: params.DateRange.Start, + EndTime: params.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) + } + + timesheetData, err := GenerateTimesheetData(entries, client.ID, client.Name, "", contractor, params.DateRange, params.Timezone) + if err != nil { + return nil, fmt.Errorf("failed to generate timesheet data: %w", err) + } + + outputPath := params.OutputPath + if outputPath == "" { + outputPath = GenerateDefaultTimesheetFilename(timesheetData.ClientName, timesheetData.ProjectName, params.DateRange) + } + + outputPath, err = filepath.Abs(outputPath) + if err != nil { + return nil, fmt.Errorf("failed to resolve output path: %w", err) + } + + if err := GenerateTimesheetPDF(timesheetData, outputPath); err != nil { + return nil, fmt.Errorf("failed to generate timesheet PDF: %w", err) + } + + return &ReportResult{ + OutputPath: outputPath, + TotalHours: timesheetData.TotalHours, + TotalAmount: 0, // Timesheets don't have amounts + TotalEntries: len(timesheetData.Entries), + }, nil +} + +func GenerateProjectTimesheet(ctx context.Context, q *queries.Queries, params ReportParams) (*ReportResult, error) { + project, err := findProjectByName(ctx, q, params.ProjectName) + if err != nil { + return nil, fmt.Errorf("failed to find project: %w", err) + } + + clients, err := q.FindClient(ctx, 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] + + contractor, err := q.GetContractor(ctx) + 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) + } + + entries, err := q.GetTimesheetDataByProject(ctx, queries.GetTimesheetDataByProjectParams{ + ProjectID: project.ID, + StartTime: params.DateRange.Start, + EndTime: params.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", params.ProjectName) + } + + timesheetData, err := GenerateTimesheetData(entries, client.ID, client.Name, params.ProjectName, contractor, params.DateRange, params.Timezone) + if err != nil { + return nil, fmt.Errorf("failed to generate timesheet data: %w", err) + } + + outputPath := params.OutputPath + if outputPath == "" { + outputPath = GenerateDefaultTimesheetFilename(timesheetData.ClientName, timesheetData.ProjectName, params.DateRange) + } + + outputPath, err = filepath.Abs(outputPath) + if err != nil { + return nil, fmt.Errorf("failed to resolve output path: %w", err) + } + + if err := GenerateTimesheetPDF(timesheetData, outputPath); err != nil { + return nil, fmt.Errorf("failed to generate timesheet PDF: %w", err) + } + + return &ReportResult{ + OutputPath: outputPath, + TotalHours: timesheetData.TotalHours, + TotalAmount: 0, // Timesheets don't have amounts + TotalEntries: len(timesheetData.Entries), + }, nil +} + +func GenerateClientUnifiedReport(ctx context.Context, q *queries.Queries, params ReportParams) (*ReportResult, error) { + client, err := findClientByName(ctx, q, params.ClientName) + if err != nil { + return nil, fmt.Errorf("client not found: %s", params.ClientName) + } + + contractor, err := q.GetContractor(ctx) + 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) + } + + highestNumber, err := q.GetHighestInvoiceNumber(ctx, queries.GetHighestInvoiceNumberParams{ + Year: int64(params.DateRange.Start.Year()), + Month: int64(params.DateRange.Start.Month()), + }) + if err != nil { + return nil, fmt.Errorf("failed to get highest invoice number: %w", err) + } + + invoiceEntries, err := q.GetInvoiceDataByClient(ctx, queries.GetInvoiceDataByClientParams{ + ClientID: client.ID, + StartTime: params.DateRange.Start, + EndTime: params.DateRange.End, + }) + if err != nil { + return nil, fmt.Errorf("failed to get invoice data: %w", err) + } + + if len(invoiceEntries) == 0 { + return nil, fmt.Errorf("no completed time entries found for client %s in the specified date range", client.Name) + } + + unifiedData, err := GenerateUnifiedReportData(invoiceEntries, client.ID, client.Name, "", contractor, highestNumber+1, params.DateRange, params.Timezone) + if err != nil { + return nil, fmt.Errorf("failed to generate unified report data: %w", err) + } + + outputPath := params.OutputPath + if outputPath == "" { + outputPath = GenerateDefaultUnifiedFilename(unifiedData.InvoiceData.ClientName, unifiedData.InvoiceData.ProjectName, params.DateRange) + } + + outputPath, err = filepath.Abs(outputPath) + if err != nil { + return nil, fmt.Errorf("failed to resolve output path: %w", err) + } + + if err := GenerateUnifiedPDF(unifiedData, outputPath); err != nil { + return nil, fmt.Errorf("failed to generate unified PDF: %w", err) + } + + if err := RecordInvoice(q, + int64(unifiedData.InvoiceData.DateRange.Start.Year()), + int64(unifiedData.InvoiceData.DateRange.Start.Month()), + unifiedData.InvoiceData.InvoiceNumber, + unifiedData.InvoiceData.ClientID, + int64(unifiedData.InvoiceData.TotalAmount*100), + ); err != nil { + return nil, err + } + + return &ReportResult{ + OutputPath: outputPath, + TotalHours: unifiedData.InvoiceData.TotalHours, + TotalAmount: unifiedData.InvoiceData.TotalAmount, + TotalEntries: len(unifiedData.TimesheetData.Entries), + }, nil +} + +func GenerateProjectUnifiedReport(ctx context.Context, q *queries.Queries, params ReportParams) (*ReportResult, error) { + project, err := findProjectByName(ctx, q, params.ProjectName) + if err != nil { + return nil, fmt.Errorf("failed to find project: %w", err) + } + + clients, err := q.FindClient(ctx, 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] + + contractor, err := q.GetContractor(ctx) + 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) + } + + highestNumber, err := q.GetHighestInvoiceNumber(ctx, queries.GetHighestInvoiceNumberParams{ + Year: int64(params.DateRange.Start.Year()), + Month: int64(params.DateRange.Start.Month()), + }) + if err != nil { + return nil, fmt.Errorf("failed to get highest invoice number: %w", err) + } + + invoiceEntries, err := q.GetInvoiceDataByProject(ctx, queries.GetInvoiceDataByProjectParams{ + ProjectID: project.ID, + StartTime: params.DateRange.Start, + EndTime: params.DateRange.End, + }) + if err != nil { + return nil, fmt.Errorf("failed to get invoice data: %w", err) + } + + if len(invoiceEntries) == 0 { + return nil, fmt.Errorf("no completed time entries found for project %s in the specified date range", params.ProjectName) + } + + unifiedData, err := GenerateUnifiedReportData(invoiceEntries, client.ID, client.Name, params.ProjectName, contractor, highestNumber+1, params.DateRange, params.Timezone) + if err != nil { + return nil, fmt.Errorf("failed to generate unified report data: %w", err) + } + + outputPath := params.OutputPath + if outputPath == "" { + outputPath = GenerateDefaultUnifiedFilename(unifiedData.InvoiceData.ClientName, unifiedData.InvoiceData.ProjectName, params.DateRange) + } + + outputPath, err = filepath.Abs(outputPath) + if err != nil { + return nil, fmt.Errorf("failed to resolve output path: %w", err) + } + + if err := GenerateUnifiedPDF(unifiedData, outputPath); err != nil { + return nil, fmt.Errorf("failed to generate unified PDF: %w", err) + } + + if err := RecordInvoice(q, + int64(unifiedData.InvoiceData.DateRange.Start.Year()), + int64(unifiedData.InvoiceData.DateRange.Start.Month()), + unifiedData.InvoiceData.InvoiceNumber, + unifiedData.InvoiceData.ClientID, + int64(unifiedData.InvoiceData.TotalAmount*100), + ); err != nil { + return nil, err + } + + return &ReportResult{ + OutputPath: outputPath, + TotalHours: unifiedData.InvoiceData.TotalHours, + TotalAmount: unifiedData.InvoiceData.TotalAmount, + TotalEntries: len(unifiedData.TimesheetData.Entries), + }, nil +} + +// Helper functions for finding clients and projects +func findClientByName(ctx context.Context, q *queries.Queries, clientName string) (queries.Client, error) { + clients, err := q.FindClient(ctx, queries.FindClientParams{ + ID: 0, + Name: clientName, + }) + if err != nil { + return queries.Client{}, err + } + if len(clients) == 0 { + return queries.Client{}, sql.ErrNoRows + } + return clients[0], nil +} + +func findProjectByName(ctx context.Context, q *queries.Queries, projectName string) (queries.Project, error) { + projects, err := q.FindProject(ctx, queries.FindProjectParams{ + ID: 0, + Name: projectName, + }) + if err != nil { + return queries.Project{}, err + } + if len(projects) == 0 { + return queries.Project{}, sql.ErrNoRows + } + return projects[0], nil +} + diff --git a/internal/tui/app.go b/internal/tui/app.go index 433a5ad..4b31c38 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -205,6 +205,11 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.historyBox.filter = HistoryFilter(msg) cmds = append(cmds, m.refreshCmd) + case openReportModal: + if m.selectedBox == HistoryBox && m.historyBox.viewLevel == HistoryLevelSummary { + m.openReportModal() + } + } return m, tea.Batch(cmds...) @@ -252,10 +257,10 @@ func (m *AppModel) openHistoryFilterModal() { var dateRangeStr string if filter.EndDate == nil { // Use "since <date>" format for open-ended ranges - dateRangeStr = fmt.Sprintf("since %s", filter.StartDate.Format("2006-01-02")) + dateRangeStr = fmt.Sprintf("since %s", filter.StartDate.Format(time.DateOnly)) } else { // Use "YYYY-MM-DD to YYYY-MM-DD" format for bounded ranges - dateRangeStr = fmt.Sprintf("%s to %s", filter.StartDate.Format("2006-01-02"), filter.EndDate.Format("2006-01-02")) + dateRangeStr = fmt.Sprintf("%s to %s", filter.StartDate.Format(time.DateOnly), filter.EndDate.Format(time.DateOnly)) } m.modalBox.form.fields[0].SetValue(dateRangeStr) @@ -282,6 +287,41 @@ func (m *AppModel) openHistoryFilterModal() { } } +func (m *AppModel) openReportModal() { + m.modalBox.activate(ModalTypeGenerateReport, 0, *m) + m.modalBox.form.fields[0].Focus() + + filter := m.historyBox.filter + + var dateRangeStr string + if filter.EndDate == nil { + dateRangeStr = fmt.Sprintf("since %s", filter.StartDate.Format(time.DateOnly)) + } else { + dateRangeStr = fmt.Sprintf("%s to %s", filter.StartDate.Format(time.DateOnly), filter.EndDate.Format(time.DateOnly)) + } + m.modalBox.form.fields[1].SetValue(dateRangeStr) + + if filter.ClientID != nil { + for _, client := range m.projectsBox.clients { + if client.ID == *filter.ClientID { + m.modalBox.form.fields[2].SetValue(client.Name) + break + } + } + } + + if filter.ProjectID != nil { + for _, clientProjects := range m.projectsBox.projects { + for _, project := range clientProjects { + if project.ID == *filter.ProjectID { + m.modalBox.form.fields[3].SetValue(project.Name) + break + } + } + } + } +} + // View renders the app func (m AppModel) View() string { if m.width == 0 || m.height == 0 { diff --git a/internal/tui/commands.go b/internal/tui/commands.go index aa5cc79..9a836c5 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -2,8 +2,13 @@ package tui import ( "context" + "fmt" + "strings" + "time" "punchcard/internal/actions" + "punchcard/internal/queries" + "punchcard/internal/reports" tea "github.com/charmbracelet/bubbletea" ) @@ -22,6 +27,7 @@ type ( openCreateClientModal struct{} openCreateProjectModal struct{} openHistoryFilterModal struct{} + openReportModal struct{} updateHistoryFilter HistoryFilter ) @@ -113,3 +119,56 @@ func createProjectModal() tea.Cmd { func createHistoryFilterModal() tea.Cmd { return func() tea.Msg { return openHistoryFilterModal{} } } + +func createReportModal() tea.Cmd { + return func() tea.Msg { return openReportModal{} } +} + +func generateReport(m *ModalBoxModel, am AppModel) tea.Cmd { + return func() tea.Msg { + form := &m.form + + dateRange, err := reports.ParseDateRange(form.fields[1].Value()) + if err != nil { + form.fields[1].Err = fmt.Errorf("invalid date range: %v", err) + return reOpenModal() + } + + var tz *time.Location + tzstr := form.fields[5].Value() + if tzstr == "" { + tz = time.Local + } else { + zone, err := time.LoadLocation(tzstr) + if err != nil { + form.fields[5].Err = err + return reOpenModal() + } + tz = zone + } + + var genFunc func(context.Context, *queries.Queries, reports.ReportParams) (*reports.ReportResult, error) + switch strings.ToLower(form.fields[0].Value()) { + case "invoice": + genFunc = reports.GenerateInvoice + case "timesheet": + genFunc = reports.GenerateTimesheet + case "unified": + genFunc = reports.GenerateUnifiedReport + } + + params := reports.ReportParams{ + ClientName: form.fields[2].Value(), + ProjectName: form.fields[3].Value(), + DateRange: dateRange, + OutputPath: form.fields[4].Value(), + Timezone: tz, + } + if _, err := genFunc(context.Background(), am.queries, params); err != nil { + form.err = err + return reOpenModal() + } + + return nil + } +} diff --git a/internal/tui/form.go b/internal/tui/form.go index 3a1e5f6..d70fb8b 100644 --- a/internal/tui/form.go +++ b/internal/tui/form.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "strconv" + "strings" "time" "punchcard/internal/reports" @@ -19,6 +20,7 @@ const ( noSuggestions suggestionType = iota suggestClients suggestProjects + suggestReportType ) type FormField struct { @@ -100,6 +102,22 @@ func newDateRangeField(label string) FormField { return f } +func newReportTypeField(label string) FormField { + f := FormField{ + Model: textinput.New(), + label: label, + suggestions: suggestReportType, + } + f.Validate = func(s string) error { + switch strings.ToLower(s) { + case "invoice", "timesheet", "unified": + return nil + } + return errors.New("pick one of invoice, timesheet, or unified") + } + return f +} + type Form struct { fields []FormField selIdx int @@ -113,8 +131,8 @@ func NewForm(fields []FormField) Form { return Form{fields: fields} } -func (ff Form) Error() error { - for _, field := range ff.fields { +func (f Form) Error() error { + for _, field := range f.fields { if field.Err != nil { return field.Err } @@ -169,44 +187,58 @@ func NewHistoryFilterForm() Form { return form } -func (ff Form) Update(msg tea.Msg) (Form, tea.Cmd) { +func NewGenerateReportForm() Form { + form := NewForm([]FormField{ + newReportTypeField("Report Type"), + newDateRangeField("Date Range"), + {Model: textinput.New(), label: "Client", suggestions: suggestClients}, + {Model: textinput.New(), label: "Project (optional)", suggestions: suggestProjects}, + {Model: textinput.New(), label: "Output Path (optional)"}, + {Model: textinput.New(), label: "Timezone (optional)"}, + }) + form.SelectedStyle = &modalFocusedInputStyle + form.UnselectedStyle = &modalBlurredInputStyle + return form +} + +func (f Form) Update(msg tea.Msg) (Form, tea.Cmd) { if msg, ok := msg.(tea.KeyMsg); ok { switch msg.String() { case "tab": - ff.fields[ff.selIdx].Blur() - ff.selIdx = (ff.selIdx + 1) % len(ff.fields) - return ff, ff.fields[ff.selIdx].Focus() + f.fields[f.selIdx].Blur() + f.selIdx = (f.selIdx + 1) % len(f.fields) + return f, f.fields[f.selIdx].Focus() case "shift+tab": - ff.fields[ff.selIdx].Blur() - ff.selIdx-- - if ff.selIdx < 0 { - ff.selIdx += len(ff.fields) + f.fields[f.selIdx].Blur() + f.selIdx-- + if f.selIdx < 0 { + f.selIdx += len(f.fields) } - return ff, ff.fields[ff.selIdx].Focus() + return f, f.fields[f.selIdx].Focus() } } - field, cmd := ff.fields[ff.selIdx].Update(msg) - ff.fields[ff.selIdx] = field - return ff, cmd + field, cmd := f.fields[f.selIdx].Update(msg) + f.fields[f.selIdx] = field + return f, cmd } -func (ff Form) View() string { +func (f Form) View() string { content := "" - if ff.err != nil { - content += errorStyle.Render(ff.err.Error()) + "\n\n" + if f.err != nil { + content += errorStyle.Render(f.err.Error()) + "\n\n" } - for i, field := range ff.fields { + for i, field := range f.fields { if i > 0 { content += "\n\n" } content += field.label + ":\n" - style := ff.UnselectedStyle - if i == ff.selIdx { - style = ff.SelectedStyle + style := f.UnselectedStyle + if i == f.selIdx { + style = f.SelectedStyle } if style != nil { content += style.Render(field.View()) @@ -241,6 +273,9 @@ func (f *Form) SetSuggestions(m AppModel) { } ff.SetSuggestions(projNames) ff.ShowSuggestions = true + case suggestReportType: + ff.SetSuggestions([]string{"Invoice", "Timesheet", "Unified"}) + ff.ShowSuggestions = true } } } diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 3a7242d..69ce223 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -174,6 +174,11 @@ var Bindings map[KeyBindingScope]map[string]KeyBinding = map[KeyBindingScope]map Description: func(AppModel) string { return "Bottom" }, Result: func(*AppModel) tea.Cmd { return changeSelectionToBottom() }, }, + "R": KeyBinding{ + Key: "R", + Description: func(AppModel) string { return "Report" }, + Result: func(*AppModel) tea.Cmd { return createReportModal() }, + }, }, ScopeHistoryBoxDetails: { "j": KeyBinding{ diff --git a/internal/tui/modal.go b/internal/tui/modal.go index badc658..7660243 100644 --- a/internal/tui/modal.go +++ b/internal/tui/modal.go @@ -23,6 +23,7 @@ const ( ModalTypeDeleteConfirmation ModalTypeEntry ModalTypeHistoryFilter + ModalTypeGenerateReport ) func (mt ModalType) newForm() Form { @@ -35,6 +36,8 @@ func (mt ModalType) newForm() Form { return NewProjectForm() case ModalTypeHistoryFilter: return NewHistoryFilterForm() + case ModalTypeGenerateReport: + return NewGenerateReportForm() } return Form{} @@ -91,6 +94,8 @@ func (m ModalBoxModel) Render() string { return m.RenderFormModal("📂 Project") case ModalTypeHistoryFilter: return m.RenderFormModal("🔍 History Filter") + case ModalTypeGenerateReport: + return m.RenderFormModal("📄 Generate Report") default: // REMOVE ME return "DEFAULT CONTENT" } @@ -274,6 +279,14 @@ func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd { // Return filter update message return func() tea.Msg { return updateHistoryFilter(newFilter) } + + case ModalTypeGenerateReport: + if err := m.form.Error(); err != nil { + return reOpenModal() + } + + return generateReport(m, am) + } return nil |