diff --git a/apps/web/main.go b/apps/web/main.go
index 1a9f19f..4af453d 100644
--- a/apps/web/main.go
+++ b/apps/web/main.go
@@ -145,6 +145,9 @@ func main() {
protected.HandleFunc("/services", web.ServicesHandler).Methods("GET")
protected.HandleFunc("/tags", web.TagsHandler).Methods("GET")
protected.HandleFunc("/users", web.UsersHandler).Methods("GET")
+ protected.HandleFunc("/users/upload", web.UsersUploadHandler).Methods("POST")
+ protected.HandleFunc("/users/update", web.UsersUpdateHandler).Methods("GET")
+ protected.HandleFunc("/users/update/upload", web.UsersUpdateUploadHandler).Methods("POST")
// Document upload routes
protected.HandleFunc("/documents", web.DocumentsHandler).Methods("GET")
diff --git a/apps/web/web-app b/apps/web/web-app
deleted file mode 100644
index c89706a..0000000
Binary files a/apps/web/web-app and /dev/null differ
diff --git a/go.mod b/go.mod
index 04e70ed..a94ab50 100644
--- a/go.mod
+++ b/go.mod
@@ -11,4 +11,13 @@ require (
require (
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
+ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
+ github.com/richardlehane/mscfb v1.0.4 // indirect
+ github.com/richardlehane/msoleps v1.0.3 // indirect
+ github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 // indirect
+ github.com/xuri/excelize/v2 v2.8.1 // indirect
+ github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 // indirect
+ golang.org/x/crypto v0.19.0 // indirect
+ golang.org/x/net v0.21.0 // indirect
+ golang.org/x/text v0.14.0 // indirect
)
diff --git a/go.sum b/go.sum
index 1176ff2..aca6e7a 100644
--- a/go.sum
+++ b/go.sum
@@ -10,3 +10,22 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
+github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
+github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
+github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
+github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM=
+github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
+github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 h1:Chd9DkqERQQuHpXjR/HSV1jLZA6uaoiwwH3vSuF3IW0=
+github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
+github.com/xuri/excelize/v2 v2.8.1 h1:pZLMEwK8ep+CLIUWpWmvW8IWE/yxqG0I1xcN6cVMGuQ=
+github.com/xuri/excelize/v2 v2.8.1/go.mod h1:oli1E4C3Pa5RXg1TBXn4ENCXDV5JUMlBluUhG7c+CEE=
+github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 h1:qhbILQo1K3mphbwKh1vNm4oGezE1eF9fQWmNiIpSfI4=
+github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
+golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
+golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
diff --git a/internal/api/users.go b/internal/api/users.go
new file mode 100644
index 0000000..064b933
--- /dev/null
+++ b/internal/api/users.go
@@ -0,0 +1,481 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+)
+
+// CreateUserPayload represents the payload required to create a ServiceTrade user.
+type CreateUserPayload struct {
+ Username string `json:"username"`
+ FirstName string `json:"firstName"`
+ LastName string `json:"lastName"`
+ Password string `json:"password"`
+ Email string `json:"email"`
+ Phone string `json:"phone,omitempty"`
+ CompanyID int `json:"companyId"`
+ LocationID int `json:"locationId"`
+ Details string `json:"details,omitempty"`
+ Status string `json:"status,omitempty"`
+ IsSales *bool `json:"isSales,omitempty"`
+ Timezone string `json:"timezone,omitempty"`
+ ServiceLineIDs []int `json:"serviceLineIds,omitempty"`
+ ManagerID *int `json:"managerId,omitempty"`
+ MFARequired *bool `json:"mfaRequired,omitempty"`
+}
+
+// UserRecord represents a simplified ServiceTrade user returned from the API.
+type UserRecord struct {
+ ID int `json:"id"`
+ Username string `json:"username"`
+ Email string `json:"email"`
+ FirstName string `json:"firstName"`
+ LastName string `json:"lastName"`
+ Status string `json:"status"`
+ Roles []Role `json:"roles,omitempty"`
+ Raw []byte `json:"-"`
+}
+
+// Role represents a ServiceTrade role.
+type Role struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Activities []string `json:"activities,omitempty"`
+ Global bool `json:"global"`
+}
+
+// UpdateUserPayload represents the fields that can be updated on an existing ServiceTrade user.
+type UpdateUserPayload struct {
+ Username *string `json:"username,omitempty"`
+ FirstName *string `json:"firstName,omitempty"`
+ LastName *string `json:"lastName,omitempty"`
+ Password *string `json:"password,omitempty"`
+ Email *string `json:"email,omitempty"`
+ Phone *string `json:"phone,omitempty"`
+ CompanyID *int `json:"companyId,omitempty"`
+ LocationID *int `json:"locationId,omitempty"`
+ Details *string `json:"details,omitempty"`
+ Status *string `json:"status,omitempty"`
+ IsSales *bool `json:"isSales,omitempty"`
+ Timezone *string `json:"timezone,omitempty"`
+ ServiceLineIDs *[]int `json:"serviceLineIds,omitempty"`
+ ManagerID *int `json:"managerId,omitempty"`
+ MFARequired *bool `json:"mfaRequired,omitempty"`
+}
+
+// CreateUser creates a new ServiceTrade user and returns the created user record.
+func (s *Session) CreateUser(payload CreateUserPayload) (*UserRecord, error) {
+ bodyBytes, err := json.Marshal(payload)
+ if err != nil {
+ return nil, fmt.Errorf("error marshalling user payload: %w", err)
+ }
+
+ resp, err := s.DoRequest(http.MethodPost, "/user", bytes.NewBuffer(bodyBytes))
+ if err != nil {
+ return nil, fmt.Errorf("error sending create user request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ responseBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("error reading create user response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+ return nil, fmt.Errorf("create user failed: %s - %s", resp.Status, string(responseBody))
+ }
+
+ user, err := parseUserFromResponse(responseBody)
+ if err != nil {
+ return nil, fmt.Errorf("unable to parse create user response: %w", err)
+ }
+ user.Raw = responseBody
+ return user, nil
+}
+
+// AssignRoleToUser assigns a role to a ServiceTrade user.
+func (s *Session) AssignRoleToUser(userID, roleID int) error {
+ endpoint := fmt.Sprintf("/user/%d/role/%d", userID, roleID)
+ resp, err := s.DoRequest(http.MethodPost, endpoint, nil)
+ if err != nil {
+ return fmt.Errorf("error assigning role %d to user %d: %w", roleID, userID, err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("assign role failed: %s - %s", resp.Status, string(body))
+ }
+
+ return nil
+}
+
+// ListRoles retrieves all ServiceTrade roles visible to the authenticated account.
+func (s *Session) ListRoles() ([]Role, error) {
+ resp, err := s.DoRequest(http.MethodGet, "/role", nil)
+ if err != nil {
+ return nil, fmt.Errorf("error requesting roles: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("error reading roles response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("list roles failed: %s - %s", resp.Status, string(body))
+ }
+
+ roles, err := parseRolesFromResponse(body)
+ if err != nil {
+ return nil, err
+ }
+
+ return roles, nil
+}
+
+// UpdateUser updates an existing ServiceTrade user with the provided fields.
+func (s *Session) UpdateUser(userID int, payload UpdateUserPayload) (*UserRecord, error) {
+ bodyBytes, err := json.Marshal(payload)
+ if err != nil {
+ return nil, fmt.Errorf("error marshalling user update payload: %w", err)
+ }
+
+ endpoint := fmt.Sprintf("/user/%d", userID)
+ resp, err := s.DoRequest(http.MethodPut, endpoint, bytes.NewBuffer(bodyBytes))
+ if err != nil {
+ return nil, fmt.Errorf("error sending update user request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ responseBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("error reading update user response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("update user failed: %s - %s", resp.Status, string(responseBody))
+ }
+
+ user, err := parseUserFromResponse(responseBody)
+ if err != nil {
+ return nil, fmt.Errorf("unable to parse update user response: %w", err)
+ }
+ user.Raw = responseBody
+ return user, nil
+}
+
+// FindUserByUsername searches for a user by username. Returns an error if not exactly one match is found.
+func (s *Session) FindUserByUsername(username string) (*UserRecord, error) {
+ if strings.TrimSpace(username) == "" {
+ return nil, fmt.Errorf("username is required")
+ }
+
+ query := url.Values{}
+ query.Set("search", username)
+ query.Set("searchFields", "username")
+ query.Set("status", "active,inactive,pending")
+
+ endpoint := fmt.Sprintf("/user?%s", query.Encode())
+ resp, err := s.DoRequest(http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, fmt.Errorf("error searching for user %q: %w", username, err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("error reading user search response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("user search failed: %s - %s", resp.Status, string(body))
+ }
+
+ users, err := parseUsersFromResponse(body)
+ if err != nil {
+ return nil, fmt.Errorf("error parsing user search response: %w", err)
+ }
+
+ var matches []*UserRecord
+ for i := range users {
+ if strings.EqualFold(users[i].Username, username) {
+ matches = append(matches, &users[i])
+ }
+ }
+
+ if len(matches) == 0 {
+ return nil, fmt.Errorf("no user found with username %q", username)
+ }
+ if len(matches) > 1 {
+ return nil, fmt.Errorf("multiple users found with username %q", username)
+ }
+
+ return matches[0], nil
+}
+
+func parseUserFromResponse(body []byte) (*UserRecord, error) {
+ var root map[string]interface{}
+ if err := json.Unmarshal(body, &root); err != nil {
+ return nil, fmt.Errorf("error decoding user response: %w", err)
+ }
+
+ candidate := extractMap(root, "data", "user")
+ if candidate == nil {
+ candidate = extractFirstMapFromArray(root, "data", "users")
+ }
+ if candidate == nil {
+ if userVal, ok := root["user"].(map[string]interface{}); ok {
+ candidate = userVal
+ }
+ }
+ if candidate == nil {
+ if dataVal, ok := root["data"].(map[string]interface{}); ok {
+ candidate = dataVal
+ }
+ }
+ if candidate == nil {
+ candidate = root
+ }
+
+ return buildUserRecord(candidate)
+}
+
+func parseUsersFromResponse(body []byte) ([]UserRecord, error) {
+ var root map[string]interface{}
+ if err := json.Unmarshal(body, &root); err != nil {
+ return nil, fmt.Errorf("error decoding users response: %w", err)
+ }
+
+ var records []interface{}
+ if dataMap, ok := root["data"].(map[string]interface{}); ok {
+ if list, ok := dataMap["users"].([]interface{}); ok {
+ records = list
+ } else if list, ok := dataMap["records"].([]interface{}); ok {
+ records = list
+ }
+ }
+ if records == nil {
+ if list, ok := root["users"].([]interface{}); ok {
+ records = list
+ }
+ }
+ if records == nil {
+ return []UserRecord{}, nil
+ }
+
+ results := make([]UserRecord, 0, len(records))
+ for _, item := range records {
+ userMap, ok := item.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ user, err := buildUserRecord(userMap)
+ if err != nil {
+ return nil, err
+ }
+ results = append(results, *user)
+ }
+ return results, nil
+}
+
+func parseRolesFromResponse(body []byte) ([]Role, error) {
+ var root map[string]interface{}
+ if err := json.Unmarshal(body, &root); err != nil {
+ return nil, fmt.Errorf("error decoding roles response: %w", err)
+ }
+
+ var rolesList []interface{}
+ if dataMap, ok := root["data"].(map[string]interface{}); ok {
+ if list, ok := dataMap["roles"].([]interface{}); ok {
+ rolesList = list
+ } else if list, ok := dataMap["records"].([]interface{}); ok {
+ rolesList = list
+ }
+ }
+ if rolesList == nil {
+ if list, ok := root["roles"].([]interface{}); ok {
+ rolesList = list
+ }
+ }
+ if rolesList == nil {
+ return nil, fmt.Errorf("roles not found in response")
+ }
+
+ return convertRoles(rolesList), nil
+}
+
+func extractMap(root map[string]interface{}, keys ...string) map[string]interface{} {
+ current := root
+ for _, key := range keys {
+ val, ok := current[key]
+ if !ok {
+ return nil
+ }
+ if nextMap, ok := val.(map[string]interface{}); ok {
+ current = nextMap
+ } else {
+ return nil
+ }
+ }
+ return current
+}
+
+func extractFirstMapFromArray(root map[string]interface{}, keys ...string) map[string]interface{} {
+ if len(keys) == 0 {
+ return nil
+ }
+ lastKey := keys[len(keys)-1]
+ parent := extractMap(root, keys[:len(keys)-1]...)
+ if parent == nil {
+ return nil
+ }
+ val, ok := parent[lastKey]
+ if !ok {
+ return nil
+ }
+ arr, ok := val.([]interface{})
+ if !ok || len(arr) == 0 {
+ return nil
+ }
+ first, ok := arr[0].(map[string]interface{})
+ if !ok {
+ return nil
+ }
+ return first
+}
+
+func convertRoles(items []interface{}) []Role {
+ var roles []Role
+ for _, item := range items {
+ roleMap, ok := item.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ role := Role{
+ ID: toInt(roleMap["id"]),
+ Name: toString(roleMap["name"]),
+ Description: toString(roleMap["description"]),
+ Global: toBool(roleMap["global"]),
+ Activities: toStringSlice(roleMap["activities"]),
+ }
+ roles = append(roles, role)
+ }
+ return roles
+}
+
+func toInt(value interface{}) int {
+ switch v := value.(type) {
+ case int:
+ return v
+ case int64:
+ return int(v)
+ case float64:
+ return int(v)
+ case json.Number:
+ i, _ := v.Int64()
+ return int(i)
+ case string:
+ if v == "" {
+ return 0
+ }
+ i, err := strconv.Atoi(strings.TrimSpace(v))
+ if err != nil {
+ return 0
+ }
+ return i
+ default:
+ return 0
+ }
+}
+
+func toString(value interface{}) string {
+ switch v := value.(type) {
+ case string:
+ return v
+ case json.Number:
+ return v.String()
+ case fmt.Stringer:
+ return v.String()
+ case int:
+ return strconv.Itoa(v)
+ case int64:
+ return strconv.FormatInt(v, 10)
+ case float64:
+ return strconv.FormatFloat(v, 'f', -1, 64)
+ default:
+ return ""
+ }
+}
+
+func toBool(value interface{}) bool {
+ switch v := value.(type) {
+ case bool:
+ return v
+ case string:
+ switch strings.ToLower(strings.TrimSpace(v)) {
+ case "true", "1", "yes", "y":
+ return true
+ case "false", "0", "no", "n":
+ return false
+ }
+ case float64:
+ return v != 0
+ case int:
+ return v != 0
+ case json.Number:
+ i, err := v.Int64()
+ if err == nil {
+ return i != 0
+ }
+ }
+ return false
+}
+
+func toStringSlice(value interface{}) []string {
+ switch v := value.(type) {
+ case []string:
+ return v
+ case []interface{}:
+ result := make([]string, 0, len(v))
+ for _, item := range v {
+ result = append(result, toString(item))
+ }
+ return result
+ default:
+ return nil
+ }
+}
+
+func buildUserRecord(candidate map[string]interface{}) (*UserRecord, error) {
+ if candidate == nil {
+ return nil, fmt.Errorf("user data missing in response")
+ }
+
+ user := &UserRecord{
+ ID: toInt(candidate["id"]),
+ Username: toString(candidate["username"]),
+ Email: toString(candidate["email"]),
+ FirstName: toString(candidate["firstName"]),
+ LastName: toString(candidate["lastName"]),
+ Status: toString(candidate["status"]),
+ }
+
+ if user.ID == 0 {
+ return nil, fmt.Errorf("user ID missing in response")
+ }
+
+ if rolesVal, ok := candidate["roles"].([]interface{}); ok {
+ user.Roles = convertRoles(rolesVal)
+ }
+
+ return user, nil
+}
diff --git a/internal/handlers/web/invoice_clock_report.go b/internal/handlers/web/invoice_clock_report.go
index 62f9989..11bf8e6 100644
--- a/internal/handlers/web/invoice_clock_report.go
+++ b/internal/handlers/web/invoice_clock_report.go
@@ -4,30 +4,28 @@ import (
"bytes"
"encoding/csv"
"fmt"
+ "log"
"net/http"
- "sort"
"strconv"
"strings"
+ "sync"
"time"
+ "github.com/xuri/excelize/v2"
+
"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",
+var reportHeader = []string{
+ "Customer PO",
+ "id",
+ "Link",
+ "Clock In",
+ "Clock Out",
}
-// InvoiceClockReportHandler processes uploaded CSVs of invoice numbers and returns a clock-event report.
+// InvoiceClockReportHandler processes a CSV upload and returns a CSV matching the requested template.
func InvoiceClockReportHandler(w http.ResponseWriter, r *http.Request) {
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session)
if !ok {
@@ -59,272 +57,719 @@ func InvoiceClockReportHandler(w http.ResponseWriter, r *http.Request) {
return
}
- if len(records) == 0 {
- http.Error(w, "CSV file is empty", http.StatusBadRequest)
+ if len(records) < 2 {
+ http.Error(w, "CSV must include a header row and at least one data row", http.StatusBadRequest)
return
}
- invoiceNumbers := extractInvoiceNumbers(records)
- if len(invoiceNumbers) == 0 {
- http.Error(w, "No invoice numbers found in CSV", http.StatusBadRequest)
+ header := records[0]
+ columns := detectInputColumns(header)
+
+ if columns.jobID == -1 && columns.invoice == -1 {
+ http.Error(w, "Input must include a job id column or an invoice number column", http.StatusBadRequest)
return
}
- var buffer bytes.Buffer
- writer := csv.NewWriter(&buffer)
+ log.Printf("Invoice clock report: detected columns jobID=%d invoice=%d customerPO=%d",
+ columns.jobID, columns.invoice, columns.customerPO)
- if err := writer.Write(invoiceClockReportHeader); err != nil {
- http.Error(w, fmt.Sprintf("Error writing CSV header: %v", err), http.StatusInternalServerError)
- return
+ tasks := make([]rowTask, 0, len(records)-1)
+ for rowIdx := 1; rowIdx < len(records); rowIdx++ {
+ row := ensureRowLength(records[rowIdx], len(header))
+ task := rowTask{
+ index: rowIdx - 1,
+ rawID: cleanIdentifier(getRowValue(row, columns.jobID)),
+ invoiceNumber: strings.TrimSpace(getRowValue(row, columns.invoice)),
+ customerPO: strings.TrimSpace(getRowValue(row, columns.customerPO)),
+ }
+ tasks = append(tasks, task)
+ }
+
+ f := excelize.NewFile()
+ sheet := f.GetSheetName(0)
+ f.SetSheetName(sheet, "Report")
+ sheet = "Report"
+
+ headerStyle, _ := f.NewStyle(&excelize.Style{
+ Font: &excelize.Font{Bold: true, Size: 12},
+ Alignment: &excelize.Alignment{
+ Horizontal: "center",
+ Vertical: "center",
+ WrapText: true,
+ },
+ })
+ textStyle, _ := f.NewStyle(&excelize.Style{
+ NumFmt: 49, // treat as text
+ Alignment: &excelize.Alignment{
+ Vertical: "top",
+ WrapText: true,
+ },
+ })
+ bodyWrapStyle, _ := f.NewStyle(&excelize.Style{
+ Alignment: &excelize.Alignment{
+ Vertical: "top",
+ WrapText: true,
+ },
+ })
+
+ for idx, title := range reportHeader {
+ cell, _ := excelize.CoordinatesToCellName(idx+1, 1)
+ if err := f.SetCellStr(sheet, cell, title); err != nil {
+ http.Error(w, fmt.Sprintf("Error writing header cell: %v", err), http.StatusInternalServerError)
+ return
+ }
+ if err := f.SetCellStyle(sheet, cell, cell, headerStyle); err != nil {
+ http.Error(w, fmt.Sprintf("Error styling header cell: %v", err), http.StatusInternalServerError)
+ return
+ }
+ }
+
+ if err := f.SetPanes(sheet, &excelize.Panes{
+ Freeze: true,
+ YSplit: 1,
+ TopLeftCell: "A2",
+ ActivePane: "bottomLeft",
+ }); err != nil {
+ log.Printf("Warning: unable to freeze header row: %v", err)
+ }
+
+ if err := f.SetColWidth(sheet, "A", "A", 25); err != nil {
+ log.Printf("Warning: unable to set column width: %v", err)
+ }
+ if err := f.SetColWidth(sheet, "B", "C", 40); err != nil {
+ log.Printf("Warning: unable to set column width: %v", err)
+ }
+ if err := f.SetColWidth(sheet, "D", "E", 28); err != nil {
+ log.Printf("Warning: unable to set column width: %v", err)
}
- 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)
+ if len(tasks) == 0 {
+ var buffer bytes.Buffer
+ if err := f.Write(&buffer); err != nil {
+ http.Error(w, fmt.Sprintf("Error finalizing XLSX: %v", err), http.StatusInternalServerError)
return
}
+ if err := f.Close(); err != nil {
+ log.Printf("Warning: error closing workbook: %v", err)
+ }
+
+ filename := fmt.Sprintf("invoice-clock-report-%s.xlsx", time.Now().Format("20060102-150405"))
+ w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
+ w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
+ w.Header().Set("Cache-Control", "no-store")
+ w.Write(buffer.Bytes())
+ return
+ }
+
+ limiter := newRateLimiter(5)
+ defer limiter.Stop()
+
+ ctx := &workerContext{
+ session: session,
+ caches: newSharedCaches(),
+ limiter: limiter,
+ lookup: newLookupState(5),
+ }
+
+ workerCount := 6
+ if len(tasks) < workerCount {
+ workerCount = len(tasks)
+ }
+ if workerCount < 1 {
+ workerCount = 1
}
- writer.Flush()
- if err := writer.Error(); err != nil {
- http.Error(w, fmt.Sprintf("Error finalizing CSV: %v", err), http.StatusInternalServerError)
+ jobsCh := make(chan rowTask)
+ resultsCh := make(chan rowResult, workerCount)
+
+ var wg sync.WaitGroup
+ for i := 0; i < workerCount; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for task := range jobsCh {
+ resultsCh <- processRow(task, ctx)
+ }
+ }()
+ }
+
+ go func() {
+ for _, task := range tasks {
+ jobsCh <- task
+ }
+ close(jobsCh)
+ }()
+
+ go func() {
+ wg.Wait()
+ close(resultsCh)
+ }()
+
+ pending := make(map[int]rowResult)
+ nextIndex := 0
+ var writeErr error
+
+ for res := range resultsCh {
+ if writeErr != nil {
+ continue
+ }
+ pending[res.index] = res
+ for {
+ data, ok := pending[nextIndex]
+ if !ok {
+ break
+ }
+ if err := writeExcelRow(f, sheet, data, nextIndex, textStyle, bodyWrapStyle); err != nil {
+ writeErr = err
+ break
+ }
+ delete(pending, nextIndex)
+ nextIndex++
+ }
+ }
+
+ if writeErr != nil {
+ http.Error(w, fmt.Sprintf("Error writing XLSX: %v", writeErr), http.StatusInternalServerError)
return
}
- filename := fmt.Sprintf("invoice-clock-report-%s.csv", time.Now().Format("20060102-150405"))
- w.Header().Set("Content-Type", "text/csv")
+ var buffer bytes.Buffer
+ if err := f.Write(&buffer); err != nil {
+ http.Error(w, fmt.Sprintf("Error finalizing XLSX: %v", err), http.StatusInternalServerError)
+ return
+ }
+ if err := f.Close(); err != nil {
+ log.Printf("Warning: error closing workbook: %v", err)
+ }
+
+ filename := fmt.Sprintf("invoice-clock-report-%s.xlsx", time.Now().Format("20060102-150405"))
+ w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
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
+type inputColumns struct {
+ jobID int
+ invoice int
+ customerPO int
+}
+
+func detectInputColumns(headers []string) inputColumns {
+ var cols inputColumns
+ cols.jobID = -1
+ cols.invoice = -1
+ cols.customerPO = -1
+
+ normalized := make([]string, len(headers))
+ original := make([]string, len(headers))
+ for i, header := range headers {
+ normalized[i] = normalizeHeaderValue(header)
+ original[i] = strings.ToLower(strings.TrimSpace(header))
}
- headers := make([]string, len(records[0]))
- for i, header := range records[0] {
- headers[i] = strings.ToLower(strings.TrimSpace(header))
+ jobPriority := []string{"id", "jobid", "job_id"}
+ for _, candidate := range jobPriority {
+ if cols.jobID != -1 {
+ break
+ }
+ for i, name := range normalized {
+ if name == candidate {
+ cols.jobID = i
+ break
+ }
+ }
}
- columnIndex := 0
- hasHeader := false
- for i, header := range headers {
- if header == "invoice_number" || header == "invoicenumber" || header == "invoice" {
- columnIndex = i
- hasHeader = true
+ if cols.jobID == -1 {
+ for i, name := range normalized {
+ if strings.Contains(name, "job") && strings.Contains(name, "id") {
+ cols.jobID = i
+ break
+ }
+ }
+ }
+
+ invoiceCandidates := []string{"invoicenumber", "invoice", "refnumber", "ref"}
+ for _, candidate := range invoiceCandidates {
+ if cols.invoice != -1 {
break
}
+ for i, name := range normalized {
+ if name == candidate {
+ cols.invoice = i
+ break
+ }
+ }
}
- var numbers []string
- startRow := 0
- if hasHeader {
- startRow = 1
+ customerExact := []string{"customer_po", "customerpo", "customer purchase order", "customer_po_number"}
+ for _, candidate := range customerExact {
+ if cols.customerPO != -1 {
+ break
+ }
+ for i, name := range original {
+ if name == candidate {
+ cols.customerPO = i
+ break
+ }
+ }
}
- for _, row := range records[startRow:] {
- if columnIndex >= len(row) {
- continue
+
+ if cols.customerPO == -1 {
+ for i, name := range normalized {
+ if strings.Contains(name, "customerpo") && !strings.Contains(name, "postal") {
+ cols.customerPO = i
+ break
+ }
}
- number := strings.TrimSpace(row[columnIndex])
- if number != "" {
- numbers = append(numbers, number)
+ }
+
+ if cols.customerPO == -1 && cols.invoice != -1 {
+ invoiceHeader := normalized[cols.invoice]
+ if strings.Contains(invoiceHeader, "customerpo") {
+ cols.customerPO = cols.invoice
}
}
- return numbers
+ return cols
}
-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
+func normalizeHeaderValue(header string) string {
+ h := strings.ToLower(strings.TrimSpace(header))
+ replacements := []string{" ", "_", "-", "."}
+ for _, repl := range replacements {
+ h = strings.ReplaceAll(h, repl, "")
}
- if invoice == nil {
- row[len(row)-1] = "invoice not found"
+ return h
+}
+
+func ensureRowLength(row []string, length int) []string {
+ if len(row) >= length {
return row
}
+ res := make([]string, length)
+ copy(res, row)
+ return res
+}
- invoiceID := valueToString(invoice["id"])
- row[1] = invoiceID
+func getRowValue(row []string, index int) string {
+ if index < 0 || index >= len(row) {
+ return ""
+ }
+ return row[index]
+}
- jobInfo, ok := invoice["job"].(map[string]interface{})
- if !ok {
- row[len(row)-1] = "invoice missing job information"
- return row
+func cleanIdentifier(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return ""
+ }
+ if strings.HasPrefix(value, "'") {
+ value = strings.TrimPrefix(value, "'")
}
+ if dot := strings.IndexRune(value, '.'); dot > 0 {
+ if decimalPart := value[dot+1:]; len(decimalPart) == 0 || strings.Trim(decimalPart, "0") == "" {
+ return value[:dot]
+ }
+ }
+ return value
+}
- jobID := valueToString(jobInfo["id"])
- jobNumber := valueToString(jobInfo["number"])
- row[2] = jobID
- row[3] = jobNumber
+type rowTask struct {
+ index int
+ rawID string
+ invoiceNumber string
+ customerPO string
+}
- var errors []string
+type rowResult struct {
+ index int
+ customerPO string
+ jobID string
+ link string
+ clockIn string
+ clockOut string
+}
- jobDetails, err := session.GetJobDetails(jobID)
- if err != nil {
- errors = append(errors, fmt.Sprintf("job details error: %v", err))
+type workerContext struct {
+ session *api.Session
+ caches *sharedCaches
+ limiter *rateLimiter
+ lookup *lookupState
+}
+
+type sharedCaches struct {
+ mu sync.RWMutex
+ invoices map[string]map[string]interface{}
+ clocks map[string]clockSummary
+}
+
+func newSharedCaches() *sharedCaches {
+ return &sharedCaches{
+ invoices: make(map[string]map[string]interface{}),
+ clocks: make(map[string]clockSummary),
}
+}
- clockData, err := session.GetJobClockEvents(jobID)
- if err != nil {
- errors = append(errors, fmt.Sprintf("clock events error: %v", err))
+func (c *sharedCaches) getInvoice(key string) (map[string]interface{}, bool) {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ inv, ok := c.invoices[key]
+ return inv, ok
+}
+
+func (c *sharedCaches) setInvoice(key string, invoice map[string]interface{}) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.invoices[key] = invoice
+}
+
+func (c *sharedCaches) getClock(jobID string) (clockSummary, bool) {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ summary, ok := c.clocks[jobID]
+ return summary, ok
+}
+
+func (c *sharedCaches) setClock(jobID string, summary clockSummary) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.clocks[jobID] = summary
+}
+
+type lookupState struct {
+ mu sync.Mutex
+ jobIDEnabled bool
+ invoiceIDEnabled bool
+ jobIDFailures int
+ invoiceIDFailures int
+ threshold int
+ jobIDDisabledLogged bool
+ invoiceIDDisabledLogged bool
+}
+
+func newLookupState(threshold int) *lookupState {
+ return &lookupState{
+ jobIDEnabled: true,
+ invoiceIDEnabled: true,
+ threshold: threshold,
}
+}
- techNames := collectTechNames(jobDetails, clockData)
+func (s *lookupState) shouldTryJobID() bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.jobIDEnabled
+}
- vendorName := extractVendorName(jobDetails, invoice)
- assignmentType := determineAssignmentType(techNames, vendorName)
+func (s *lookupState) recordJobIDResult(success bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if success {
+ s.jobIDFailures = 0
+ if !s.jobIDEnabled {
+ s.jobIDEnabled = true
+ s.jobIDDisabledLogged = false
+ }
+ return
+ }
+ if !s.jobIDEnabled {
+ return
+ }
+ s.jobIDFailures++
+ if s.jobIDFailures >= s.threshold {
+ s.jobIDEnabled = false
+ if !s.jobIDDisabledLogged {
+ log.Printf("Disabling job ID lookups after %d consecutive failures", s.jobIDFailures)
+ s.jobIDDisabledLogged = true
+ }
+ }
+}
- row[4] = assignmentType
- row[5] = vendorName
- row[6] = strings.Join(techNames, ", ")
+func (s *lookupState) shouldTryInvoiceID() bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.invoiceIDEnabled
+}
- if clockData != nil {
- row[7] = formatActivityEvents(clockData.PairedEvents, "enroute")
- row[8] = formatActivityEvents(clockData.PairedEvents, "onsite")
+func (s *lookupState) recordInvoiceIDResult(success bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if success {
+ s.invoiceIDFailures = 0
+ if !s.invoiceIDEnabled {
+ s.invoiceIDEnabled = true
+ s.invoiceIDDisabledLogged = false
+ }
+ return
}
+ if !s.invoiceIDEnabled {
+ return
+ }
+ s.invoiceIDFailures++
+ if s.invoiceIDFailures >= s.threshold {
+ s.invoiceIDEnabled = false
+ if !s.invoiceIDDisabledLogged {
+ log.Printf("Disabling invoice ID lookups after %d consecutive failures", s.invoiceIDFailures)
+ s.invoiceIDDisabledLogged = true
+ }
+ }
+}
+
+type rateLimiter struct {
+ ticker *time.Ticker
+}
- if len(errors) > 0 {
- row[len(row)-1] = strings.Join(errors, "; ")
+func newRateLimiter(rps int) *rateLimiter {
+ if rps <= 0 {
+ rps = 5
}
+ interval := time.Second / time.Duration(rps)
+ return &rateLimiter{ticker: time.NewTicker(interval)}
+}
- return row
+func (rl *rateLimiter) Wait() {
+ if rl == nil || rl.ticker == nil {
+ return
+ }
+ <-rl.ticker.C
}
-func collectTechNames(jobDetails map[string]interface{}, clockData *api.ClockEventData) []string {
- seen := map[string]struct{}{}
+func (rl *rateLimiter) Stop() {
+ if rl == nil || rl.ticker == nil {
+ return
+ }
+ rl.ticker.Stop()
+}
- 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{}{}
- }
- }
- }
- }
+func processRow(task rowTask, ctx *workerContext) rowResult {
+ result := rowResult{
+ index: task.index,
+ customerPO: strings.TrimSpace(task.customerPO),
+ }
+
+ rawID := strings.TrimSpace(task.rawID)
+ invoiceNumber := strings.TrimSpace(task.invoiceNumber)
+ displayRow := task.index + 1
+
+ if rawID != "" {
+ if ctx.lookup.shouldTryJobID() {
+ log.Printf("Row %d: attempting job clock lookup for id=%s", displayRow, rawID)
+ summary, err := fetchClockSummary(ctx.session, rawID, ctx.caches, ctx.limiter)
+ if err == nil {
+ ctx.lookup.recordJobIDResult(true)
+ result.jobID = rawID
+ result.link = buildJobLink(rawID)
+ result.clockIn = summary.ClockIn
+ result.clockOut = summary.ClockOut
+ finalizeCustomerPO(&result, invoiceNumber, rawID)
+ return result
}
+ log.Printf("Row %d: clock lookup failed for job %s: %v", displayRow, rawID, err)
+ ctx.lookup.recordJobIDResult(false)
+ } else {
+ log.Printf("Row %d: skipping job id lookup (disabled)", displayRow)
}
- 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{}{}
- }
- }
+ }
+
+ var invoiceData map[string]interface{}
+ if rawID != "" {
+ if ctx.lookup.shouldTryInvoiceID() {
+ log.Printf("Row %d: falling back to invoice id %s", displayRow, rawID)
+ inv, err := fetchInvoice(ctx.session, rawID, ctx.caches, ctx.limiter)
+ if err == nil && inv != nil {
+ ctx.lookup.recordInvoiceIDResult(true)
+ invoiceData = inv
+ } else {
+ if err != nil {
+ log.Printf("Invoice lookup failed for id %s (row %d): %v", rawID, displayRow, err)
}
+ ctx.lookup.recordInvoiceIDResult(false)
}
+ } else {
+ log.Printf("Row %d: skipping invoice id lookup (disabled)", displayRow)
}
}
- 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 invoiceData == nil && invoiceNumber != "" {
+ log.Printf("Row %d: falling back to invoice number %s", displayRow, invoiceNumber)
+ inv, err := fetchInvoice(ctx.session, invoiceNumber, ctx.caches, ctx.limiter)
+ if err != nil {
+ log.Printf("Invoice lookup failed for number %s (row %d): %v", invoiceNumber, displayRow, err)
+ } else if inv != nil {
+ invoiceData = inv
}
}
- if len(seen) == 0 {
- return nil
+ if invoiceData != nil {
+ applyInvoiceFields(&result, invoiceData)
+ jobFromInvoice := extractJobIDFromInvoice(invoiceData)
+ if jobFromInvoice != "" {
+ summary, err := fetchClockSummary(ctx.session, jobFromInvoice, ctx.caches, ctx.limiter)
+ if err != nil {
+ log.Printf("Row %d: clock lookup failed for invoice-derived job %s: %v", displayRow, jobFromInvoice, err)
+ } else {
+ result.clockIn = summary.ClockIn
+ result.clockOut = summary.ClockOut
+ }
+ result.jobID = jobFromInvoice
+ result.link = buildJobLink(jobFromInvoice)
+ }
}
- var names []string
- for name := range seen {
- names = append(names, name)
+ if result.jobID == "" {
+ log.Printf("Row %d: no job id could be determined; leaving link blank", displayRow)
}
- sort.Strings(names)
- return names
+
+ finalizeCustomerPO(&result, invoiceNumber, rawID)
+ return result
}
-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
- }
+func applyInvoiceFields(result *rowResult, invoice map[string]interface{}) {
+ if invoice == nil {
+ return
+ }
+ if result.customerPO == "" {
+ if po := strings.TrimSpace(valueToString(invoice["customerPo"])); po != "" {
+ result.customerPO = po
}
}
+}
+
+func finalizeCustomerPO(result *rowResult, invoiceNumber, rawID string) {
+ if result.customerPO != "" {
+ return
+ }
+ if invoiceNumber != "" {
+ result.customerPO = invoiceNumber
+ return
+ }
+ if rawID != "" {
+ result.customerPO = rawID
+ }
+}
- if vendor, ok := invoice["vendor"].(map[string]interface{}); ok {
- if name := strings.TrimSpace(valueToString(vendor["name"])); name != "" {
- return name
+func buildJobLink(jobID string) string {
+ if jobID == "" {
+ return ""
+ }
+ return fmt.Sprintf("https://app.servicetrade.com/jobs/%s", jobID)
+}
+
+func writeExcelRow(f *excelize.File, sheet string, result rowResult, index int, textStyle, bodyStyle int) error {
+ excelRow := index + 2
+ values := []string{
+ result.customerPO,
+ result.jobID,
+ result.link,
+ result.clockIn,
+ result.clockOut,
+ }
+
+ for colIdx, value := range values {
+ cell, _ := excelize.CoordinatesToCellName(colIdx+1, excelRow)
+ style := bodyStyle
+ if colIdx == 1 {
+ style = textStyle
+ }
+ if err := f.SetCellStr(sheet, cell, value); err != nil {
+ return err
+ }
+ if err := f.SetCellStyle(sheet, cell, cell, style); err != nil {
+ return err
+ }
+ if colIdx == 2 && value != "" {
+ if err := f.SetCellHyperLink(sheet, cell, value, "External"); err != nil {
+ log.Printf("Warning: unable to set hyperlink for cell %s: %v", cell, err)
+ }
}
}
+ return nil
+}
- return ""
+func fetchInvoice(session *api.Session, identifier string, caches *sharedCaches, limiter *rateLimiter) (map[string]interface{}, error) {
+ if identifier == "" {
+ return nil, nil
+ }
+ if invoice, ok := caches.getInvoice(identifier); ok {
+ return invoice, nil
+ }
+ if limiter != nil {
+ limiter.Wait()
+ }
+ invoice, err := session.GetInvoice(identifier)
+ if err != nil {
+ return nil, err
+ }
+ if invoice != nil {
+ caches.setInvoice(identifier, invoice)
+ }
+ return invoice, nil
}
-func determineAssignmentType(techNames []string, vendorName string) string {
- if len(techNames) > 0 {
- return "Technician"
+func extractJobIDFromInvoice(invoice map[string]interface{}) string {
+ if invoice == nil {
+ return ""
}
- if vendorName != "" {
- return "Vendor"
+ jobInfo, ok := invoice["job"].(map[string]interface{})
+ if !ok {
+ return ""
}
- return "Unknown"
+ return cleanIdentifier(valueToString(jobInfo["id"]))
}
-func formatActivityEvents(events []api.ClockPairedEvent, activity string) string {
- var parts []string
- activityLower := strings.ToLower(activity)
+type clockSummary struct {
+ ClockIn string
+ ClockOut string
+}
- for _, e := range events {
- if strings.ToLower(e.Start.Activity) != activityLower {
- continue
- }
+func fetchClockSummary(session *api.Session, jobID string, caches *sharedCaches, limiter *rateLimiter) (clockSummary, error) {
+ if jobID == "" {
+ return clockSummary{}, nil
+ }
+ if summary, ok := caches.getClock(jobID); ok {
+ return summary, nil
+ }
- tech := strings.TrimSpace(e.Start.User.Name)
- if tech == "" {
- tech = "Unknown Tech"
- }
+ if limiter != nil {
+ limiter.Wait()
+ }
+ data, err := session.GetJobClockEvents(jobID)
+ if err != nil {
+ return clockSummary{}, err
+ }
- 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()
- }
+ var earliestStart int64
+ var latestEnd int64
- detail := fmt.Sprintf("%s: %s -> %s", tech, start, end)
- if durationText != "" {
- detail = fmt.Sprintf("%s (%s)", detail, durationText)
+ for _, event := range data.PairedEvents {
+ if event.Start.EventTime != 0 {
+ if earliestStart == 0 || event.Start.EventTime < earliestStart {
+ earliestStart = event.Start.EventTime
+ }
+ }
+ if event.End.EventTime != 0 {
+ if event.End.EventTime > latestEnd {
+ latestEnd = event.End.EventTime
+ }
}
-
- parts = append(parts, detail)
}
- return strings.Join(parts, " | ")
+ summary := clockSummary{
+ ClockIn: formatTimestampPlain(earliestStart),
+ ClockOut: formatTimestampPlain(latestEnd),
+ }
+ caches.setClock(jobID, summary)
+ return summary, nil
}
-func formatTimestamp(epoch int64) string {
+func formatTimestampPlain(epoch int64) string {
if epoch == 0 {
return ""
}
- return time.Unix(epoch, 0).UTC().Format(time.RFC3339)
+ local := time.Unix(epoch, 0).In(time.Local)
+ return local.Format("2006-01-02 15:04:05 MST")
}
func valueToString(value interface{}) string {
diff --git a/internal/handlers/web/users.go b/internal/handlers/web/users.go
index 1f6d49b..63d8afe 100644
--- a/internal/handlers/web/users.go
+++ b/internal/handlers/web/users.go
@@ -1,18 +1,872 @@
package web
import (
- root "marmic/servicetrade-toolbox"
+ "bytes"
+ "encoding/csv"
+ "fmt"
+ "html/template"
+ "log"
"net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ root "marmic/servicetrade-toolbox"
+ "marmic/servicetrade-toolbox/internal/api"
+ "marmic/servicetrade-toolbox/internal/middleware"
+
+ "github.com/gorilla/csrf"
)
+// RoleAssignmentResult captures the outcome of assigning a role to a user.
+type RoleAssignmentResult struct {
+ Token string
+ RoleID int
+ Role string
+ Success bool
+ Message string
+}
+
+// UserImportResult represents the processing result for a single CSV row.
+type UserImportResult struct {
+ Row int
+ Username string
+ Email string
+ UserID int
+ Created bool
+ Error string
+ RoleAssignments []RoleAssignmentResult
+ ProcessingTime time.Duration
+ ServiceLineIDs []int
+ AdditionalFields map[string]string
+}
+
+// UserImportSummary aggregates statistics about a CSV import run.
+type UserImportSummary struct {
+ TotalRows int
+ UsersCreated int
+ RowsFailed int
+ RoleAssignments int
+ RoleAssignmentErrors int
+ ProcessedFilename string
+ ProcessedAt time.Time
+}
+
+// UserUpdateResult represents the processing result for a single update row.
+type UserUpdateResult struct {
+ Row int
+ Username string
+ Email string
+ UserID int
+ Updated bool
+ Error string
+ UpdatedFields []string
+ LookupMethod string
+ RoleAssignments []RoleAssignmentResult
+ ProcessingTime time.Duration
+}
+
+// UserUpdateSummary aggregates statistics about a CSV update run.
+type UserUpdateSummary struct {
+ TotalRows int
+ UsersUpdated int
+ RowsFailed int
+ RoleAssignments int
+ RoleAssignmentErrors int
+ LookupsByUsername int
+ LookupsByID int
+ ProcessedFilename string
+ ProcessedAt time.Time
+}
+
+// UsersHandler renders the user management page.
func UsersHandler(w http.ResponseWriter, r *http.Request) {
+ session, ok := r.Context().Value(middleware.SessionKey).(*api.Session)
+ if !ok {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ data := baseUsersPageData(r)
+ data["Title"] = "Users"
+ data["Session"] = session
+
+ if roles, err := session.ListRoles(); err != nil {
+ log.Printf("UsersHandler: unable to load roles: %v", err)
+ data["RolesError"] = err.Error()
+ } else {
+ data["Roles"] = roles
+ }
+
+ renderUsersPage(w, r, data)
+}
+
+// UsersUploadHandler processes uploaded CSV files to create users and assign roles.
+func UsersUploadHandler(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+ session, ok := r.Context().Value(middleware.SessionKey).(*api.Session)
+ if !ok {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if err := r.ParseMultipartForm(10 << 20); err != nil {
+ http.Error(w, fmt.Sprintf("Unable to parse upload: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ file, header, err := r.FormFile("csvFile")
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Unable to read file: %v", err), http.StatusBadRequest)
+ return
+ }
+ defer file.Close()
+
+ reader := csv.NewReader(file)
+ reader.FieldsPerRecord = -1
+ reader.TrimLeadingSpace = true
+ rows, err := reader.ReadAll()
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Unable to read CSV: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ data := baseUsersPageData(r)
+ data["Title"] = "Users"
+ data["Session"] = session
+
+ if len(rows) < 2 {
+ data["FlashError"] = "CSV must include a header row and at least one data row."
+ renderUsersPage(w, r, data)
+ return
+ }
+
+ headerMap := buildHeaderIndex(rows[0])
+ required := []string{"username", "firstname", "lastname", "email", "password", "companyid", "locationid"}
+ if missing := missingHeaders(headerMap, required); len(missing) > 0 {
+ data["FlashError"] = fmt.Sprintf("Missing required column headers: %s", strings.Join(missing, ", "))
+ renderUsersPage(w, r, data)
+ return
+ }
+
+ roles, rolesErr := session.ListRoles()
+ if rolesErr != nil {
+ log.Printf("UsersUploadHandler: unable to load roles: %v", rolesErr)
+ data["RolesError"] = fmt.Sprintf("Unable to load roles: %v. Role names in the CSV will not resolve.", rolesErr)
+ } else {
+ data["Roles"] = roles
+ }
+ roleIndexByName := buildRoleNameIndex(roles)
+
+ results := make([]UserImportResult, 0, len(rows)-1)
+ summary := UserImportSummary{
+ TotalRows: len(rows) - 1,
+ ProcessedAt: time.Now(),
+ ProcessedFilename: header.Filename,
+ }
+
+ for rowIdx, row := range rows[1:] {
+ rowStart := time.Now()
+ result := UserImportResult{
+ Row: rowIdx + 2,
+ Username: getValue(row, headerMap, "username"),
+ Email: getValue(row, headerMap, "email"),
+ AdditionalFields: map[string]string{},
+ }
+
+ if rowIsEmpty(row) {
+ continue
+ }
+
+ if err := validateRequiredRowFields(row, headerMap, required); err != nil {
+ result.Error = err.Error()
+ result.ProcessingTime = time.Since(rowStart)
+ results = append(results, result)
+ summary.RowsFailed++
+ continue
+ }
+
+ companyID, err := strconv.Atoi(getValue(row, headerMap, "companyid"))
+ if err != nil {
+ result.Error = fmt.Sprintf("invalid companyId: %v", err)
+ result.ProcessingTime = time.Since(rowStart)
+ results = append(results, result)
+ summary.RowsFailed++
+ continue
+ }
+
+ locationID, err := strconv.Atoi(getValue(row, headerMap, "locationid"))
+ if err != nil {
+ result.Error = fmt.Sprintf("invalid locationId: %v", err)
+ result.ProcessingTime = time.Since(rowStart)
+ results = append(results, result)
+ summary.RowsFailed++
+ continue
+ }
+
+ isSales, err := parseOptionalBool(getValue(row, headerMap, "issales"))
+ if err != nil {
+ result.Error = fmt.Sprintf("invalid isSales value: %v", err)
+ result.ProcessingTime = time.Since(rowStart)
+ results = append(results, result)
+ summary.RowsFailed++
+ continue
+ }
+
+ mfaRequired, err := parseOptionalBool(getValue(row, headerMap, "mfarequired"))
+ if err != nil {
+ result.Error = fmt.Sprintf("invalid mfaRequired value: %v", err)
+ result.ProcessingTime = time.Since(rowStart)
+ results = append(results, result)
+ summary.RowsFailed++
+ continue
+ }
+
+ managerID, err := parseOptionalInt(getValue(row, headerMap, "managerid"))
+ if err != nil {
+ result.Error = fmt.Sprintf("invalid managerId value: %v", err)
+ result.ProcessingTime = time.Since(rowStart)
+ results = append(results, result)
+ summary.RowsFailed++
+ continue
+ }
+
+ serviceLineIDs, err := parseIntList(getValue(row, headerMap, "servicelineids"))
+ if err != nil {
+ result.Error = fmt.Sprintf("invalid serviceLineIds value: %v", err)
+ result.ProcessingTime = time.Since(rowStart)
+ results = append(results, result)
+ summary.RowsFailed++
+ continue
+ }
+ result.ServiceLineIDs = serviceLineIDs
+
+ payload := api.CreateUserPayload{
+ Username: result.Username,
+ FirstName: getValue(row, headerMap, "firstname"),
+ LastName: getValue(row, headerMap, "lastname"),
+ Password: getValue(row, headerMap, "password"),
+ Email: result.Email,
+ Phone: getValue(row, headerMap, "phone"),
+ CompanyID: companyID,
+ LocationID: locationID,
+ Details: getValue(row, headerMap, "details"),
+ Status: getValue(row, headerMap, "status"),
+ Timezone: getValue(row, headerMap, "timezone"),
+ ServiceLineIDs: serviceLineIDs,
+ }
+
+ if isSales != nil {
+ payload.IsSales = isSales
+ }
+ if mfaRequired != nil {
+ payload.MFARequired = mfaRequired
+ }
+ if managerID != nil {
+ payload.ManagerID = managerID
+ }
+
+ userRecord, err := session.CreateUser(payload)
+ if err != nil {
+ result.Error = fmt.Sprintf("failed to create user: %v", err)
+ result.ProcessingTime = time.Since(rowStart)
+ results = append(results, result)
+ summary.RowsFailed++
+ continue
+ }
+
+ result.Created = true
+ result.UserID = userRecord.ID
+ summary.UsersCreated++
+
+ roleTokens := parseRoleTokens(getValue(row, headerMap, "roles"))
+ if len(roleTokens) > 0 {
+ assignments := make([]RoleAssignmentResult, 0, len(roleTokens))
+ for _, token := range roleTokens {
+ assignResult := RoleAssignmentResult{Token: token}
+ role, resolved, message := resolveRoleToken(token, roleIndexByName)
+ if !resolved {
+ assignResult.Message = message
+ assignments = append(assignments, assignResult)
+ summary.RoleAssignmentErrors++
+ continue
+ }
+ assignResult.RoleID = role.ID
+ assignResult.Role = role.Name
+
+ if err := session.AssignRoleToUser(userRecord.ID, role.ID); err != nil {
+ assignResult.Message = fmt.Sprintf("failed to assign role: %v", err)
+ summary.RoleAssignmentErrors++
+ } else {
+ assignResult.Success = true
+ assignResult.Message = "assigned"
+ summary.RoleAssignments++
+ }
+ assignments = append(assignments, assignResult)
+ }
+ result.RoleAssignments = assignments
+ }
+
+ result.ProcessingTime = time.Since(rowStart)
+ results = append(results, result)
+ }
+
+ data["ImportResults"] = results
+ data["ImportSummary"] = summary
+ data["FlashSuccess"] = fmt.Sprintf("Processed %d row(s) in %s.", summary.TotalRows, time.Since(start).Round(time.Millisecond))
+
+ renderUsersPage(w, r, data)
+}
+
+// UsersUpdateHandler renders the user update page.
+func UsersUpdateHandler(w http.ResponseWriter, r *http.Request) {
+ session, ok := r.Context().Value(middleware.SessionKey).(*api.Session)
+ if !ok {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ data := baseUsersPageData(r)
+ data["Title"] = "User Updates"
+ data["Session"] = session
+
+ if roles, err := session.ListRoles(); err != nil {
+ log.Printf("UsersUpdateHandler: unable to load roles: %v", err)
+ data["RolesError"] = err.Error()
+ } else {
+ data["Roles"] = roles
+ }
+
+ renderUsersUpdatePage(w, r, data)
+}
+
+// UsersUpdateUploadHandler processes CSV uploads to update existing users.
+func UsersUpdateUploadHandler(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+ session, ok := r.Context().Value(middleware.SessionKey).(*api.Session)
+ if !ok {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if err := r.ParseMultipartForm(10 << 20); err != nil {
+ http.Error(w, fmt.Sprintf("Unable to parse upload: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ file, header, err := r.FormFile("csvFile")
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Unable to read file: %v", err), http.StatusBadRequest)
+ return
+ }
+ defer file.Close()
+
+ reader := csv.NewReader(file)
+ reader.FieldsPerRecord = -1
+ reader.TrimLeadingSpace = true
+ rows, err := reader.ReadAll()
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Unable to read CSV: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ data := baseUsersPageData(r)
+ data["Title"] = "User Updates"
+ data["Session"] = session
+
+ if len(rows) < 2 {
+ data["FlashError"] = "CSV must include a header row and at least one data row."
+ renderUsersUpdatePage(w, r, data)
+ return
+ }
+
+ headerMap := buildHeaderIndex(rows[0])
+ if _, ok := headerMap["username"]; !ok {
+ if _, ok := headerMap["userid"]; !ok {
+ data["FlashError"] = "CSV must include either a username or userId column to locate users."
+ renderUsersUpdatePage(w, r, data)
+ return
+ }
+ }
+
+ roles, rolesErr := session.ListRoles()
+ if rolesErr != nil {
+ log.Printf("UsersUpdateUploadHandler: unable to load roles: %v", rolesErr)
+ data["RolesError"] = fmt.Sprintf("Unable to load roles: %v. Role names in the CSV will not resolve.", rolesErr)
+ } else {
+ data["Roles"] = roles
+ }
+ roleIndexByName := buildRoleNameIndex(roles)
+
+ results := make([]UserUpdateResult, 0, len(rows)-1)
+ summary := UserUpdateSummary{
+ TotalRows: len(rows) - 1,
+ ProcessedAt: time.Now(),
+ ProcessedFilename: header.Filename,
+ }
+
+ for rowIdx, row := range rows[1:] {
+ rowStart := time.Now()
+ if rowIsEmpty(row) {
+ continue
+ }
+
+ result := UserUpdateResult{
+ Row: rowIdx + 2,
+ Username: getValue(row, headerMap, "username"),
+ Email: getValue(row, headerMap, "email"),
+ }
+
+ userIDValue := getValue(row, headerMap, "userid")
+ var userID int
+ if userIDValue != "" {
+ parsedID, err := strconv.Atoi(userIDValue)
+ if err != nil || parsedID <= 0 {
+ result.Error = fmt.Sprintf("invalid userId: %v", err)
+ result.ProcessingTime = time.Since(rowStart)
+ results = append(results, result)
+ summary.RowsFailed++
+ continue
+ }
+ userID = parsedID
+ result.UserID = userID
+ result.LookupMethod = "userId"
+ summary.LookupsByID++
+ } else if result.Username != "" {
+ userRecord, err := session.FindUserByUsername(result.Username)
+ if err != nil {
+ result.Error = fmt.Sprintf("failed to locate user by username: %v", err)
+ result.ProcessingTime = time.Since(rowStart)
+ results = append(results, result)
+ summary.RowsFailed++
+ continue
+ }
+ userID = userRecord.ID
+ result.UserID = userID
+ result.LookupMethod = "username"
+ summary.LookupsByUsername++
+ } else {
+ result.Error = "row missing username and userId"
+ result.ProcessingTime = time.Since(rowStart)
+ results = append(results, result)
+ summary.RowsFailed++
+ continue
+ }
+
+ payload := api.UpdateUserPayload{}
+ var updatedFields []string
+
+ if v := getValue(row, headerMap, "firstname"); v != "" {
+ payload.FirstName = stringPtr(v)
+ updatedFields = append(updatedFields, "firstName")
+ }
+ if v := getValue(row, headerMap, "new_username"); v != "" {
+ payload.Username = stringPtr(v)
+ updatedFields = append(updatedFields, "username")
+ }
+ if v := getValue(row, headerMap, "lastname"); v != "" {
+ payload.LastName = stringPtr(v)
+ updatedFields = append(updatedFields, "lastName")
+ }
+ if v := getValue(row, headerMap, "email"); v != "" {
+ payload.Email = stringPtr(v)
+ updatedFields = append(updatedFields, "email")
+ }
+ if v := getValue(row, headerMap, "password"); v != "" {
+ payload.Password = stringPtr(v)
+ updatedFields = append(updatedFields, "password")
+ }
+ if v := getValue(row, headerMap, "phone"); v != "" {
+ payload.Phone = stringPtr(v)
+ updatedFields = append(updatedFields, "phone")
+ }
+ if v := getValue(row, headerMap, "details"); v != "" {
+ payload.Details = stringPtr(v)
+ updatedFields = append(updatedFields, "details")
+ }
+ if v := getValue(row, headerMap, "status"); v != "" {
+ payload.Status = stringPtr(v)
+ updatedFields = append(updatedFields, "status")
+ }
+ if v := getValue(row, headerMap, "timezone"); v != "" {
+ payload.Timezone = stringPtr(v)
+ updatedFields = append(updatedFields, "timezone")
+ }
+ if companyVal := getValue(row, headerMap, "companyid"); companyVal != "" {
+ companyID, err := strconv.Atoi(companyVal)
+ if err != nil {
+ result.Error = fmt.Sprintf("invalid companyId: %v", err)
+ result.ProcessingTime = time.Since(rowStart)
+ results = append(results, result)
+ summary.RowsFailed++
+ continue
+ }
+ payload.CompanyID = intPtr(companyID)
+ updatedFields = append(updatedFields, "companyId")
+ }
+ if locationVal := getValue(row, headerMap, "locationid"); locationVal != "" {
+ locationID, err := strconv.Atoi(locationVal)
+ if err != nil {
+ result.Error = fmt.Sprintf("invalid locationId: %v", err)
+ result.ProcessingTime = time.Since(rowStart)
+ results = append(results, result)
+ summary.RowsFailed++
+ continue
+ }
+ payload.LocationID = intPtr(locationID)
+ updatedFields = append(updatedFields, "locationId")
+ }
+ if managerVal := getValue(row, headerMap, "managerid"); managerVal != "" {
+ managerID, err := strconv.Atoi(managerVal)
+ if err != nil {
+ result.Error = fmt.Sprintf("invalid managerId: %v", err)
+ result.ProcessingTime = time.Since(rowStart)
+ results = append(results, result)
+ summary.RowsFailed++
+ continue
+ }
+ payload.ManagerID = intPtr(managerID)
+ updatedFields = append(updatedFields, "managerId")
+ }
+
+ if isSales, err := parseOptionalBool(getValue(row, headerMap, "issales")); err != nil {
+ result.Error = fmt.Sprintf("invalid isSales value: %v", err)
+ result.ProcessingTime = time.Since(rowStart)
+ results = append(results, result)
+ summary.RowsFailed++
+ continue
+ } else if isSales != nil {
+ payload.IsSales = isSales
+ updatedFields = append(updatedFields, "isSales")
+ }
+
+ if mfaRequired, err := parseOptionalBool(getValue(row, headerMap, "mfarequired")); err != nil {
+ result.Error = fmt.Sprintf("invalid mfaRequired value: %v", err)
+ result.ProcessingTime = time.Since(rowStart)
+ results = append(results, result)
+ summary.RowsFailed++
+ continue
+ } else if mfaRequired != nil {
+ payload.MFARequired = mfaRequired
+ updatedFields = append(updatedFields, "mfaRequired")
+ }
+
+ if serviceLineIDs, err := parseIntList(getValue(row, headerMap, "servicelineids")); err != nil {
+ result.Error = fmt.Sprintf("invalid serviceLineIds value: %v", err)
+ result.ProcessingTime = time.Since(rowStart)
+ results = append(results, result)
+ summary.RowsFailed++
+ continue
+ } else if serviceLineIDs != nil {
+ payload.ServiceLineIDs = &serviceLineIDs
+ updatedFields = append(updatedFields, "serviceLineIds")
+ }
+
+ if len(updatedFields) == 0 {
+ result.Error = "no updatable fields provided in row"
+ result.ProcessingTime = time.Since(rowStart)
+ results = append(results, result)
+ summary.RowsFailed++
+ continue
+ }
+
+ if _, err := session.UpdateUser(userID, payload); err != nil {
+ result.Error = fmt.Sprintf("failed to update user: %v", err)
+ result.ProcessingTime = time.Since(rowStart)
+ results = append(results, result)
+ summary.RowsFailed++
+ continue
+ }
+
+ result.Updated = true
+ result.UpdatedFields = updatedFields
+ summary.UsersUpdated++
+
+ roleTokens := parseRoleTokens(getValue(row, headerMap, "roles"))
+ if len(roleTokens) > 0 {
+ assignments := make([]RoleAssignmentResult, 0, len(roleTokens))
+ for _, token := range roleTokens {
+ assignResult := RoleAssignmentResult{Token: token}
+ role, resolved, message := resolveRoleToken(token, roleIndexByName)
+ if !resolved {
+ assignResult.Message = message
+ assignments = append(assignments, assignResult)
+ summary.RoleAssignmentErrors++
+ continue
+ }
+ assignResult.RoleID = role.ID
+ assignResult.Role = role.Name
+
+ if err := session.AssignRoleToUser(userID, role.ID); err != nil {
+ assignResult.Message = fmt.Sprintf("failed to assign role: %v", err)
+ summary.RoleAssignmentErrors++
+ } else {
+ assignResult.Success = true
+ assignResult.Message = "assigned"
+ summary.RoleAssignments++
+ }
+ assignments = append(assignments, assignResult)
+ }
+ result.RoleAssignments = assignments
+ }
+
+ result.ProcessingTime = time.Since(rowStart)
+ results = append(results, result)
+ }
+
+ data["UpdateResults"] = results
+ data["UpdateSummary"] = summary
+ if summary.UsersUpdated > 0 {
+ data["FlashSuccess"] = fmt.Sprintf("Updated %d user(s) (processed %d rows in %s).", summary.UsersUpdated, summary.TotalRows, time.Since(start).Round(time.Millisecond))
+ } else {
+ data["FlashError"] = "No users were updated. Review the row errors below."
+ }
+
+ renderUsersUpdatePage(w, r, data)
+}
+
+func renderUsersPage(w http.ResponseWriter, r *http.Request, data map[string]interface{}) {
+ renderUsersTemplatePage(w, r, data, "users_content")
+}
+
+func renderUsersUpdatePage(w http.ResponseWriter, r *http.Request, data map[string]interface{}) {
+ renderUsersTemplatePage(w, r, data, "users_update_content")
+}
+
+func renderUsersTemplatePage(w http.ResponseWriter, r *http.Request, data map[string]interface{}, templateName string) {
tmpl := root.WebTemplates
+
+ if r.Header.Get("HX-Request") == "true" {
+ if err := tmpl.ExecuteTemplate(w, templateName, data); err != nil {
+ log.Printf("UsersHandler: template error: %v", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ }
+ return
+ }
+
+ var contentBuf bytes.Buffer
+ if err := tmpl.ExecuteTemplate(&contentBuf, templateName, data); err != nil {
+ log.Printf("UsersHandler: template error: %v", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+ data["BodyContent"] = template.HTML(contentBuf.String())
+
+ if err := tmpl.ExecuteTemplate(w, "layout.html", data); err != nil {
+ log.Printf("UsersHandler: layout template error: %v", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ }
+}
+
+func baseUsersPageData(r *http.Request) map[string]interface{} {
data := map[string]interface{}{
- "Title": "Users",
+ "CSRFField": csrf.TemplateField(r),
+ "CSRFToken": csrf.Token(r),
+ }
+ if c, err := r.Cookie("XSRF-TOKEN"); err == nil {
+ data["CSRFCookie"] = c.Value
+ } else if c, err := r.Cookie("XSRF-TOKEN-VALUE"); err == nil {
+ data["CSRFCookie"] = c.Value
+ }
+ return data
+}
+
+func buildHeaderIndex(headers []string) map[string]int {
+ index := make(map[string]int, len(headers))
+ for idx, header := range headers {
+ if header == "" {
+ continue
+ }
+ normalized := normalizeHeader(header)
+ if canonical, ok := headerCanonicalMap[normalized]; ok {
+ index[canonical] = idx
+ } else {
+ index[normalized] = idx
+ }
}
+ return index
+}
- err := tmpl.Execute(w, data)
+func missingHeaders(headerIndex map[string]int, required []string) []string {
+ var missing []string
+ for _, key := range required {
+ if _, ok := headerIndex[key]; !ok {
+ missing = append(missing, key)
+ }
+ }
+ return missing
+}
+
+func rowIsEmpty(row []string) bool {
+ for _, value := range row {
+ if strings.TrimSpace(value) != "" {
+ return false
+ }
+ }
+ return true
+}
+
+func validateRequiredRowFields(row []string, index map[string]int, required []string) error {
+ var missing []string
+ for _, key := range required {
+ if strings.TrimSpace(getValue(row, index, key)) == "" {
+ missing = append(missing, key)
+ }
+ }
+ if len(missing) > 0 {
+ return fmt.Errorf("missing required value(s): %s", strings.Join(missing, ", "))
+ }
+ return nil
+}
+
+func getValue(row []string, index map[string]int, key string) string {
+ if idx, ok := index[key]; ok && idx < len(row) {
+ return strings.TrimSpace(row[idx])
+ }
+ return ""
+}
+
+func parseOptionalBool(value string) (*bool, error) {
+ if value == "" {
+ return nil, nil
+ }
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case "true", "1", "yes", "y":
+ result := true
+ return &result, nil
+ case "false", "0", "no", "n":
+ result := false
+ return &result, nil
+ default:
+ return nil, fmt.Errorf("expected boolean value, got %q", value)
+ }
+}
+
+func parseOptionalInt(value string) (*int, error) {
+ if strings.TrimSpace(value) == "" {
+ return nil, nil
+ }
+ intVal, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return nil, err
+ }
+ return &intVal, nil
+}
+
+func parseIntList(raw string) ([]int, error) {
+ if strings.TrimSpace(raw) == "" {
+ return nil, nil
+ }
+ parts := splitTokens(raw)
+ ints := make([]int, 0, len(parts))
+ for _, part := range parts {
+ id, err := strconv.Atoi(part)
+ if err != nil {
+ return nil, fmt.Errorf("invalid integer %q", part)
+ }
+ ints = append(ints, id)
+ }
+ return ints, nil
+}
+
+func parseRoleTokens(raw string) []string {
+ if strings.TrimSpace(raw) == "" {
+ return nil
+ }
+ return splitTokens(raw)
+}
+
+func splitTokens(raw string) []string {
+ cleaned := strings.NewReplacer(";", ",", "|", ",").Replace(raw)
+ parts := strings.Split(cleaned, ",")
+ result := make([]string, 0, len(parts))
+ for _, part := range parts {
+ token := strings.TrimSpace(part)
+ if token != "" {
+ result = append(result, token)
+ }
+ }
+ return result
+}
+
+func resolveRoleToken(token string, nameIndex map[string]api.Role) (api.Role, bool, string) {
+ clean := strings.TrimSpace(token)
+ if clean == "" {
+ return api.Role{}, false, "empty role token"
+ }
+ normalized := normalizeRoleName(clean)
+ if role, ok := nameIndex[normalized]; ok {
+ return role, true, ""
+ }
+ return api.Role{}, false, fmt.Sprintf("role %q not found; use the role name exactly as listed", token)
+}
+
+func buildRoleNameIndex(roles []api.Role) map[string]api.Role {
+ nameIndex := make(map[string]api.Role, len(roles))
+ for _, role := range roles {
+ nameKey := normalizeRoleName(role.Name)
+ if nameKey != "" {
+ nameIndex[nameKey] = role
+ }
}
+ return nameIndex
+}
+
+func normalizeHeader(value string) string {
+ v := strings.TrimSpace(strings.ToLower(value))
+ v = strings.ReplaceAll(v, " ", "")
+ v = strings.ReplaceAll(v, "_", "")
+ v = strings.ReplaceAll(v, "-", "")
+ return v
+}
+
+func normalizeRoleName(value string) string {
+ return strings.ToLower(strings.TrimSpace(value))
+}
+
+var headerCanonicalMap = map[string]string{
+ "username": "username",
+ "user": "username",
+ "firstname": "firstname",
+ "first": "firstname",
+ "lastname": "lastname",
+ "last": "lastname",
+ "email": "email",
+ "mail": "email",
+ "password": "password",
+ "pass": "password",
+ "companyid": "companyid",
+ "company": "companyid",
+ "locationid": "locationid",
+ "location": "locationid",
+ "userid": "userid",
+ "id": "userid",
+ "newusername": "new_username",
+ "username_new": "new_username",
+ "usernameupdate": "new_username",
+ "phone": "phone",
+ "phonenumber": "phone",
+ "roles": "roles",
+ "roleids": "roles",
+ "rolenames": "roles",
+ "status": "status",
+ "timezone": "timezone",
+ "details": "details",
+ "issales": "issales",
+ "sales": "issales",
+ "managerid": "managerid",
+ "manager": "managerid",
+ "mfarequired": "mfarequired",
+ "mfa": "mfarequired",
+ "servicelineids": "servicelineids",
+}
+
+func stringPtr(value string) *string {
+ v := value
+ return &v
+}
+
+func intPtr(value int) *int {
+ v := value
+ return &v
}
diff --git a/internal/middleware/csrf_simple.go b/internal/middleware/csrf_simple.go
index c278c22..9f3043e 100644
--- a/internal/middleware/csrf_simple.go
+++ b/internal/middleware/csrf_simple.go
@@ -20,16 +20,26 @@ func CSRFSimple(next http.Handler) http.Handler {
// Ensure token cookie exists for safe methods
if method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions || method == http.MethodTrace {
- if _, err := r.Cookie("XSRF-TOKEN"); err != nil {
- // Generate a random token
+ token := ""
+ if c, err := r.Cookie("XSRF-TOKEN"); err == nil {
+ if v := strings.TrimSpace(c.Value); v != "" && !strings.Contains(v, "|") {
+ token = v
+ }
+ }
+ if token == "" {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err == nil {
- token := base64.RawURLEncoding.EncodeToString(buf)
+ token = base64.RawURLEncoding.EncodeToString(buf)
+ }
+ }
+ if token != "" {
+ // Refresh both legacy and simple-mode cookie names so the frontend reads the correct value.
+ for _, name := range []string{"XSRF-TOKEN", "XSRF-TOKEN-VALUE"} {
http.SetCookie(w, &http.Cookie{
- Name: "XSRF-TOKEN",
+ Name: name,
Value: token,
Path: "/",
- HttpOnly: false, // must be readable by client script
+ HttpOnly: false,
Secure: isHTTPS,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(12 * time.Hour),
@@ -53,16 +63,24 @@ func CSRFSimple(next http.Handler) http.Handler {
}
}
- cookie, err := r.Cookie("XSRF-TOKEN")
- if err != nil || token == "" || cookie == nil || cookie.Value == "" || cookie.Value != token {
+ tokenCookie, err := r.Cookie("XSRF-TOKEN")
+ if err != nil || tokenCookie == nil || strings.Contains(tokenCookie.Value, "|") || tokenCookie.Value == "" {
+ if legacyCookie, legacyErr := r.Cookie("XSRF-TOKEN-VALUE"); legacyErr == nil {
+ tokenCookie = legacyCookie
+ } else {
+ err = legacyErr
+ }
+ }
+
+ if token == "" || tokenCookie == nil || tokenCookie.Value == "" || tokenCookie.Value != token {
if token == "" {
if headerToken := r.Header.Get("X-CSRF-Token"); headerToken != "" {
token = headerToken
}
}
log.Printf("CSRF validation failed (simple mode): header=%q form=%q cookie=%q err=%v", r.Header.Get("X-CSRF-Token"), token, func() string {
- if cookie != nil {
- return cookie.Value
+ if tokenCookie != nil {
+ return tokenCookie.Value
}
return ""
}(), err)
diff --git a/templates/generic.html b/templates/generic.html
index 1119af8..ad74b6d 100644
--- a/templates/generic.html
+++ b/templates/generic.html
@@ -11,12 +11,14 @@
Invoice Clock Events Report (Proof of Concept)
- Upload a CSV containing invoice numbers to generate a downloadable report that aggregates
- related job information, assigned technicians, and enroute/onsite clock events.
+ Upload a CSV export that contains either job ids or invoice numbers. The tool automatically detects
+ which identifier is usable, gathers the related clock activity, and returns an Excel workbook formatted
+ like the shared audit template (Customer PO, id, Link, Clock In, Clock Out) with the header row frozen
+ and columns expanded.
+
+ Generating very large reports can take a few minutes after you submit. Please keep the tab open while the download prepares.
+
+
+
+
+
Building report…
+
Hang tight while we fetch clock events and assemble the workbook.
+
+
+
@@ -69,11 +81,6 @@
document.addEventListener('submit', ensureTokenBeforeSubmit, true);
- if (document.body && document.body.addEventListener) {
- document.body.addEventListener('htmx:load', function (evt) {
- applyToken(evt.target || document);
- });
- }
})();
{{end}}
\ No newline at end of file
diff --git a/templates/layout.html b/templates/layout.html
index 4d07857..252d008 100644
--- a/templates/layout.html
+++ b/templates/layout.html
@@ -45,6 +45,7 @@
Services
Tags
Users
+ User Updates
Admin
@@ -87,6 +88,8 @@
{{template "tags_content" .}}
{{else if eq .Title "Users"}}
{{template "users_content" .}}
+ {{else if eq .Title "User Updates"}}
+ {{template "users_update_content" .}}
{{else if eq .Title "Admin"}}
{{template "admin_content" .}}
{{else}}
diff --git a/templates/users.html b/templates/users.html
index 7a8d286..c0ba388 100644
--- a/templates/users.html
+++ b/templates/users.html
@@ -1,18 +1,132 @@
{{define "users_content"}}
-
-
User management functionality will be implemented here.
-
-
👤
-
Coming Soon
-
User management features are under development.
+
+ {{if .FlashError}}
+
{{.FlashError}}
+ {{end}}
+ {{if .FlashSuccess}}
+
{{.FlashSuccess}}
+ {{end}}
+
+
+ Required headers: username, firstName, lastName, email, password, companyId, locationId.
+ Optional headers include phone, status, timezone, details, isSales, managerId, mfaRequired, serviceLineIds, and roles.
+ In the roles column list the human-readable role names (comma, semicolon, or pipe separated); names are matched case-insensitively.
+
+ {{if .RolesError}}
+
+ {{end}}
+
+ {{if .Roles}}
+
+
+
Use the role names from this list in the CSV roles column.
+
+
+
+ | ID |
+ Name |
+ Description |
+ Global |
+
+
+
+ {{range .Roles}}
+
+ | {{.ID}} |
+ {{.Name}} |
+ {{.Description}} |
+ {{if .Global}}Yes{{else}}No{{end}} |
+
+ {{end}}
+
+
+
+ {{end}}
+
+ {{if .ImportSummary}}
+
+
+
+ Processed file: {{.ImportSummary.ProcessedFilename}} at {{.ImportSummary.ProcessedAt.Format "2006-01-02 15:04 MST"}}.
+
+
+ - Total rows: {{.ImportSummary.TotalRows}}
+ - Users created: {{.ImportSummary.UsersCreated}}
+ - Rows failed: {{.ImportSummary.RowsFailed}}
+ - Role assignments succeeded: {{.ImportSummary.RoleAssignments}}
+ - Role assignments failed: {{.ImportSummary.RoleAssignmentErrors}}
+
+
+ {{end}}
+
+ {{if .ImportResults}}
+
+
+
+
+
+ | Row |
+ Username |
+ Email |
+ User Result |
+ Role Assignments |
+ Processing Time |
+
+
+
+ {{range .ImportResults}}
+
+ | {{.Row}} |
+ {{.Username}} |
+ {{.Email}} |
+
+ {{if .Error}}
+ {{.Error}}
+ {{else if .Created}}
+ Created (#{{.UserID}})
+ {{else}}
+ Skipped
+ {{end}}
+ |
+
+ {{if .RoleAssignments}}
+
+ {{range .RoleAssignments}}
+ -
+ {{if .Success}}✅{{else}}⚠️{{end}}
+ {{if .Role}}{{.Role}} ({{.RoleID}}){{else}}{{.Token}}{{end}} — {{.Message}}
+
+ {{end}}
+
+ {{else if .Error}}
+ —
+ {{else}}
+ No roles provided
+ {{end}}
+ |
+ {{.ProcessingTime}} |
+
+ {{end}}
+
+
+
+ {{end}}
-{{end}}
\ No newline at end of file
+{{end}}
diff --git a/templates/users_update.html b/templates/users_update.html
new file mode 100644
index 0000000..753d507
--- /dev/null
+++ b/templates/users_update.html
@@ -0,0 +1,152 @@
+{{define "users_update_content"}}
+
+
+
+
+
+ {{if .FlashError}}
+
{{.FlashError}}
+ {{end}}
+ {{if .FlashSuccess}}
+
{{.FlashSuccess}}
+ {{end}}
+
+
+ Provide either userId or username for each row. Use new_username to change an account's username. All other columns match the create template; only non-empty values are applied. Roles column should list human-readable role names (comma, semicolon, or pipe separated).
+
+
+
+ {{if .RolesError}}
+
+ {{end}}
+
+ {{if .Roles}}
+
+
+
Use the role names from this list in the CSV roles column.
+
+
+
+ | ID |
+ Name |
+ Description |
+ Global |
+
+
+
+ {{range .Roles}}
+
+ | {{.ID}} |
+ {{.Name}} |
+ {{.Description}} |
+ {{if .Global}}Yes{{else}}No{{end}} |
+
+ {{end}}
+
+
+
+ {{end}}
+
+ {{if .UpdateSummary}}
+
+
+
+ Processed file: {{.UpdateSummary.ProcessedFilename}} at {{.UpdateSummary.ProcessedAt.Format "2006-01-02 15:04 MST"}}.
+
+
+ - Total rows: {{.UpdateSummary.TotalRows}}
+ - Users updated: {{.UpdateSummary.UsersUpdated}}
+ - Rows failed: {{.UpdateSummary.RowsFailed}}
+ - Role assignments succeeded: {{.UpdateSummary.RoleAssignments}}
+ - Role assignments failed: {{.UpdateSummary.RoleAssignmentErrors}}
+ - Lookups by username: {{.UpdateSummary.LookupsByUsername}}
+ - Lookups by userId: {{.UpdateSummary.LookupsByID}}
+
+
+ {{end}}
+
+ {{if .UpdateResults}}
+
+
+
+
+
+ | Row |
+ User |
+ Lookup |
+ Updated Fields |
+ Result |
+ Role Assignments |
+ Processing Time |
+
+
+
+ {{range .UpdateResults}}
+
+ | {{.Row}} |
+
+ {{if .UserID}}
+ #{{.UserID}}
+ {{end}}
+ {{if .Username}}
+ ({{.Username}})
+ {{end}}
+ |
+ {{.LookupMethod}} |
+
+ {{if .UpdatedFields}}
+
+ {{range .UpdatedFields}}
+ - {{.}}
+ {{end}}
+
+ {{else}}
+ —
+ {{end}}
+ |
+
+ {{if .Error}}
+ {{.Error}}
+ {{else if .Updated}}
+ Updated
+ {{else}}
+ Skipped
+ {{end}}
+ |
+
+ {{if .RoleAssignments}}
+
+ {{range .RoleAssignments}}
+ -
+ {{if .Success}}✅{{else}}⚠️{{end}}
+ {{if .Role}}{{.Role}} ({{.RoleID}}){{else}}{{.Token}}{{end}} — {{.Message}}
+
+ {{end}}
+
+ {{else if .Error}}
+ —
+ {{else}}
+ No roles provided
+ {{end}}
+ |
+ {{.ProcessingTime}} |
+
+ {{end}}
+
+
+
+ {{end}}
+
+{{end}}
+