commit
af000b064d
41 changed files with 1886 additions and 0 deletions
@ -0,0 +1,86 @@ |
|||
# Complete ServiceTrade Tool Project |
|||
|
|||
## Project Structure |
|||
|
|||
```project_root/ |
|||
├── apps/ |
|||
│ ├── cli/ |
|||
│ │ └── main.go |
|||
│ └── web/ |
|||
│ └── main.go |
|||
├── internal/ |
|||
│ ├── api/ |
|||
│ │ └── api.go |
|||
| | └── attachments.go (will be removed) |
|||
| | └── auth.go (will be removed) |
|||
| | └── common.go (will be removed) |
|||
| | └── deficiencies.go |
|||
│ ├── auth/ |
|||
│ │ └── auth.go (may be removed, idk if it should be in the api or be here by itself) |
|||
│ ├── handlers/ (for web) |
|||
│ │ ├── admin.go |
|||
│ │ ├── assets.go |
|||
│ │ ├── companies.go |
|||
│ │ ├── contacts.go |
|||
│ │ ├── contracts.go |
|||
| | └── dashboard.go |
|||
│ │ ├── generic.go |
|||
│ │ ├── invoices.go |
|||
│ │ ├── jobs.go |
|||
│ │ ├── locations.go |
|||
| | └── login.go |
|||
│ │ ├── notifications.go |
|||
│ │ ├── quotes.go |
|||
│ │ ├── services.go |
|||
│ │ ├── tags.go |
|||
│ │ └── users.go |
|||
│ ├── menu/ (in both cli and web (☉__☉”)) |
|||
│ │ ├── admin.go (need to create) |
|||
│ │ ├── assets.go (need to create) |
|||
│ │ ├── companies.go (need to create) |
|||
│ │ ├── contacts.go (need to create) |
|||
│ │ ├── contracts.go (need to create) |
|||
│ │ ├── generic.go (need to create) |
|||
│ │ ├── invoices.go (need to create) |
|||
│ │ ├── jobs.go |
|||
│ │ ├── locations.go (need to create) |
|||
| | ├── menu.go |
|||
│ │ ├── notifications.go (need to create) |
|||
│ │ ├── quotes.go (need to create) |
|||
│ │ ├── services.go (need to create) |
|||
│ │ ├── tags.go (need to create) |
|||
│ │ └── users.go (need to create) |
|||
│ ├── middleware/ |
|||
│ │ └── auth_middleware.go |
|||
│ └── models/ |
|||
│ └── models.go |
|||
├── static/ |
|||
│ ├── css/ |
|||
│ │ └── styles.css |
|||
│ └── js/ |
|||
│ └── htmx.min.js |
|||
├── templates/ |
|||
│ ├── layout.html |
|||
│ ├── login.html |
|||
│ ├── dashboard.html |
|||
│ └── partials/ |
|||
│ ├── admin.html (need to create) |
|||
│ ├── assets.html (need to create) |
|||
│ ├── companies.html (need to create) |
|||
│ ├── contacts.html (need to create) |
|||
│ ├── contracts.html (need to create) |
|||
│ ├── generic.html (need to create) |
|||
│ ├── invoices.html (need to create) |
|||
│ ├── jobs.html |
|||
│ ├── locations.html (need to create) |
|||
│ ├── notifications.html (need to create) |
|||
│ ├── quotes.html (need to create) |
|||
│ ├── services.html (need to create) |
|||
│ ├── tags.html (need to create) |
|||
│ └── users.html (need to create) |
|||
├── ui/ |
|||
| | └── ui.go (for cli) |
|||
├── utils/ |
|||
| | └── utils.go (for cli) |
|||
└── go.mod |
|||
``` |
|||
@ -0,0 +1,46 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"os" |
|||
|
|||
"marmic/servicetrade-toolbox/internal/api" |
|||
"marmic/servicetrade-toolbox/internal/menu" |
|||
"marmic/servicetrade-toolbox/internal/ui" |
|||
) |
|||
|
|||
func main() { |
|||
ui.DisplayStartScreen() |
|||
|
|||
email, password, err := ui.PromptCredentials() |
|||
if err != nil { |
|||
ui.DisplayError("Error getting credentials:", err) |
|||
os.Exit(1) |
|||
} |
|||
|
|||
session := api.NewSession() |
|||
err = session.Login(email, password) |
|||
if err != nil { |
|||
ui.DisplayError("Authentication failed:", err) |
|||
os.Exit(1) |
|||
} |
|||
|
|||
ui.DisplayMessage("Login successful!") |
|||
|
|||
mainMenu := menu.GetMainMenu() |
|||
for { |
|||
choice := menu.DisplayMenuAndGetChoice(mainMenu, "Main Menu") |
|||
if choice == len(mainMenu)+1 { |
|||
ui.ClearScreen() |
|||
ui.DisplayMessage("Logging out...") |
|||
err := session.Logout() |
|||
if err != nil { |
|||
ui.DisplayError("Error during logout: ", err) |
|||
} else { |
|||
ui.DisplayMessage("Logout successful.") |
|||
} |
|||
ui.DisplayMessage("Exiting ServiceTrade CLI Toolbox. Goodbye!") |
|||
return |
|||
} |
|||
mainMenu[choice-1].Handler(session) |
|||
} |
|||
} |
|||
@ -0,0 +1,45 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"log" |
|||
"net/http" |
|||
|
|||
"marmic/servicetrade-toolbox/internal/handlers" |
|||
"marmic/servicetrade-toolbox/internal/middleware" |
|||
|
|||
"github.com/gorilla/mux" |
|||
) |
|||
|
|||
func main() { |
|||
r := mux.NewRouter() |
|||
|
|||
// Serve static files
|
|||
r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) |
|||
|
|||
// Auth routes
|
|||
r.HandleFunc("/login", handlers.LoginHandler).Methods("GET", "POST") |
|||
r.HandleFunc("/logout", handlers.LogoutHandler).Methods("GET", "POST") |
|||
|
|||
// Protected routes
|
|||
protected := r.PathPrefix("/").Subrouter() |
|||
protected.Use(middleware.AuthMiddleware) |
|||
|
|||
protected.HandleFunc("/", handlers.DashboardHandler).Methods("GET") |
|||
protected.HandleFunc("/jobs", handlers.JobsHandler).Methods("GET") |
|||
protected.HandleFunc("/admin", handlers.AdminHandler).Methods("GET") |
|||
protected.HandleFunc("/assets", handlers.AssetsHandler).Methods("GET") |
|||
protected.HandleFunc("/companies", handlers.CompaniesHandler).Methods("GET") |
|||
protected.HandleFunc("/contacts", handlers.ContactsHandler).Methods("GET") |
|||
protected.HandleFunc("/contracts", handlers.ContractsHandler).Methods("GET") |
|||
protected.HandleFunc("/generic", handlers.GenericHandler).Methods("GET") |
|||
protected.HandleFunc("/invoices", handlers.InvoicesHandler).Methods("GET") |
|||
protected.HandleFunc("/locations", handlers.LocationsHandler).Methods("GET") |
|||
protected.HandleFunc("/notifications", handlers.NotificationsHandler).Methods("GET") |
|||
protected.HandleFunc("/quotes", handlers.QuotesHandler).Methods("GET") |
|||
protected.HandleFunc("/services", handlers.ServicesHandler).Methods("GET") |
|||
protected.HandleFunc("/tags", handlers.TagsHandler).Methods("GET") |
|||
protected.HandleFunc("/users", handlers.UsersHandler).Methods("GET") |
|||
|
|||
log.Println("Server starting on :8080") |
|||
log.Fatal(http.ListenAndServe(":8080", r)) |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
module marmic/servicetrade-toolbox |
|||
|
|||
go 1.22.1 |
|||
|
|||
require ( |
|||
github.com/gorilla/mux v1.8.1 |
|||
github.com/inancgumus/screen v0.0.0-20190314163918-06e984b86ed3 |
|||
) |
|||
|
|||
require ( |
|||
golang.org/x/crypto v0.27.0 // indirect |
|||
golang.org/x/sys v0.25.0 // indirect |
|||
golang.org/x/term v0.24.0 // indirect |
|||
) |
|||
@ -0,0 +1,10 @@ |
|||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= |
|||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= |
|||
github.com/inancgumus/screen v0.0.0-20190314163918-06e984b86ed3 h1:fO9A67/izFYFYky7l1pDP5Dr0BTCRkaQJUG6Jm5ehsk= |
|||
github.com/inancgumus/screen v0.0.0-20190314163918-06e984b86ed3/go.mod h1:Ey4uAp+LvIl+s5jRbOHLcZpUDnkjLBROl15fZLwPlTM= |
|||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= |
|||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= |
|||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= |
|||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= |
|||
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= |
|||
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= |
|||
@ -0,0 +1,196 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"bytes" |
|||
"encoding/json" |
|||
"fmt" |
|||
"io" |
|||
"net/http" |
|||
"strings" |
|||
) |
|||
|
|||
type Session struct { |
|||
Client *http.Client |
|||
Cookie string |
|||
} |
|||
|
|||
func NewSession() *Session { |
|||
return &Session{Client: &http.Client{}} |
|||
} |
|||
|
|||
func (s *Session) Login(email, password string) error { |
|||
url := "https://api.servicetrade.com/api/auth" |
|||
payload := map[string]string{ |
|||
"username": email, |
|||
"password": password, |
|||
} |
|||
payloadBytes, _ := json.Marshal(payload) |
|||
|
|||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes)) |
|||
if err != nil { |
|||
return fmt.Errorf("error creating request: %v", err) |
|||
} |
|||
req.Header.Set("Content-Type", "application/json") |
|||
|
|||
resp, err := s.Client.Do(req) |
|||
if err != nil { |
|||
return fmt.Errorf("error sending request: %v", err) |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
if resp.StatusCode != 200 { |
|||
body, _ := io.ReadAll(resp.Body) |
|||
return fmt.Errorf("failed to authenticate: %s, response: %s", resp.Status, string(body)) |
|||
} |
|||
|
|||
for _, cookie := range resp.Cookies() { |
|||
if strings.Contains(cookie.Name, "PHPSESSID") { |
|||
s.Cookie = cookie.String() |
|||
fmt.Println(s.Cookie) |
|||
return nil |
|||
} |
|||
} |
|||
|
|||
return fmt.Errorf("failed to retrieve session cookie; authentication may have failed") |
|||
} |
|||
|
|||
func (s *Session) Logout() error { |
|||
if s.Cookie == "" { |
|||
return fmt.Errorf("no active session to end") |
|||
} |
|||
|
|||
url := "https://api.servicetrade.com/api/auth" |
|||
req, err := http.NewRequest("DELETE", url, nil) |
|||
if err != nil { |
|||
return fmt.Errorf("failed to create DELETE request to end session: %v", err) |
|||
} |
|||
req.Header.Set("Cookie", s.Cookie) |
|||
|
|||
resp, err := s.Client.Do(req) |
|||
if err != nil { |
|||
return fmt.Errorf("failed to send DELETE request to end session: %v", err) |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
if resp.StatusCode != 200 && resp.StatusCode != 204 { |
|||
body, _ := io.ReadAll(resp.Body) |
|||
return fmt.Errorf("failed to end session: %s, response: %s", resp.Status, string(body)) |
|||
} |
|||
|
|||
s.Cookie = "" |
|||
return nil |
|||
} |
|||
|
|||
func (s *Session) GetAttachmentsForJob(jobID string) (map[string]interface{}, error) { |
|||
url := fmt.Sprintf("https://api.servicetrade.com/api/job/%s/paperwork", jobID) |
|||
req, err := http.NewRequest("GET", url, nil) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("error creating request: %v", err) |
|||
} |
|||
req.Header.Set("Cookie", s.Cookie) |
|||
|
|||
resp, err := s.Client.Do(req) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("error sending request: %v", err) |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
body, _ := io.ReadAll(resp.Body) |
|||
|
|||
if resp.StatusCode != 200 { |
|||
return nil, fmt.Errorf("failed to get attachments: %s, response: %s", resp.Status, string(body)) |
|||
} |
|||
|
|||
var result map[string]interface{} |
|||
if err := json.Unmarshal(body, &result); err != nil { |
|||
return nil, fmt.Errorf("error unmarshalling response: %v, body: %s", err, string(body)) |
|||
} |
|||
return result, nil |
|||
} |
|||
|
|||
func (s *Session) DeleteAttachment(endpoint string) error { |
|||
req, err := http.NewRequest("DELETE", endpoint, nil) |
|||
if err != nil { |
|||
return fmt.Errorf("failed to create DELETE request: %v", err) |
|||
} |
|||
req.Header.Set("Cookie", s.Cookie) |
|||
|
|||
resp, err := s.Client.Do(req) |
|||
if err != nil { |
|||
return fmt.Errorf("failed to send DELETE request: %v", err) |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
if resp.StatusCode != 200 && resp.StatusCode != 204 { |
|||
body, _ := io.ReadAll(resp.Body) |
|||
return fmt.Errorf("failed to delete attachment: %s, response: %s", resp.Status, string(body)) |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
func (s *Session) GetDeficiencyInfoForJob(jobID string) ([][]string, error) { |
|||
url := fmt.Sprintf("https://api.servicetrade.com/api/deficiency/%s", jobID) |
|||
req, err := http.NewRequest("GET", url, nil) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("error creating request: %v", err) |
|||
} |
|||
req.Header.Set("Cookie", s.Cookie) |
|||
|
|||
resp, err := s.Client.Do(req) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("error sending request: %v", err) |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
body, _ := io.ReadAll(resp.Body) |
|||
if resp.StatusCode != 200 { |
|||
return nil, fmt.Errorf("failed to get deficiency info: %s, response: %s", resp.Status, string(body)) |
|||
} |
|||
|
|||
var result map[string]interface{} |
|||
if err := json.Unmarshal(body, &result); err != nil { |
|||
return nil, fmt.Errorf("error unmarshalling response: %v, body: %s", err, string(body)) |
|||
} |
|||
|
|||
var deficiencyLogs [][]string |
|||
if data, ok := result["data"].([]interface{}); ok { |
|||
for _, item := range data { |
|||
if deficiency, ok := item.(map[string]interface{}); ok { |
|||
id := fmt.Sprintf("%v", deficiency["id"]) |
|||
description := fmt.Sprintf("%v", deficiency["description"]) |
|||
status := fmt.Sprintf("%v", deficiency["status"]) |
|||
deficiencyLogs = append(deficiencyLogs, []string{jobID, id, description, status}) |
|||
} |
|||
} |
|||
} |
|||
|
|||
return deficiencyLogs, nil |
|||
} |
|||
|
|||
func (s *Session) GetDeficiencyById(deficiencyId string) (map[string]interface{}, error) { |
|||
url := fmt.Sprintf("https://api.servicetrade.com/api/deficiency/%s", deficiencyId) |
|||
req, err := http.NewRequest("GET", url, nil) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("error creating request: %v", err) |
|||
} |
|||
req.Header.Set("Cookie", s.Cookie) |
|||
|
|||
resp, err := s.Client.Do(req) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("error sending request: %v", err) |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
body, _ := io.ReadAll(resp.Body) |
|||
if resp.StatusCode != 200 { |
|||
return nil, fmt.Errorf("failed to get deficiency info: %s, response: %s", resp.Status, string(body)) |
|||
} |
|||
|
|||
var result map[string]interface{} |
|||
if err := json.Unmarshal(body, &result); err != nil { |
|||
return nil, fmt.Errorf("error unmarshalling response: %v, body: %s", err, string(body)) |
|||
} |
|||
|
|||
return result, nil |
|||
} |
|||
@ -0,0 +1,72 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"fmt" |
|||
"marmic/servicetrade-toolbox/internal/auth" |
|||
"strings" |
|||
) |
|||
|
|||
func GetAttachmentsForJob(session *auth.Session, jobID string) (map[string]interface{}, error) { |
|||
url := fmt.Sprintf("%s/job/%s/paperwork", BaseURL, jobID) |
|||
req, err := AuthenticatedRequest(session, "GET", url, nil) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("error creating request: %v", err) |
|||
} |
|||
|
|||
resp, err := DoAuthenticatedRequest(session, req) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("error sending request: %v", err) |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
var result map[string]interface{} |
|||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { |
|||
return nil, fmt.Errorf("error decoding response: %v", err) |
|||
} |
|||
|
|||
return result, nil |
|||
} |
|||
|
|||
func GenerateDeleteEndpoints(data map[string]interface{}, filenames []string) []string { |
|||
var endpoints []string |
|||
filenamesToDeleteMap := make(map[string]struct{}) |
|||
for _, name := range filenames { |
|||
filenamesToDeleteMap[strings.ToLower(strings.TrimSpace(name))] = struct{}{} |
|||
} |
|||
|
|||
if dataMap, ok := data["data"].(map[string]interface{}); ok { |
|||
if attachments, ok := dataMap["attachments"].([]interface{}); ok { |
|||
for _, item := range attachments { |
|||
attachment := item.(map[string]interface{}) |
|||
if filename, ok := attachment["fileName"].(string); ok { |
|||
trimmedFilename := strings.ToLower(strings.TrimSpace(filename)) |
|||
if _, exists := filenamesToDeleteMap[trimmedFilename]; exists { |
|||
endpoints = append(endpoints, fmt.Sprintf("%s/attachment/%d", BaseURL, int64(attachment["id"].(float64)))) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
return endpoints |
|||
} |
|||
|
|||
func DeleteAttachment(session *auth.Session, endpoint string) error { |
|||
req, err := AuthenticatedRequest(session, "DELETE", endpoint, nil) |
|||
if err != nil { |
|||
return fmt.Errorf("failed to create DELETE request: %v", err) |
|||
} |
|||
|
|||
resp, err := DoAuthenticatedRequest(session, req) |
|||
if err != nil { |
|||
return fmt.Errorf("failed to send DELETE request: %v", err) |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
if resp.StatusCode != 200 && resp.StatusCode != 204 { |
|||
return fmt.Errorf("failed to delete attachment: %s", resp.Status) |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"io" |
|||
"marmic/servicetrade-toolbox/internal/auth" |
|||
"net/http" |
|||
) |
|||
|
|||
// AuthenticatedRequest wraps an http.Request with the session cookie
|
|||
func AuthenticatedRequest(session *auth.Session, method, url string, body io.Reader) (*http.Request, error) { |
|||
req, err := http.NewRequest(method, url, body) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
req.Header.Set("Cookie", session.Cookie) |
|||
return req, nil |
|||
} |
|||
|
|||
// DoAuthenticatedRequest performs an authenticated request and returns the response
|
|||
func DoAuthenticatedRequest(session *auth.Session, req *http.Request) (*http.Response, error) { |
|||
return session.Client.Do(req) |
|||
} |
|||
@ -0,0 +1,20 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"fmt" |
|||
"net/http" |
|||
) |
|||
|
|||
const BaseURL = "https://api.servicetrade.com/api" |
|||
|
|||
// DecodeJSONResponse decodes a JSON response into the provided interface
|
|||
func DecodeJSONResponse(resp *http.Response, v interface{}) error { |
|||
defer resp.Body.Close() |
|||
|
|||
if resp.StatusCode != http.StatusOK { |
|||
return fmt.Errorf("API request failed with status code: %d", resp.StatusCode) |
|||
} |
|||
|
|||
return json.NewDecoder(resp.Body).Decode(v) |
|||
} |
|||
@ -0,0 +1,41 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"bytes" |
|||
"encoding/json" |
|||
"fmt" |
|||
"io" |
|||
"marmic/servicetrade-toolbox/internal/auth" |
|||
) |
|||
|
|||
func GetDeficiencyById(session *auth.Session, deficiencyId string) (map[string]interface{}, error) { |
|||
url := fmt.Sprintf("%s/deficiency/%s", BaseURL, deficiencyId) |
|||
req, err := AuthenticatedRequest(session, "GET", url, nil) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("error creating request: %v", err) |
|||
} |
|||
|
|||
resp, err := DoAuthenticatedRequest(session, req) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("error sending request: %v", err) |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
if resp.StatusCode != 200 { |
|||
return nil, fmt.Errorf("API request failed with status code: %d", resp.StatusCode) |
|||
} |
|||
|
|||
body, err := io.ReadAll(resp.Body) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("error reading response body: %v", err) |
|||
} |
|||
|
|||
var result map[string]interface{} |
|||
d := json.NewDecoder(bytes.NewReader(body)) |
|||
d.UseNumber() |
|||
if err := d.Decode(&result); err != nil { |
|||
return nil, fmt.Errorf("error decoding response: %v", err) |
|||
} |
|||
|
|||
return result, nil |
|||
} |
|||
@ -0,0 +1,82 @@ |
|||
package auth |
|||
|
|||
import ( |
|||
"bytes" |
|||
"encoding/json" |
|||
"fmt" |
|||
"io" |
|||
"net/http" |
|||
"strings" |
|||
) |
|||
|
|||
type Session struct { |
|||
Client *http.Client |
|||
Cookie string |
|||
} |
|||
|
|||
func NewSession() *Session { |
|||
return &Session{Client: &http.Client{}} |
|||
} |
|||
|
|||
func (s *Session) Login(email, password string) error { |
|||
url := "https://api.servicetrade.com/api/auth" |
|||
payload := map[string]string{ |
|||
"username": email, |
|||
"password": password, |
|||
} |
|||
payloadBytes, _ := json.Marshal(payload) |
|||
|
|||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes)) |
|||
if err != nil { |
|||
return fmt.Errorf("error creating request: %v", err) |
|||
} |
|||
req.Header.Set("Content-Type", "application/json") |
|||
|
|||
resp, err := s.Client.Do(req) |
|||
if err != nil { |
|||
return fmt.Errorf("error sending request: %v", err) |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
body, _ := io.ReadAll(resp.Body) |
|||
|
|||
if resp.StatusCode != 200 { |
|||
return fmt.Errorf("failed to authenticate: %s, response: %s", resp.Status, string(body)) |
|||
} |
|||
|
|||
for _, cookie := range resp.Cookies() { |
|||
if strings.Contains(cookie.Name, "PHPSESSID") { |
|||
s.Cookie = cookie.String() |
|||
return nil |
|||
} |
|||
} |
|||
|
|||
return fmt.Errorf("failed to retrieve session cookie; authentication may have failed") |
|||
} |
|||
|
|||
func (s *Session) Logout() error { |
|||
if s.Cookie == "" { |
|||
return fmt.Errorf("no active session to end") |
|||
} |
|||
|
|||
url := "https://api.servicetrade.com/api/auth" |
|||
req, err := http.NewRequest("DELETE", url, nil) |
|||
if err != nil { |
|||
return fmt.Errorf("failed to create DELETE request to end session: %v", err) |
|||
} |
|||
req.Header.Set("Cookie", s.Cookie) |
|||
|
|||
resp, err := s.Client.Do(req) |
|||
if err != nil { |
|||
return fmt.Errorf("failed to send DELETE request to end session: %v", err) |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
if resp.StatusCode != 200 && resp.StatusCode != 204 { |
|||
body, _ := io.ReadAll(resp.Body) |
|||
return fmt.Errorf("failed to end session: %s, response: %s", resp.Status, string(body)) |
|||
} |
|||
|
|||
s.Cookie = "" |
|||
return nil |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
package handlers |
|||
|
|||
import ( |
|||
"html/template" |
|||
"net/http" |
|||
) |
|||
|
|||
func AdminHandler(w http.ResponseWriter, r *http.Request) { |
|||
if r.Header.Get("HX-Request") == "true" { |
|||
// This is an HTMX request, return only the jobs partial
|
|||
tmpl := template.Must(template.ParseFiles("templates/partials/jobs.html")) |
|||
jobs := r.Cookies() // Replace with actual data fetching
|
|||
tmpl.Execute(w, jobs) |
|||
} else { |
|||
// This is a full page request
|
|||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/partials/admin.html")) |
|||
jobs := []string{"Job 1", "Job 2", "Job 3"} // Replace with actual data fetching
|
|||
|
|||
tmpl.Execute(w, map[string]interface{}{ |
|||
"Title": "Jobs", |
|||
"Jobs": jobs, |
|||
}) |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
package handlers |
|||
|
|||
import ( |
|||
"html/template" |
|||
"net/http" |
|||
) |
|||
|
|||
func AssetsHandler(w http.ResponseWriter, r *http.Request) { |
|||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/assets.html")) |
|||
tmpl.Execute(w, nil) |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
package handlers |
|||
|
|||
import ( |
|||
"html/template" |
|||
"net/http" |
|||
) |
|||
|
|||
func CompaniesHandler(w http.ResponseWriter, r *http.Request) { |
|||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/companies.html")) |
|||
tmpl.Execute(w, nil) |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
package handlers |
|||
|
|||
import ( |
|||
"html/template" |
|||
"net/http" |
|||
) |
|||
|
|||
func ContactsHandler(w http.ResponseWriter, r *http.Request) { |
|||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/contacts.html")) |
|||
tmpl.Execute(w, nil) |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
package handlers |
|||
|
|||
import ( |
|||
"html/template" |
|||
"net/http" |
|||
) |
|||
|
|||
func ContractsHandler(w http.ResponseWriter, r *http.Request) { |
|||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/contracts.html")) |
|||
tmpl.Execute(w, nil) |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
package handlers |
|||
|
|||
import ( |
|||
"html/template" |
|||
"net/http" |
|||
) |
|||
|
|||
func DashboardHandler(w http.ResponseWriter, r *http.Request) { |
|||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/dashboard.html")) |
|||
tmpl.Execute(w, nil) |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
package handlers |
|||
|
|||
import ( |
|||
"html/template" |
|||
"net/http" |
|||
) |
|||
|
|||
func GenericHandler(w http.ResponseWriter, r *http.Request) { |
|||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/generic.html")) |
|||
tmpl.Execute(w, nil) |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
package handlers |
|||
|
|||
import ( |
|||
"html/template" |
|||
"net/http" |
|||
) |
|||
|
|||
func InvoicesHandler(w http.ResponseWriter, r *http.Request) { |
|||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/invoices.html")) |
|||
tmpl.Execute(w, nil) |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
package handlers |
|||
|
|||
import ( |
|||
"html/template" |
|||
"net/http" |
|||
) |
|||
|
|||
func JobsHandler(w http.ResponseWriter, r *http.Request) { |
|||
if r.Header.Get("HX-Request") == "true" { |
|||
// This is an HTMX request, return only the jobs partial
|
|||
tmpl := template.Must(template.ParseFiles("templates/partials/jobs.html")) |
|||
jobs := []string{"Job 1", "Job 2", "Job 3"} // Replace with actual data fetching
|
|||
tmpl.Execute(w, jobs) |
|||
} else { |
|||
// This is a full page request
|
|||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/partials/jobs.html")) |
|||
jobs := []string{"Job 1", "Job 2", "Job 3"} // Replace with actual data fetching
|
|||
|
|||
tmpl.Execute(w, map[string]interface{}{ |
|||
"Title": "Jobs", |
|||
"Jobs": jobs, |
|||
}) |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
package handlers |
|||
|
|||
import ( |
|||
"html/template" |
|||
"net/http" |
|||
) |
|||
|
|||
func LocationsHandler(w http.ResponseWriter, r *http.Request) { |
|||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/locations.html")) |
|||
tmpl.Execute(w, nil) |
|||
} |
|||
@ -0,0 +1,103 @@ |
|||
package handlers |
|||
|
|||
import ( |
|||
"html/template" |
|||
"log" |
|||
"marmic/servicetrade-toolbox/internal/api" |
|||
"net/http" |
|||
"strings" |
|||
) |
|||
|
|||
func LoginHandler(w http.ResponseWriter, r *http.Request) { |
|||
|
|||
if r.Method == "GET" { |
|||
tmpl := template.Must(template.ParseFiles("templates/login.html")) |
|||
tmpl.Execute(w, nil) |
|||
return |
|||
} |
|||
|
|||
if r.Method == "POST" { |
|||
email := r.FormValue("email") |
|||
password := r.FormValue("password") |
|||
|
|||
session := api.NewSession() |
|||
err := session.Login(email, password) |
|||
if err != nil { |
|||
if r.Header.Get("HX-Request") == "true" { |
|||
w.Write([]byte("<div class='error'>Login failed: " + err.Error() + "</div>")) |
|||
} else { |
|||
http.Error(w, "Login failed", http.StatusUnauthorized) |
|||
} |
|||
return |
|||
} |
|||
cookieParts := strings.Split(session.Cookie, ";") |
|||
sessionId := strings.TrimPrefix(cookieParts[0], "PHPSESSID=") |
|||
// Set session cookie
|
|||
http.SetCookie(w, &http.Cookie{ |
|||
Name: "PHPSESSID", |
|||
Value: sessionId, |
|||
Path: "/", |
|||
HttpOnly: true, |
|||
Secure: r.TLS != nil, |
|||
SameSite: http.SameSiteLaxMode, |
|||
}) |
|||
|
|||
if r.Header.Get("HX-Request") == "true" { |
|||
w.Header().Set("HX-Redirect", "/") |
|||
w.WriteHeader(http.StatusOK) |
|||
w.Write([]byte("Login successful")) |
|||
} else { |
|||
http.Redirect(w, r, "/", http.StatusSeeOther) |
|||
} |
|||
} |
|||
} |
|||
|
|||
func LogoutHandler(w http.ResponseWriter, r *http.Request) { |
|||
cookie, err := r.Cookie("PHPSESSID") |
|||
if err != nil { |
|||
log.Printf("No session cookie found: %v", err) |
|||
|
|||
// Check if the request is an HTMX request
|
|||
if r.Header.Get("HX-Request") != "" { |
|||
// Use HX-Redirect to redirect the entire page to the login page
|
|||
w.Header().Set("HX-Redirect", "/login") |
|||
w.WriteHeader(http.StatusOK) |
|||
} else { |
|||
http.Redirect(w, r, "/login", http.StatusSeeOther) |
|||
} |
|||
return |
|||
} |
|||
|
|||
session := api.NewSession() |
|||
session.Cookie = "PHPSESSID=" + cookie.Value |
|||
|
|||
err = session.Logout() |
|||
if err != nil { |
|||
log.Printf("Logout failed: %v", err) |
|||
http.Error(w, "Logout failed", http.StatusInternalServerError) |
|||
return |
|||
} |
|||
|
|||
// Clear the session cookie
|
|||
http.SetCookie(w, &http.Cookie{ |
|||
Name: "PHPSESSID", |
|||
Value: "", |
|||
Path: "/", |
|||
MaxAge: -1, |
|||
HttpOnly: true, |
|||
Secure: r.TLS != nil, |
|||
SameSite: http.SameSiteLaxMode, |
|||
}) |
|||
|
|||
log.Println("Logout successful, redirecting to login page") |
|||
|
|||
// Check if the request is an HTMX request
|
|||
if r.Header.Get("HX-Request") != "" { |
|||
// Use HX-Redirect to ensure the entire page is redirected to the login page
|
|||
w.Header().Set("HX-Redirect", "/login") |
|||
w.WriteHeader(http.StatusOK) |
|||
} else { |
|||
// If not an HTMX request, perform a full-page redirect
|
|||
http.Redirect(w, r, "/login", http.StatusSeeOther) |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
package handlers |
|||
|
|||
import ( |
|||
"html/template" |
|||
"net/http" |
|||
) |
|||
|
|||
func NotificationsHandler(w http.ResponseWriter, r *http.Request) { |
|||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/notifications.html")) |
|||
tmpl.Execute(w, nil) |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
package handlers |
|||
|
|||
import ( |
|||
"html/template" |
|||
"net/http" |
|||
) |
|||
|
|||
func QuotesHandler(w http.ResponseWriter, r *http.Request) { |
|||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/quotes.html")) |
|||
tmpl.Execute(w, nil) |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
package handlers |
|||
|
|||
import ( |
|||
"html/template" |
|||
"net/http" |
|||
) |
|||
|
|||
func ServicesHandler(w http.ResponseWriter, r *http.Request) { |
|||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/services.html")) |
|||
tmpl.Execute(w, nil) |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
package handlers |
|||
|
|||
import ( |
|||
"html/template" |
|||
"net/http" |
|||
) |
|||
|
|||
func TagsHandler(w http.ResponseWriter, r *http.Request) { |
|||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/tags.html")) |
|||
tmpl.Execute(w, nil) |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
package handlers |
|||
|
|||
import ( |
|||
"html/template" |
|||
"net/http" |
|||
) |
|||
|
|||
func UsersHandler(w http.ResponseWriter, r *http.Request) { |
|||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/users.html")) |
|||
tmpl.Execute(w, nil) |
|||
} |
|||
@ -0,0 +1,189 @@ |
|||
package menu |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"fmt" |
|||
"marmic/servicetrade-toolbox/internal/api" |
|||
"marmic/servicetrade-toolbox/internal/ui" |
|||
) |
|||
|
|||
func HandleJobs(session *api.Session) { |
|||
jobsMenu := getJobsMenu() |
|||
for { |
|||
choice := DisplayMenuAndGetChoice(jobsMenu, "Jobs Menu") |
|||
if choice == len(jobsMenu)+1 { |
|||
return // Go back to main menu
|
|||
} |
|||
jobsMenu[choice-1].Handler(session) |
|||
} |
|||
} |
|||
|
|||
func getJobsMenu() []MenuItem { |
|||
return []MenuItem{ |
|||
{"Search Job by ID", searchJobByID, nil}, |
|||
{"List Recent Jobs", listRecentJobs, nil}, |
|||
{"Create New Job", createNewJob, nil}, |
|||
{"Manage Job Attachments", manageJobAttachments, nil}, |
|||
{"View Deficiencies", viewDeficiencyById, nil}, |
|||
} |
|||
} |
|||
|
|||
func searchJobByID(session *api.Session) { |
|||
ui.ClearScreen() |
|||
ui.DisplayMessage("We will search a job ID here.") |
|||
ui.PressEnterToContinue() |
|||
} |
|||
|
|||
func listRecentJobs(session *api.Session) { |
|||
ui.ClearScreen() |
|||
ui.DisplayMessage("Listing recent jobs...") |
|||
ui.PressEnterToContinue() |
|||
} |
|||
|
|||
func createNewJob(session *api.Session) { |
|||
ui.ClearScreen() |
|||
ui.DisplayMessage("Creating a new job...") |
|||
ui.PressEnterToContinue() |
|||
} |
|||
|
|||
func manageJobAttachments(session *api.Session) { |
|||
ui.ClearScreen() |
|||
jobID := ui.PromptForInput("Enter Job ID: ") |
|||
|
|||
attachments, err := session.GetAttachmentsForJob(jobID) |
|||
if err != nil { |
|||
ui.DisplayError("Failed to retrieve attachments:", err) |
|||
return |
|||
} |
|||
|
|||
// Display attachments
|
|||
ui.DisplayMessage("Attachments for Job " + jobID + ":") |
|||
if dataMap, ok := attachments["data"].(map[string]interface{}); ok { |
|||
if attachmentsList, ok := dataMap["attachments"].([]interface{}); ok { |
|||
for i, attachment := range attachmentsList { |
|||
if att, ok := attachment.(map[string]interface{}); ok { |
|||
fmt.Printf("%d. %s\n", i+1, att["fileName"]) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Prompt for attachments to delete
|
|||
toDelete := ui.PromptForInput("Enter the numbers of attachments to delete (comma-separated), or 'all' for all: ") |
|||
|
|||
var filesToDelete []string |
|||
if toDelete == "all" { |
|||
if dataMap, ok := attachments["data"].(map[string]interface{}); ok { |
|||
if attachmentsList, ok := dataMap["attachments"].([]interface{}); ok { |
|||
for _, attachment := range attachmentsList { |
|||
if att, ok := attachment.(map[string]interface{}); ok { |
|||
filesToDelete = append(filesToDelete, att["fileName"].(string)) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} else { |
|||
// Parse the input and get the corresponding filenames
|
|||
// This part needs to be implemented
|
|||
} |
|||
|
|||
// Generate delete endpoints
|
|||
endpoints := generateDeleteEndpoints(attachments, filesToDelete) |
|||
|
|||
// Confirm deletion
|
|||
ui.DisplayMessage(fmt.Sprintf("You are about to delete %d attachments. Are you sure? (y/n)", len(endpoints))) |
|||
confirm := ui.PromptForInput("") |
|||
if confirm != "y" { |
|||
ui.DisplayMessage("Deletion cancelled.") |
|||
return |
|||
} |
|||
|
|||
// Perform deletion
|
|||
for _, endpoint := range endpoints { |
|||
err := session.DeleteAttachment(endpoint) |
|||
if err != nil { |
|||
ui.DisplayError(fmt.Sprintf("Failed to delete attachment %s:", endpoint), err) |
|||
} else { |
|||
ui.DisplayMessage(fmt.Sprintf("Successfully deleted attachment: %s", endpoint)) |
|||
} |
|||
} |
|||
|
|||
ui.DisplayMessage("Attachment management completed.") |
|||
ui.PressEnterToContinue() |
|||
} |
|||
|
|||
func viewDeficiencyById(session *api.Session) { |
|||
ui.ClearScreen() |
|||
deficiencyId := ui.PromptForInput("Enter Deficiency ID: ") |
|||
|
|||
ui.DisplayMessage(fmt.Sprintf("Fetching information for Deficiency %s...", deficiencyId)) |
|||
|
|||
result, err := session.GetDeficiencyById(deficiencyId) |
|||
if err != nil { |
|||
ui.DisplayError("Failed to retrieve deficiency information:", err) |
|||
ui.PressEnterToContinue() |
|||
return |
|||
} |
|||
|
|||
ui.ClearScreen() |
|||
ui.DisplayMessage(fmt.Sprintf("Information for Deficiency %s:", deficiencyId)) |
|||
|
|||
if data, ok := result["data"].(map[string]interface{}); ok { |
|||
if len(data) == 0 { |
|||
ui.DisplayMessage("No information found for this deficiency.") |
|||
} else { |
|||
fmt.Println("Deficiency Details:") |
|||
for key, value := range data { |
|||
fmt.Printf("- %s:\n", key) |
|||
if details, ok := value.(map[string]interface{}); ok { |
|||
for detailKey, detailValue := range details { |
|||
if detailValue != nil { |
|||
// Handle potential json.Number values
|
|||
if num, ok := detailValue.(json.Number); ok { |
|||
fmt.Printf(" %s: %s\n", detailKey, num.String()) |
|||
} else { |
|||
fmt.Printf(" %s: %v\n", detailKey, detailValue) |
|||
} |
|||
} |
|||
} |
|||
} else if num, ok := value.(json.Number); ok { |
|||
fmt.Printf(" %s\n", num.String()) |
|||
} else { |
|||
fmt.Printf(" %v\n", value) |
|||
} |
|||
fmt.Println() |
|||
} |
|||
} |
|||
} else { |
|||
ui.DisplayMessage("Unexpected data structure in the API response.") |
|||
fmt.Printf("Response structure: %+v\n", result) |
|||
} |
|||
|
|||
ui.DisplayMessage("\nDeficiency information viewing completed.") |
|||
ui.PressEnterToContinue() |
|||
} |
|||
|
|||
// Helper function to generate delete endpoints
|
|||
func generateDeleteEndpoints(data map[string]interface{}, filenames []string) []string { |
|||
var endpoints []string |
|||
|
|||
filenamesToDeleteMap := make(map[string]struct{}) |
|||
for _, name := range filenames { |
|||
filenamesToDeleteMap[name] = struct{}{} |
|||
} |
|||
|
|||
if dataMap, ok := data["data"].(map[string]interface{}); ok { |
|||
if attachments, ok := dataMap["attachments"].([]interface{}); ok { |
|||
for _, item := range attachments { |
|||
attachment := item.(map[string]interface{}) |
|||
if filename, ok := attachment["fileName"].(string); ok { |
|||
if _, exists := filenamesToDeleteMap[filename]; exists { |
|||
endpoints = append(endpoints, fmt.Sprintf("https://api.servicetrade.com/api/attachment/%d", int64(attachment["id"].(float64)))) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
return endpoints |
|||
} |
|||
@ -0,0 +1,129 @@ |
|||
package menu |
|||
|
|||
import ( |
|||
"marmic/servicetrade-toolbox/internal/api" |
|||
"marmic/servicetrade-toolbox/internal/ui" |
|||
) |
|||
|
|||
type MenuItem struct { |
|||
Name string |
|||
Handler func(*api.Session) |
|||
Submenu []MenuItem |
|||
} |
|||
|
|||
func GetMainMenu() []MenuItem { |
|||
return []MenuItem{ |
|||
{"Administration", HandleAdministration, nil}, |
|||
{"Assets", HandleAssets, nil}, |
|||
{"Companies", HandleCompanies, nil}, |
|||
{"Contacts", HandleContacts, nil}, |
|||
{"Contracts", HandleContracts, nil}, |
|||
{"Generic Tools", HandleGenericTools, nil}, |
|||
{"Invoices", HandleInvoices, nil}, |
|||
{"Jobs", HandleJobs, nil}, |
|||
{"Locations", HandleLocations, nil}, |
|||
{"Notifications", HandleNotifications, nil}, |
|||
{"Quotes", HandleQuotes, nil}, |
|||
{"Services", HandleServices, nil}, |
|||
{"Tags", HandleTags, nil}, |
|||
{"Users", HandleUsers, nil}, |
|||
} |
|||
} |
|||
|
|||
func DisplayMenuAndGetChoice(menuItems []MenuItem, title string) int { |
|||
for { |
|||
displayMenuItems := make([]string, len(menuItems)+1) |
|||
for i, item := range menuItems { |
|||
displayMenuItems[i] = item.Name |
|||
} |
|||
displayMenuItems[len(menuItems)] = "Back/Exit" |
|||
|
|||
ui.DisplayMenu(displayMenuItems, title) |
|||
|
|||
choice, err := ui.GetUserChoice(len(displayMenuItems)) |
|||
if err == nil { |
|||
return choice |
|||
} |
|||
ui.DisplayMessage("Invalid input. Please try again.") |
|||
ui.PressEnterToContinue() |
|||
} |
|||
} |
|||
|
|||
// Placeholder functions for other main menu items
|
|||
func HandleAdministration(session *api.Session) { |
|||
ui.ClearScreen() |
|||
ui.DisplayMessage("Administration menu placeholder") |
|||
ui.PressEnterToContinue() |
|||
} |
|||
|
|||
func HandleAssets(session *api.Session) { |
|||
ui.ClearScreen() |
|||
ui.DisplayMessage("Assets menu placeholder") |
|||
ui.PressEnterToContinue() |
|||
} |
|||
|
|||
func HandleCompanies(session *api.Session) { |
|||
ui.ClearScreen() |
|||
ui.DisplayMessage("Companies menu placeholder") |
|||
ui.PressEnterToContinue() |
|||
} |
|||
|
|||
func HandleContacts(session *api.Session) { |
|||
ui.ClearScreen() |
|||
ui.DisplayMessage("Contacts menu placeholder") |
|||
ui.PressEnterToContinue() |
|||
} |
|||
|
|||
func HandleContracts(session *api.Session) { |
|||
ui.ClearScreen() |
|||
ui.DisplayMessage("Contracts menu placeholder") |
|||
ui.PressEnterToContinue() |
|||
} |
|||
|
|||
func HandleGenericTools(session *api.Session) { |
|||
ui.ClearScreen() |
|||
ui.DisplayMessage("Generic Tools menu placeholder") |
|||
ui.PressEnterToContinue() |
|||
} |
|||
|
|||
func HandleInvoices(session *api.Session) { |
|||
ui.ClearScreen() |
|||
ui.DisplayMessage("Invoices menu placeholder") |
|||
ui.PressEnterToContinue() |
|||
} |
|||
|
|||
func HandleLocations(session *api.Session) { |
|||
ui.ClearScreen() |
|||
ui.DisplayMessage("Locations menu placeholder") |
|||
ui.PressEnterToContinue() |
|||
} |
|||
|
|||
func HandleNotifications(session *api.Session) { |
|||
ui.ClearScreen() |
|||
ui.DisplayMessage("Notifications menu placeholder") |
|||
ui.PressEnterToContinue() |
|||
} |
|||
|
|||
func HandleQuotes(session *api.Session) { |
|||
ui.ClearScreen() |
|||
ui.DisplayMessage("Quotes menu placeholder") |
|||
ui.PressEnterToContinue() |
|||
} |
|||
|
|||
func HandleServices(session *api.Session) { |
|||
ui.ClearScreen() |
|||
ui.DisplayMessage("Services menu placeholder") |
|||
ui.PressEnterToContinue() |
|||
} |
|||
|
|||
func HandleTags(session *api.Session) { |
|||
ui.ClearScreen() |
|||
ui.DisplayMessage("Tags menu placeholder") |
|||
ui.PressEnterToContinue() |
|||
} |
|||
|
|||
func HandleUsers(session *api.Session) { |
|||
ui.ClearScreen() |
|||
ui.DisplayMessage("Users menu placeholder") |
|||
ui.PressEnterToContinue() |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
package middleware |
|||
|
|||
import ( |
|||
"marmic/servicetrade-toolbox/internal/api" |
|||
"net/http" |
|||
) |
|||
|
|||
func AuthMiddleware(next http.Handler) http.Handler { |
|||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
|||
cookie, err := r.Cookie("PHPSESSID") |
|||
if err != nil { |
|||
http.Redirect(w, r, "/login", http.StatusSeeOther) |
|||
return |
|||
} |
|||
|
|||
session := api.NewSession() |
|||
session.Cookie = "PHPSESSID=" + cookie.Value |
|||
|
|||
// You might want to add a method to validate the session token
|
|||
// For now, we'll assume if the cookie exists, the session is valid
|
|||
|
|||
next.ServeHTTP(w, r) |
|||
}) |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
package models |
|||
|
|||
type Job struct { |
|||
ID int `json:"id"` |
|||
Title string `json:"title"` |
|||
Description string `json:"description"` |
|||
Status string `json:"status"` |
|||
} |
|||
|
|||
type Asset struct { |
|||
ID int `json:"id"` |
|||
Name string `json:"name"` |
|||
Type string `json:"type"` |
|||
Location string `json:"location"` |
|||
} |
|||
|
|||
type Company struct { |
|||
ID int `json:"id"` |
|||
Name string `json:"name"` |
|||
Address string `json:"address"` |
|||
Phone string `json:"phone"` |
|||
} |
|||
@ -0,0 +1,79 @@ |
|||
package ui |
|||
|
|||
import ( |
|||
"bufio" |
|||
"fmt" |
|||
"os" |
|||
"strconv" |
|||
"strings" |
|||
|
|||
"github.com/inancgumus/screen" |
|||
) |
|||
|
|||
func ClearScreen() { |
|||
screen.Clear() |
|||
screen.MoveTopLeft() |
|||
} |
|||
|
|||
func DisplayStartScreen() { |
|||
ClearScreen() |
|||
fmt.Println("========================================") |
|||
fmt.Println(" Welcome to ServiceTrade CLI") |
|||
fmt.Println("========================================") |
|||
fmt.Println("Please log in with your ServiceTrade credentials to continue.") |
|||
fmt.Println() |
|||
} |
|||
|
|||
func PromptCredentials() (string, string, error) { |
|||
reader := bufio.NewReader(os.Stdin) |
|||
|
|||
fmt.Print("Enter your email: ") |
|||
email, _ := reader.ReadString('\n') |
|||
email = strings.TrimSpace(email) |
|||
|
|||
fmt.Print("Enter your password: ") |
|||
password, _ := reader.ReadString('\n') |
|||
password = strings.TrimSpace(password) |
|||
|
|||
return email, password, nil |
|||
} |
|||
|
|||
func DisplayMessage(message string) { |
|||
fmt.Println(message) |
|||
} |
|||
|
|||
func DisplayError(prefix string, err error) { |
|||
fmt.Printf("%s %v\n", prefix, err) |
|||
} |
|||
|
|||
func PressEnterToContinue() { |
|||
fmt.Println("Press Enter to continue...") |
|||
bufio.NewReader(os.Stdin).ReadBytes('\n') |
|||
} |
|||
|
|||
func DisplayMenu(items []string, title string) { |
|||
ClearScreen() |
|||
fmt.Printf("\n%s:\n", title) |
|||
for i, item := range items { |
|||
fmt.Printf("%d. %s\n", i+1, item) |
|||
} |
|||
} |
|||
|
|||
func GetUserChoice(max int) (int, error) { |
|||
reader := bufio.NewReader(os.Stdin) |
|||
fmt.Printf("\nEnter your choice (1-%d): ", max) |
|||
input, _ := reader.ReadString('\n') |
|||
input = strings.TrimSpace(input) |
|||
choice, err := strconv.Atoi(input) |
|||
if err != nil || choice < 1 || choice > max { |
|||
return 0, fmt.Errorf("invalid input") |
|||
} |
|||
return choice, nil |
|||
} |
|||
|
|||
func PromptForInput(prompt string) string { |
|||
reader := bufio.NewReader(os.Stdin) |
|||
fmt.Print(prompt) |
|||
input, _ := reader.ReadString('\n') |
|||
return strings.TrimSpace(input) |
|||
} |
|||
@ -0,0 +1,85 @@ |
|||
package utils |
|||
|
|||
import ( |
|||
"bufio" |
|||
"encoding/csv" |
|||
"fmt" |
|||
"os" |
|||
"path/filepath" |
|||
"strings" |
|||
) |
|||
|
|||
// ReadCSV reads a CSV file and returns its contents as a slice of string slices
|
|||
func ReadCSV(filename string) ([][]string, error) { |
|||
file, err := os.Open(filename) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
defer file.Close() |
|||
|
|||
reader := csv.NewReader(file) |
|||
return reader.ReadAll() |
|||
} |
|||
|
|||
// WriteCSV writes a slice of string slices to a CSV file
|
|||
func WriteCSV(filename string, data [][]string) error { |
|||
file, err := os.Create(filename) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
defer file.Close() |
|||
|
|||
writer := csv.NewWriter(file) |
|||
defer writer.Flush() |
|||
|
|||
return writer.WriteAll(data) |
|||
} |
|||
|
|||
// ChooseFile allows the user to select a file from the current directory
|
|||
func ChooseFile(extension string) (string, error) { |
|||
currentDir, err := os.Getwd() |
|||
if err != nil { |
|||
return "", err |
|||
} |
|||
|
|||
files, err := filepath.Glob(filepath.Join(currentDir, "*"+extension)) |
|||
if err != nil { |
|||
return "", err |
|||
} |
|||
|
|||
if len(files) == 0 { |
|||
return "", fmt.Errorf("no %s files found in the current directory", extension) |
|||
} |
|||
|
|||
fmt.Printf("Available %s files:\n", extension) |
|||
for i, file := range files { |
|||
fmt.Printf("%d. %s\n", i+1, filepath.Base(file)) |
|||
} |
|||
|
|||
var choice int |
|||
fmt.Print("Enter the number of the file you want to select: ") |
|||
_, err = fmt.Scanf("%d", &choice) |
|||
if err != nil || choice < 1 || choice > len(files) { |
|||
return "", fmt.Errorf("invalid selection") |
|||
} |
|||
|
|||
return files[choice-1], nil |
|||
} |
|||
|
|||
// PromptYesNo prompts the user for a yes/no response
|
|||
func PromptYesNo(prompt string) bool { |
|||
reader := bufio.NewReader(os.Stdin) |
|||
for { |
|||
fmt.Printf("%s (y/n): ", prompt) |
|||
response, _ := reader.ReadString('\n') |
|||
response = strings.ToLower(strings.TrimSpace(response)) |
|||
|
|||
if response == "y" || response == "yes" { |
|||
return true |
|||
} else if response == "n" || response == "no" { |
|||
return false |
|||
} |
|||
|
|||
fmt.Println("Please answer with 'y' or 'n'.") |
|||
} |
|||
} |
|||
@ -0,0 +1,291 @@ |
|||
/* General reset and body setup */ |
|||
body, html { |
|||
margin: 0; |
|||
height: 100%; |
|||
font-family: Arial, sans-serif; |
|||
background-color: #f3f4f6; /* similar to bg-gray-100 */ |
|||
} |
|||
|
|||
.flex { |
|||
display: flex; |
|||
} |
|||
|
|||
.h-screen { |
|||
height: 100vh; |
|||
} |
|||
|
|||
/* Flexbox centering for login */ |
|||
.flex-center { |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
height: 100vh; |
|||
} |
|||
|
|||
.bg-gray { |
|||
background-color: #f3f4f6; |
|||
} |
|||
|
|||
/* Container setup for dashboard */ |
|||
.container { |
|||
display: flex; |
|||
flex-direction: row; |
|||
width: 100%; |
|||
height: 100vh; |
|||
} |
|||
|
|||
/* Sidebar styles */ |
|||
.sidebar { |
|||
width: 16rem; /* Fixed width for sidebar */ |
|||
background-color: #1f2937; /* Dark background */ |
|||
color: white; |
|||
padding: 1rem; |
|||
display: flex; |
|||
flex-direction: column; |
|||
} |
|||
|
|||
.sidebar .title { |
|||
font-size: 1.5rem; |
|||
font-weight: bold; |
|||
margin-bottom: 1.5rem; |
|||
} |
|||
|
|||
.sidebar ul { |
|||
list-style-type: none; |
|||
} |
|||
|
|||
.sidebar a { |
|||
color: white; |
|||
text-decoration: none; |
|||
padding: 0.5rem 0; |
|||
display: block; |
|||
margin-bottom: 0.5rem; |
|||
border-radius: 0.25rem; |
|||
} |
|||
|
|||
.sidebar a:hover { |
|||
background-color: #374151; /* Slightly lighter on hover */ |
|||
} |
|||
|
|||
/* Main content area for dashboard */ |
|||
.main-content { |
|||
flex-grow: 1; /* Take up remaining space */ |
|||
padding: 2rem; |
|||
display: flex; |
|||
flex-direction: column; |
|||
overflow-y: auto; /* Allow scrolling if needed */ |
|||
} |
|||
|
|||
.header { |
|||
display: flex; |
|||
justify-content: flex-end; /* Align logout button to the right */ |
|||
margin-bottom: 2rem; |
|||
} |
|||
|
|||
.logout-btn { |
|||
background-color: #ef4444; /* Red button */ |
|||
color: white; |
|||
padding: 0.5rem 1rem; |
|||
border: none; |
|||
border-radius: 0.25rem; |
|||
cursor: pointer; |
|||
} |
|||
|
|||
.logout-btn:hover { |
|||
background-color: #dc2626; /* Darker red on hover */ |
|||
} |
|||
|
|||
.content { |
|||
background-color: white; |
|||
padding: 1.5rem; |
|||
border-radius: 0.5rem; |
|||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); |
|||
flex-grow: 1; |
|||
overflow-y: auto; /* Ensure content area can scroll */ |
|||
} |
|||
|
|||
/* Login Container */ |
|||
.login-container { |
|||
background-color: white; |
|||
padding: 2rem; |
|||
border-radius: 0.5rem; |
|||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* Similar to shadow-md */ |
|||
width: 24rem; /* similar to w-96 */ |
|||
} |
|||
|
|||
.login-title { |
|||
font-size: 1.75rem; /* similar to text-2xl */ |
|||
font-weight: bold; |
|||
margin-bottom: 0.5rem; /* similar to mb-6 */ |
|||
text-align: center; |
|||
color: #2d3748; /* similar to text-gray-800 */ |
|||
} |
|||
|
|||
.login-subtitle { |
|||
font-size: 1.25rem; /* Size between h2 and h3 */ |
|||
font-weight: normal; /* Lighter weight to distinguish from the title */ |
|||
margin-bottom: 1.5rem; /* Adjust spacing below subtitle */ |
|||
text-align: center; |
|||
color: #4a5568; /* A slightly lighter color than the title */ |
|||
} |
|||
|
|||
/* Input Fields */ |
|||
.input-group { |
|||
margin-bottom: 1rem; /* similar to mb-4 */ |
|||
} |
|||
|
|||
.input-group label { |
|||
display: block; |
|||
color: #4a5568; /* similar to text-gray-700 */ |
|||
font-size: 0.875rem; /* similar to text-sm */ |
|||
font-weight: bold; |
|||
margin-bottom: 0.5rem; /* similar to mb-2 */ |
|||
} |
|||
|
|||
.input-field { |
|||
width: 100%; |
|||
padding: 0.5rem; |
|||
border: 1px solid #cbd5e0; /* similar to border */ |
|||
border-radius: 0.25rem; /* similar to rounded */ |
|||
color: #4a5568; /* similar to text-gray-700 */ |
|||
font-size: 1rem; |
|||
line-height: 1.5; |
|||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0); /* resets shadow */ |
|||
transition: box-shadow 0.15s ease-in-out; |
|||
} |
|||
|
|||
.input-field:focus { |
|||
outline: none; |
|||
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5); /* similar to focus:shadow-outline */ |
|||
} |
|||
|
|||
/* Form Footer */ |
|||
.form-footer { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
margin-top: 1rem; /* similar to mb-6 */ |
|||
} |
|||
|
|||
.btn-primary { |
|||
background-color: #4299e1; /* similar to bg-blue-500 */ |
|||
color: white; |
|||
padding: 0.5rem 1rem; |
|||
border-radius: 0.25rem; |
|||
border: none; |
|||
font-weight: bold; |
|||
cursor: pointer; |
|||
transition: background-color 0.2s; |
|||
} |
|||
|
|||
.btn-primary:hover { |
|||
background-color: #2b6cb0; /* similar to hover:bg-blue-700 */ |
|||
} |
|||
|
|||
.forgot-password { |
|||
color: #4299e1; /* similar to text-blue-500 */ |
|||
font-size: 0.875rem; /* similar to text-sm */ |
|||
font-weight: bold; |
|||
text-decoration: none; |
|||
} |
|||
|
|||
.forgot-password:hover { |
|||
color: #2b6cb0; /* similar to hover:text-blue-800 */ |
|||
} |
|||
|
|||
/* Login Message */ |
|||
.login-message { |
|||
margin-top: 1rem; |
|||
text-align: center; |
|||
font-size: 0.875rem; |
|||
} |
|||
|
|||
/* Dashboard-specific styles */ |
|||
.dashboard-grid { |
|||
display: grid; |
|||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); |
|||
gap: 1rem; |
|||
margin-top: 1rem; |
|||
} |
|||
|
|||
.dashboard-item { |
|||
background-color: white; |
|||
padding: 1rem; |
|||
border-radius: 0.5rem; |
|||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); |
|||
} |
|||
|
|||
.dashboard-item h3 { |
|||
margin-top: 0; |
|||
margin-bottom: 1rem; |
|||
} |
|||
|
|||
/* New styles for jobs submenu */ |
|||
.submenu-container { |
|||
margin-bottom: 2rem; |
|||
} |
|||
|
|||
.submenu-header { |
|||
font-size: 1.5rem; |
|||
font-weight: bold; |
|||
color: #2d3748; |
|||
margin-bottom: 1rem; |
|||
} |
|||
|
|||
.submenu-grid { |
|||
display: grid; |
|||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); |
|||
gap: 1rem; |
|||
} |
|||
|
|||
.submenu-item { |
|||
background-color: white; |
|||
border-radius: 0.5rem; |
|||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); |
|||
padding: 1rem; |
|||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; |
|||
} |
|||
|
|||
.submenu-item:hover { |
|||
transform: translateY(-2px); |
|||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); |
|||
} |
|||
|
|||
.submenu-title { |
|||
font-size: 1.25rem; |
|||
font-weight: bold; |
|||
color: #4a5568; |
|||
margin-bottom: 0.5rem; |
|||
} |
|||
|
|||
/* Style for lists within submenu items */ |
|||
.submenu-item ul { |
|||
list-style-type: none; |
|||
padding-left: 0; |
|||
margin-top: 0.5rem; |
|||
} |
|||
|
|||
.submenu-item li { |
|||
font-size: 0.875rem; |
|||
color: #718096; |
|||
padding: 0.25rem 0; |
|||
} |
|||
|
|||
/* Style for buttons or links within submenu items */ |
|||
.submenu-item a, .submenu-item button { |
|||
display: inline-block; |
|||
background-color: #4299e1; |
|||
color: white; |
|||
padding: 0.5rem 1rem; |
|||
border-radius: 0.25rem; |
|||
text-decoration: none; |
|||
font-size: 0.875rem; |
|||
font-weight: bold; |
|||
transition: background-color 0.2s; |
|||
border: none; |
|||
cursor: pointer; |
|||
} |
|||
|
|||
.submenu-item a:hover, .submenu-item button:hover { |
|||
background-color: #2b6cb0; |
|||
} |
|||
@ -0,0 +1,20 @@ |
|||
{{define "content"}} |
|||
<h2>Dashboard</h2> |
|||
<p>Welcome to the ServiceTrade Tools Dashboard.</p> |
|||
|
|||
<div class="dashboard-grid"> |
|||
<div class="dashboard-item"> |
|||
<h3>Manage Jobs</h3> |
|||
<a href="/jobs" hx-get="/jobs" hx-target="#content">View Jobs</a> |
|||
</div> |
|||
<div class="dashboard-item"> |
|||
<h3>Manage Assets</h3> |
|||
<a href="/assets" hx-get="/assets" hx-target="#content">View Assets</a> |
|||
</div> |
|||
<div class="dashboard-item"> |
|||
<h3>Manage Companies</h3> |
|||
<a href="/companies" hx-get="/companies" hx-target="#content">View Companies</a> |
|||
</div> |
|||
<!-- Add more dashboard items as needed --> |
|||
</div> |
|||
{{end}} |
|||
@ -0,0 +1,45 @@ |
|||
<!DOCTYPE html> |
|||
<html lang="en"> |
|||
<head> |
|||
<meta charset="UTF-8" /> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|||
<title>ServiceTrade Tools</title> |
|||
<script src="https://unpkg.com/htmx.org@1.9.6"></script> |
|||
<link rel="stylesheet" href="/static/css/styles.css" /> |
|||
</head> |
|||
<body class="flex h-screen bg-gray-100"> |
|||
<!-- Sidebar --> |
|||
<aside class="sidebar"> |
|||
<h1 class="title">ServiceTrade Tools</h1> |
|||
<nav> |
|||
<ul> |
|||
<li><a href="/jobs" hx-get="/jobs" hx-target="#content">Jobs</a></li> |
|||
<li><a href="/assets" hx-get="/assets" hx-target="#content">Assets</a></li> |
|||
<li><a href="/companies" hx-get="/companies" hx-target="#content">Companies</a></li> |
|||
<li><a href="/contacts" hx-get="/contacts" hx-target="#content">Contacts</a></li> |
|||
<li><a href="/contracts" hx-get="/contracts" hx-target="#content">Contracts</a></li> |
|||
<li><a href="/generic" hx-get="/generic" hx-target="#content">Generic Tools</a></li> |
|||
<li><a href="/invoices" hx-get="/invoices" hx-target="#content">Invoices</a></li> |
|||
<li><a href="/locations" hx-get="/locations" hx-target="#content">Locations</a></li> |
|||
<li><a href="/notifications" hx-get="/notifications" hx-target="#content">Notifications</a></li> |
|||
<li><a href="/quotes" hx-get="/quotes" hx-target="#content">Quotes</a></li> |
|||
<li><a href="/services" hx-get="/services" hx-target="#content">Services</a></li> |
|||
<li><a href="/tags" hx-get="/tags" hx-target="#content">Tags</a></li> |
|||
<li><a href="/users" hx-get="/users" hx-target="#content">Users</a></li> |
|||
<li><a href="/admin" hx-get="/admin" hx-target="#content">Admin</a></li> |
|||
</ul> |
|||
</nav> |
|||
</aside> |
|||
|
|||
<!-- Main content area --> |
|||
<main class="main-content"> |
|||
<!-- Header with logout --> |
|||
<header class="header"> |
|||
<button class="logout-btn" hx-post="/logout">Logout</button> |
|||
</header> |
|||
|
|||
<!-- Dynamic content area --> |
|||
<div id="content" class="content">{{template "content" .}}</div> |
|||
</main> |
|||
</body> |
|||
</html> |
|||
@ -0,0 +1,31 @@ |
|||
<!DOCTYPE html> |
|||
<html lang="en"> |
|||
<head> |
|||
<meta charset="UTF-8" /> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|||
<title>Login - ServiceTrade Tools</title> |
|||
<script src="https://unpkg.com/htmx.org@1.9.6"></script> |
|||
<link rel="stylesheet" href="/static/css/styles.css" /> |
|||
</head> |
|||
<body class="bg-gray flex-center"> |
|||
<div class="login-container"> |
|||
<h1 class="login-title">ServiceTrade Tools</h1> |
|||
<h2 class="login-subtitle">Walmart Edition</h2> |
|||
<form hx-post="/login" hx-target="#login-message"> |
|||
<div class="input-group"> |
|||
<label for="email">Email</label> |
|||
<input type="email" id="email" name="email" required class="input-field" /> |
|||
</div> |
|||
<div class="input-group"> |
|||
<label for="password">Password</label> |
|||
<input type="password" id="password" name="password" required class="input-field" /> |
|||
</div> |
|||
<div class="form-footer"> |
|||
<button type="submit" class="btn-primary">Sign In</button> |
|||
<a href="#" class="forgot-password">Forgot Password?</a> |
|||
</div> |
|||
</form> |
|||
<div id="login-message" class="login-message"></div> |
|||
</div> |
|||
</body> |
|||
</html> |
|||
@ -0,0 +1,5 @@ |
|||
{{define "content"}} |
|||
<h2>Admin</h2> |
|||
<p>Manage your assets here.</p> |
|||
<!-- Add asset management content --> |
|||
{{end}} |
|||
@ -0,0 +1,5 @@ |
|||
{{define "content"}} |
|||
<h2>Assets</h2> |
|||
<p>Manage your assets here.</p> |
|||
<!-- Add asset management content --> |
|||
{{end}} |
|||
@ -0,0 +1,5 @@ |
|||
{{define "content"}} |
|||
<h2>Companies</h2> |
|||
<p>Manage your companies here.</p> |
|||
<!-- Add company management content --> |
|||
{{end}} |
|||
@ -0,0 +1,28 @@ |
|||
{{define "content"}} |
|||
<div class="submenu-container"> |
|||
<h2 class="submenu-header">Jobs</h2> |
|||
<div class="submenu-grid"> |
|||
<div class="submenu-item"> |
|||
<h3 class="submenu-title">Search Job</h3> |
|||
<input type="text" placeholder="Enter Job ID" class="submenu-input" /> |
|||
<button class="submenu-button">Search</button> |
|||
</div> |
|||
<div class="submenu-item"> |
|||
<h3 class="submenu-title">Recent Jobs</h3> |
|||
<ul class="submenu-list"> |
|||
<li>Job #12345</li> |
|||
<li>Job #67890</li> |
|||
<li>Job #54321</li> |
|||
</ul> |
|||
</div> |
|||
<div class="submenu-item"> |
|||
<h3 class="submenu-title">Create New Job</h3> |
|||
<button class="submenu-button">Create Job</button> |
|||
</div> |
|||
<div class="submenu-item"> |
|||
<h3 class="submenu-title">Job Reports</h3> |
|||
<button class="submenu-button">Generate Report</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
{{end}} |
|||
Loading…
Reference in new issue