Browse Source

to date boilerplate cli and web app

cli-archive
nic 2 years ago
commit
af000b064d
  1. 86
      Readme.md
  2. 46
      apps/cli/main.go
  3. 45
      apps/web/main.go
  4. 14
      go.mod
  5. 10
      go.sum
  6. 196
      internal/api/api.go
  7. 72
      internal/api/attachments.go
  8. 22
      internal/api/auth.go
  9. 20
      internal/api/common.go
  10. 41
      internal/api/deficiencies.go
  11. 82
      internal/auth/auth.go
  12. 24
      internal/handlers/admin.go
  13. 11
      internal/handlers/assets.go
  14. 11
      internal/handlers/companies.go
  15. 11
      internal/handlers/contacts.go
  16. 11
      internal/handlers/contracts.go
  17. 11
      internal/handlers/dashboard.go
  18. 11
      internal/handlers/generic.go
  19. 11
      internal/handlers/invoices.go
  20. 24
      internal/handlers/jobs.go
  21. 11
      internal/handlers/locations.go
  22. 103
      internal/handlers/login.go
  23. 11
      internal/handlers/notifications.go
  24. 11
      internal/handlers/quotes.go
  25. 11
      internal/handlers/services.go
  26. 11
      internal/handlers/tags.go
  27. 11
      internal/handlers/users.go
  28. 189
      internal/menu/jobs.go
  29. 129
      internal/menu/menu.go
  30. 24
      internal/middleware/auth_middleware.go
  31. 22
      internal/models/models.go
  32. 79
      internal/ui/ui.go
  33. 85
      internal/utils/utils.go
  34. 291
      static/css/styles.css
  35. 20
      templates/dashboard.html
  36. 45
      templates/layout.html
  37. 31
      templates/login.html
  38. 5
      templates/partials/admin.html
  39. 5
      templates/partials/assets.html
  40. 5
      templates/partials/companies.html
  41. 28
      templates/partials/jobs.html

86
Readme.md

@ -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
```

46
apps/cli/main.go

@ -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)
}
}

45
apps/web/main.go

@ -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))
}

14
go.mod

@ -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
)

10
go.sum

@ -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=

196
internal/api/api.go

@ -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
}

72
internal/api/attachments.go

@ -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
}

22
internal/api/auth.go

@ -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)
}

20
internal/api/common.go

@ -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)
}

41
internal/api/deficiencies.go

@ -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
}

82
internal/auth/auth.go

@ -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
}

24
internal/handlers/admin.go

@ -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,
})
}
}

11
internal/handlers/assets.go

@ -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)
}

11
internal/handlers/companies.go

@ -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)
}

11
internal/handlers/contacts.go

@ -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)
}

11
internal/handlers/contracts.go

@ -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)
}

11
internal/handlers/dashboard.go

@ -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)
}

11
internal/handlers/generic.go

@ -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)
}

11
internal/handlers/invoices.go

@ -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)
}

24
internal/handlers/jobs.go

@ -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,
})
}
}

11
internal/handlers/locations.go

@ -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)
}

103
internal/handlers/login.go

@ -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)
}
}

11
internal/handlers/notifications.go

@ -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)
}

11
internal/handlers/quotes.go

@ -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)
}

11
internal/handlers/services.go

@ -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)
}

11
internal/handlers/tags.go

@ -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)
}

11
internal/handlers/users.go

@ -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)
}

189
internal/menu/jobs.go

@ -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
}

129
internal/menu/menu.go

@ -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()
}

24
internal/middleware/auth_middleware.go

@ -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)
})
}

22
internal/models/models.go

@ -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"`
}

79
internal/ui/ui.go

@ -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)
}

85
internal/utils/utils.go

@ -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'.")
}
}

291
static/css/styles.css

@ -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;
}

20
templates/dashboard.html

@ -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}}

45
templates/layout.html

@ -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>

31
templates/login.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>

5
templates/partials/admin.html

@ -0,0 +1,5 @@
{{define "content"}}
<h2>Admin</h2>
<p>Manage your assets here.</p>
<!-- Add asset management content -->
{{end}}

5
templates/partials/assets.html

@ -0,0 +1,5 @@
{{define "content"}}
<h2>Assets</h2>
<p>Manage your assets here.</p>
<!-- Add asset management content -->
{{end}}

5
templates/partials/companies.html

@ -0,0 +1,5 @@
{{define "content"}}
<h2>Companies</h2>
<p>Manage your companies here.</p>
<!-- Add company management content -->
{{end}}

28
templates/partials/jobs.html

@ -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…
Cancel
Save