diff --git a/internal/api/clock.go b/internal/api/clock.go new file mode 100644 index 0000000..10121be --- /dev/null +++ b/internal/api/clock.go @@ -0,0 +1,78 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" +) + +// ClockEventData represents the response data returned from the ServiceTrade clock endpoint. +type ClockEventData struct { + TotalPages int `json:"totalPages"` + Page int `json:"page"` + ActivityTime *ClockActivityMap `json:"activityTime"` + PairedEvents []ClockPairedEvent `json:"pairedEvents"` + UnpairedEvents []ClockEvent `json:"unpairedEvents"` +} + +// ClockActivityMap captures total elapsed seconds for each activity type. +type ClockActivityMap struct { + Onsite int64 `json:"onsite"` + Offsite int64 `json:"offsite"` + Enroute int64 `json:"enroute"` + Onbreak int64 `json:"onbreak"` +} + +// ClockPairedEvent represents a paired start/end clock event. +type ClockPairedEvent struct { + ElapsedTime int64 `json:"elapsedTime"` + Start ClockEvent `json:"start"` + End ClockEvent `json:"end"` +} + +// ClockEvent represents an individual clock event. +type ClockEvent struct { + EventTime int64 `json:"eventTime"` + EventType string `json:"eventType"` + Activity string `json:"activity"` + Source string `json:"source"` + User ClockUser `json:"user"` +} + +// ClockUser represents a technician associated with a clock event. +type ClockUser struct { + Name string `json:"name"` + Email string `json:"email"` +} + +type clockResponse struct { + Data ClockEventData `json:"data"` +} + +// GetJobClockEvents retrieves paired clock events for a given job identifier. +func (s *Session) GetJobClockEvents(jobID string) (*ClockEventData, error) { + endpoint := fmt.Sprintf("/clock?jobId=%s", jobID) + resp, err := s.DoRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned error: %s - %s", resp.Status, string(body)) + } + + var result clockResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("error unmarshalling clock response: %w", err) + } + + return &result.Data, nil +} + diff --git a/internal/handlers/web/invoice_clock_report.go b/internal/handlers/web/invoice_clock_report.go new file mode 100644 index 0000000..62f9989 --- /dev/null +++ b/internal/handlers/web/invoice_clock_report.go @@ -0,0 +1,348 @@ +package web + +import ( + "bytes" + "encoding/csv" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "marmic/servicetrade-toolbox/internal/api" + "marmic/servicetrade-toolbox/internal/middleware" +) + +var invoiceClockReportHeader = []string{ + "invoice_number", + "invoice_id", + "job_id", + "job_number", + "assignment_type", + "vendor_name", + "tech_names", + "enroute_events", + "onsite_events", + "error", +} + +// InvoiceClockReportHandler processes uploaded CSVs of invoice numbers and returns a clock-event report. +func InvoiceClockReportHandler(w http.ResponseWriter, r *http.Request) { + session, ok := r.Context().Value(middleware.SessionKey).(*api.Session) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if err := r.ParseMultipartForm(10 << 20); err != nil { + http.Error(w, fmt.Sprintf("Unable to parse form: %v", err), http.StatusBadRequest) + return + } + + file, _, err := r.FormFile("invoiceCsv") + if err != nil { + http.Error(w, fmt.Sprintf("Error retrieving file: %v", err), http.StatusBadRequest) + return + } + defer file.Close() + + csvReader := csv.NewReader(file) + records, err := csvReader.ReadAll() + if err != nil { + http.Error(w, fmt.Sprintf("Error reading CSV file: %v", err), http.StatusBadRequest) + return + } + + if len(records) == 0 { + http.Error(w, "CSV file is empty", http.StatusBadRequest) + return + } + + invoiceNumbers := extractInvoiceNumbers(records) + if len(invoiceNumbers) == 0 { + http.Error(w, "No invoice numbers found in CSV", http.StatusBadRequest) + return + } + + var buffer bytes.Buffer + writer := csv.NewWriter(&buffer) + + if err := writer.Write(invoiceClockReportHeader); err != nil { + http.Error(w, fmt.Sprintf("Error writing CSV header: %v", err), http.StatusInternalServerError) + return + } + + for _, invoiceNumber := range invoiceNumbers { + row := buildInvoiceClockReportRow(session, invoiceNumber) + if err := writer.Write(row); err != nil { + http.Error(w, fmt.Sprintf("Error writing CSV row: %v", err), http.StatusInternalServerError) + return + } + } + + writer.Flush() + if err := writer.Error(); err != nil { + http.Error(w, fmt.Sprintf("Error finalizing CSV: %v", err), http.StatusInternalServerError) + return + } + + filename := fmt.Sprintf("invoice-clock-report-%s.csv", time.Now().Format("20060102-150405")) + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + w.Header().Set("Cache-Control", "no-store") + w.Write(buffer.Bytes()) +} + +func extractInvoiceNumbers(records [][]string) []string { + if len(records) == 0 { + return nil + } + + headers := make([]string, len(records[0])) + for i, header := range records[0] { + headers[i] = strings.ToLower(strings.TrimSpace(header)) + } + + columnIndex := 0 + hasHeader := false + for i, header := range headers { + if header == "invoice_number" || header == "invoicenumber" || header == "invoice" { + columnIndex = i + hasHeader = true + break + } + } + + var numbers []string + startRow := 0 + if hasHeader { + startRow = 1 + } + for _, row := range records[startRow:] { + if columnIndex >= len(row) { + continue + } + number := strings.TrimSpace(row[columnIndex]) + if number != "" { + numbers = append(numbers, number) + } + } + + return numbers +} + +func buildInvoiceClockReportRow(session *api.Session, invoiceIdentifier string) []string { + row := make([]string, len(invoiceClockReportHeader)) + row[0] = invoiceIdentifier + + invoice, err := session.GetInvoice(invoiceIdentifier) + if err != nil { + row[len(row)-1] = fmt.Sprintf("invoice lookup error: %v", err) + return row + } + if invoice == nil { + row[len(row)-1] = "invoice not found" + return row + } + + invoiceID := valueToString(invoice["id"]) + row[1] = invoiceID + + jobInfo, ok := invoice["job"].(map[string]interface{}) + if !ok { + row[len(row)-1] = "invoice missing job information" + return row + } + + jobID := valueToString(jobInfo["id"]) + jobNumber := valueToString(jobInfo["number"]) + row[2] = jobID + row[3] = jobNumber + + var errors []string + + jobDetails, err := session.GetJobDetails(jobID) + if err != nil { + errors = append(errors, fmt.Sprintf("job details error: %v", err)) + } + + clockData, err := session.GetJobClockEvents(jobID) + if err != nil { + errors = append(errors, fmt.Sprintf("clock events error: %v", err)) + } + + techNames := collectTechNames(jobDetails, clockData) + + vendorName := extractVendorName(jobDetails, invoice) + assignmentType := determineAssignmentType(techNames, vendorName) + + row[4] = assignmentType + row[5] = vendorName + row[6] = strings.Join(techNames, ", ") + + if clockData != nil { + row[7] = formatActivityEvents(clockData.PairedEvents, "enroute") + row[8] = formatActivityEvents(clockData.PairedEvents, "onsite") + } + + if len(errors) > 0 { + row[len(row)-1] = strings.Join(errors, "; ") + } + + return row +} + +func collectTechNames(jobDetails map[string]interface{}, clockData *api.ClockEventData) []string { + seen := map[string]struct{}{} + + if jobDetails != nil { + if appointments, ok := jobDetails["appointments"].([]interface{}); ok { + for _, appt := range appointments { + apptMap, ok := appt.(map[string]interface{}) + if !ok { + continue + } + if techs, ok := apptMap["techs"].([]interface{}); ok { + for _, tech := range techs { + if techMap, ok := tech.(map[string]interface{}); ok { + name := strings.TrimSpace(valueToString(techMap["name"])) + if name != "" { + seen[name] = struct{}{} + } + } + } + } + } + } + if current, ok := jobDetails["currentAppointment"].(map[string]interface{}); ok { + if techs, ok := current["techs"].([]interface{}); ok { + for _, tech := range techs { + if techMap, ok := tech.(map[string]interface{}); ok { + name := strings.TrimSpace(valueToString(techMap["name"])) + if name != "" { + seen[name] = struct{}{} + } + } + } + } + } + } + + if clockData != nil { + for _, event := range clockData.PairedEvents { + name := strings.TrimSpace(event.Start.User.Name) + if name != "" { + seen[name] = struct{}{} + } + name = strings.TrimSpace(event.End.User.Name) + if name != "" { + seen[name] = struct{}{} + } + } + } + + if len(seen) == 0 { + return nil + } + + var names []string + for name := range seen { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func extractVendorName(jobDetails map[string]interface{}, invoice map[string]interface{}) string { + if jobDetails != nil { + if vendor, ok := jobDetails["vendor"].(map[string]interface{}); ok { + if name := strings.TrimSpace(valueToString(vendor["name"])); name != "" { + return name + } + } + } + + if vendor, ok := invoice["vendor"].(map[string]interface{}); ok { + if name := strings.TrimSpace(valueToString(vendor["name"])); name != "" { + return name + } + } + + return "" +} + +func determineAssignmentType(techNames []string, vendorName string) string { + if len(techNames) > 0 { + return "Technician" + } + if vendorName != "" { + return "Vendor" + } + return "Unknown" +} + +func formatActivityEvents(events []api.ClockPairedEvent, activity string) string { + var parts []string + activityLower := strings.ToLower(activity) + + for _, e := range events { + if strings.ToLower(e.Start.Activity) != activityLower { + continue + } + + tech := strings.TrimSpace(e.Start.User.Name) + if tech == "" { + tech = "Unknown Tech" + } + + start := formatTimestamp(e.Start.EventTime) + end := formatTimestamp(e.End.EventTime) + var durationText string + if e.ElapsedTime > 0 { + duration := time.Duration(e.ElapsedTime) * time.Second + durationText = duration.String() + } + + detail := fmt.Sprintf("%s: %s -> %s", tech, start, end) + if durationText != "" { + detail = fmt.Sprintf("%s (%s)", detail, durationText) + } + + parts = append(parts, detail) + } + + return strings.Join(parts, " | ") +} + +func formatTimestamp(epoch int64) string { + if epoch == 0 { + return "" + } + return time.Unix(epoch, 0).UTC().Format(time.RFC3339) +} + +func valueToString(value interface{}) string { + switch v := value.(type) { + case string: + return v + case fmt.Stringer: + return v.String() + case float64: + return strconv.FormatInt(int64(v), 10) + case int64: + return strconv.FormatInt(v, 10) + case int: + return strconv.Itoa(v) + default: + if v == nil { + return "" + } + return fmt.Sprintf("%v", v) + } +}