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.

+ enctype="multipart/form-data" hx-target="#invoice-clock-status" hx-indicator=".htmx-indicator"> {{if .CSRFField}} {{.CSRFField}} {{end}} @@ -26,12 +28,22 @@

- The CSV may include a header named invoice_number; if no header is present, the first - column - is used. Each invoice number is processed individually. + If a job id column is present we use it directly; otherwise we fall back to columns such as + invoice_number or customer po to discover the job before pulling clock events.

- +
+
+ Generating very large reports can take a few minutes after you submit. Please keep the tab open while the download prepares. +
+ +
@@ -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}} +
    + {{if .CSRFField}}{{.CSRFField}}{{end}} + {{if .CSRFCookie}}{{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}} +
    +
    {{.RolesError}}
    +
    + {{end}} + + {{if .Roles}} +
    + +

    Use the role names from this list in the CSV roles column.

    + + + + + + + + + + + {{range .Roles}} + + + + + + + {{end}} + +
    IDNameDescriptionGlobal
    {{.ID}}{{.Name}}{{.Description}}{{if .Global}}Yes{{else}}No{{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}} +
    + + + + + + + + + + + + + + {{range .ImportResults}} + + + + + + + + + {{end}} + +
    RowUsernameEmailUser ResultRole AssignmentsProcessing Time
    {{.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}} \ 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}} +
    + {{if .CSRFField}}{{.CSRFField}}{{end}} + {{if .CSRFCookie}}{{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}} +
    +
    {{.RolesError}}
    +
    + {{end}} + + {{if .Roles}} +
    + +

    Use the role names from this list in the CSV roles column.

    + + + + + + + + + + + {{range .Roles}} + + + + + + + {{end}} + +
    IDNameDescriptionGlobal
    {{.ID}}{{.Name}}{{.Description}}{{if .Global}}Yes{{else}}No{{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}} +
    + + + + + + + + + + + + + + + {{range .UpdateResults}} + + + + + + + + + + {{end}} + +
    RowUserLookupUpdated FieldsResultRole AssignmentsProcessing Time
    {{.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}} +