summaryrefslogtreecommitdiff
path: root/internal/reports
diff options
context:
space:
mode:
authorT <t@tjp.lol>2025-08-13 15:25:23 -0600
committerT <t@tjp.lol>2025-08-13 16:02:05 -0600
commite9e6eb4e456ee53da5a6ef743251410d4d3d8381 (patch)
treed722145a5f7a3dd07623e96045078983b3a14e4c /internal/reports
parentd6781f3e5b431057c23b2deaa943f273699e37f5 (diff)
report generation modal
Diffstat (limited to 'internal/reports')
-rw-r--r--internal/reports/api.go500
1 files changed, 500 insertions, 0 deletions
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
+}
+