diff --git a/go-app/cmd/server/main.go b/go-app/cmd/server/main.go index 184ad43c..69b65740 100644 --- a/go-app/cmd/server/main.go +++ b/go-app/cmd/server/main.go @@ -52,8 +52,8 @@ func main() { log.Fatal("Failed to initialize templates:", err) } - // Only pageHandler is needed - pageHandler := handlers.NewPageHandler(queries, tmpl) + // Load handlers + quoteHandler := handlers.ExpiringQuotesHandler(queries, tmpl) // Setup routes r := mux.NewRouter() @@ -65,7 +65,7 @@ func main() { // PDF files goRouter.PathPrefix("/pdf/").Handler(http.StripPrefix("/go/pdf/", http.FileServer(http.Dir("webroot/pdf")))) - goRouter.HandleFunc("/quotes", pageHandler.QuotesView).Methods("GET") + goRouter.HandleFunc("/quotes", quoteHandler.ExpiringQuotesView).Methods("GET") // The following routes are currently disabled: /* diff --git a/go-app/internal/cmc/db/models.go b/go-app/internal/cmc/db/models.go index 7da8b1b9..99ad8410 100644 --- a/go-app/internal/cmc/db/models.go +++ b/go-app/internal/cmc/db/models.go @@ -333,6 +333,28 @@ type PurchaseOrder struct { ParentPurchaseOrderID int32 `json:"parent_purchase_order_id"` } +type Quote struct { + Created time.Time `json:"created"` + Modified time.Time `json:"modified"` + ID int32 `json:"id"` + EnquiryID int32 `json:"enquiry_id"` + CurrencyID int32 `json:"currency_id"` + // limited at 5 digits. Really, you're not going to have more revisions of a single quote than that + Revision int32 `json:"revision"` + // estimated delivery time for quote + DeliveryTime string `json:"delivery_time"` + DeliveryTimeFrame string `json:"delivery_time_frame"` + PaymentTerms string `json:"payment_terms"` + DaysValid int32 `json:"days_valid"` + DateIssued time.Time `json:"date_issued"` + ValidUntil time.Time `json:"valid_until"` + DeliveryPoint string `json:"delivery_point"` + ExchangeRate string `json:"exchange_rate"` + CustomsDuty string `json:"customs_duty"` + DocumentID int32 `json:"document_id"` + CommercialComments sql.NullString `json:"commercial_comments"` +} + type State struct { ID int32 `json:"id"` Name string `json:"name"` diff --git a/go-app/internal/cmc/db/querier.go b/go-app/internal/cmc/db/querier.go index 5079f60b..5f13c7ac 100644 --- a/go-app/internal/cmc/db/querier.go +++ b/go-app/internal/cmc/db/querier.go @@ -60,6 +60,7 @@ type Querier interface { GetEnquiriesByCustomer(ctx context.Context, arg GetEnquiriesByCustomerParams) ([]GetEnquiriesByCustomerRow, error) GetEnquiriesByUser(ctx context.Context, arg GetEnquiriesByUserParams) ([]GetEnquiriesByUserRow, error) GetEnquiry(ctx context.Context, id int32) (GetEnquiryRow, error) + GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesRow, error) GetLineItem(ctx context.Context, id int32) (LineItem, error) GetLineItemsByProduct(ctx context.Context, productID sql.NullInt32) ([]LineItem, error) GetLineItemsTable(ctx context.Context, documentID int32) ([]GetLineItemsTableRow, error) @@ -74,6 +75,7 @@ type Querier interface { GetPurchaseOrderRevisions(ctx context.Context, parentPurchaseOrderID int32) ([]PurchaseOrder, error) GetPurchaseOrdersByPrinciple(ctx context.Context, arg GetPurchaseOrdersByPrincipleParams) ([]PurchaseOrder, error) GetRecentDocuments(ctx context.Context, limit int32) ([]GetRecentDocumentsRow, error) + GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesRow, error) GetState(ctx context.Context, id int32) (State, error) GetStatus(ctx context.Context, id int32) (Status, error) GetUser(ctx context.Context, id int32) (GetUserRow, error) diff --git a/go-app/internal/cmc/db/quotes.sql.go b/go-app/internal/cmc/db/quotes.sql.go new file mode 100644 index 00000000..d0685cae --- /dev/null +++ b/go-app/internal/cmc/db/quotes.sql.go @@ -0,0 +1,109 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: quotes.sql + +package db + +import ( + "context" + "time" +) + +const getExpiringSoonQuotes = `-- name: GetExpiringSoonQuotes :many +SELECT d.id, u.username, e.id, e.title, q.date_issued, q.valid_until +FROM quotes q +JOIN documents d on d.id = q.document_id +JOIN users u on u.id = d.user_id +JOIN enquiries e on e.id = q.enquiry_id +WHERE valid_until >= CURRENT_DATE AND valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY) +ORDER BY valid_until DESC +` + +type GetExpiringSoonQuotesRow struct { + ID int32 `json:"id"` + Username string `json:"username"` + ID_2 int32 `json:"id_2"` + Title string `json:"title"` + DateIssued time.Time `json:"date_issued"` + ValidUntil time.Time `json:"valid_until"` +} + +func (q *Queries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesRow, error) { + rows, err := q.db.QueryContext(ctx, getExpiringSoonQuotes, dateADD) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetExpiringSoonQuotesRow{} + for rows.Next() { + var i GetExpiringSoonQuotesRow + if err := rows.Scan( + &i.ID, + &i.Username, + &i.ID_2, + &i.Title, + &i.DateIssued, + &i.ValidUntil, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getRecentlyExpiredQuotes = `-- name: GetRecentlyExpiredQuotes :many +SELECT d.id, u.username, e.id, e.title, q.date_issued, q.valid_until +FROM quotes q +JOIN documents d on d.id = q.document_id +JOIN users u on u.id = d.user_id +JOIN enquiries e on e.id = q.enquiry_id +WHERE valid_until < CURRENT_DATE AND valid_until >= DATE_SUB(CURRENT_DATE, INTERVAL ? DAY) +ORDER BY valid_until DESC +` + +type GetRecentlyExpiredQuotesRow struct { + ID int32 `json:"id"` + Username string `json:"username"` + ID_2 int32 `json:"id_2"` + Title string `json:"title"` + DateIssued time.Time `json:"date_issued"` + ValidUntil time.Time `json:"valid_until"` +} + +func (q *Queries) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesRow, error) { + rows, err := q.db.QueryContext(ctx, getRecentlyExpiredQuotes, dateSUB) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetRecentlyExpiredQuotesRow{} + for rows.Next() { + var i GetRecentlyExpiredQuotesRow + if err := rows.Scan( + &i.ID, + &i.Username, + &i.ID_2, + &i.Title, + &i.DateIssued, + &i.ValidUntil, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/go-app/internal/cmc/handlers/pages.go b/go-app/internal/cmc/handlers/pages.go index a9405bf4..7853fe7a 100644 --- a/go-app/internal/cmc/handlers/pages.go +++ b/go-app/internal/cmc/handlers/pages.go @@ -813,10 +813,3 @@ func (h *PageHandler) DocumentsView(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) } } - -// Quotes view page -func (h *PageHandler) QuotesView(w http.ResponseWriter, r *http.Request) { - if err := h.tmpl.Render(w, "quotes/index.html", nil); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } -} diff --git a/go-app/internal/cmc/handlers/quotes.go b/go-app/internal/cmc/handlers/quotes.go new file mode 100644 index 00000000..46fb45bc --- /dev/null +++ b/go-app/internal/cmc/handlers/quotes.go @@ -0,0 +1,110 @@ +package handlers + +import ( + "net/http" + "strconv" + "strings" + "time" + + "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db" + "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates" +) + +type QuotesHandler struct { + queries *db.Queries + tmpl *templates.TemplateManager +} + +func ExpiringQuotesHandler(queries *db.Queries, tmpl *templates.TemplateManager) *QuotesHandler { + return &QuotesHandler{ + queries: queries, + tmpl: tmpl, + } +} + +func (h *QuotesHandler) ExpiringQuotesView(w http.ResponseWriter, r *http.Request) { + days := int32(14) + + recentlyExpiredQuotes, err := h.queries.GetRecentlyExpiredQuotes(r.Context(), days) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + expiringSoonQuotes, err := h.queries.GetExpiringSoonQuotes(r.Context(), days) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + calcExpiryInfo := func(validUntil time.Time) (string, int, int) { + now := time.Now() + daysUntil := int(validUntil.Sub(now).Hours() / 24) + daysSince := int(now.Sub(validUntil).Hours() / 24) + var relative string + if validUntil.After(now) { + if daysUntil == 0 { + relative = "expires today" + } else if daysUntil == 1 { + relative = "in 1 day" + } else { + relative = "in " + strconv.Itoa(daysUntil) + " days" + } + } else { + if daysSince == 0 { + relative = "expired today" + } else if daysSince == 1 { + relative = "expired 1 day ago" + } else { + relative = "expired " + strconv.Itoa(daysSince) + " days ago" + } + } + return relative, daysUntil, daysSince + } + + formatQuote := func(q db.GetRecentlyExpiredQuotesRow) map[string]interface{} { + relative, daysUntil, daysSince := calcExpiryInfo(q.ValidUntil) + return map[string]interface{}{ + "ID": q.ID, + "Username": strings.Title(q.Username), + "EnquiryID": q.ID_2, + "EnquiryTitle": q.Title, + "DateIssued": q.DateIssued.Format("2006-01-02"), + "ValidUntil": q.ValidUntil.Format("2006-01-02"), + "ValidUntilRelative": relative, + "DaysUntilExpiry": daysUntil, + "DaysSinceExpiry": daysSince, + } + } + formatSoonQuote := func(q db.GetExpiringSoonQuotesRow) map[string]interface{} { + relative, daysUntil, daysSince := calcExpiryInfo(q.ValidUntil) + return map[string]interface{}{ + "ID": q.ID, + "Username": strings.ToUpper(q.Username), + "EnquiryID": q.ID_2, + "EnquiryTitle": q.Title, + "DateIssued": q.DateIssued.Format("2006-01-02"), + "ValidUntil": q.ValidUntil.Format("2006-01-02"), + "ValidUntilRelative": relative, + "DaysUntilExpiry": daysUntil, + "DaysSinceExpiry": daysSince, + } + } + + var expiredRows []map[string]interface{} + for _, q := range recentlyExpiredQuotes { + expiredRows = append(expiredRows, formatQuote(q)) + } + var soonRows []map[string]interface{} + for _, q := range expiringSoonQuotes { + soonRows = append(soonRows, formatSoonQuote(q)) + } + + data := map[string]interface{}{ + "RecentlyExpiredQuotes": expiredRows, + "ExpiringSoonQuotes": soonRows, + } + if err := h.tmpl.Render(w, "quotes/index.html", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} diff --git a/go-app/sql/queries/quotes.sql b/go-app/sql/queries/quotes.sql new file mode 100644 index 00000000..58e6f74c --- /dev/null +++ b/go-app/sql/queries/quotes.sql @@ -0,0 +1,17 @@ +-- name: GetRecentlyExpiredQuotes :many +SELECT d.id, u.username, e.id, e.title, q.date_issued, q.valid_until +FROM quotes q +JOIN documents d on d.id = q.document_id +JOIN users u on u.id = d.user_id +JOIN enquiries e on e.id = q.enquiry_id +WHERE valid_until < CURRENT_DATE AND valid_until >= DATE_SUB(CURRENT_DATE, INTERVAL ? DAY) +ORDER BY valid_until DESC; + +-- name: GetExpiringSoonQuotes :many +SELECT d.id, u.username, e.id, e.title, q.date_issued, q.valid_until +FROM quotes q +JOIN documents d on d.id = q.document_id +JOIN users u on u.id = d.user_id +JOIN enquiries e on e.id = q.enquiry_id +WHERE valid_until >= CURRENT_DATE AND valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY) +ORDER BY valid_until DESC; \ No newline at end of file diff --git a/go-app/sql/schema/014_quotes.sql b/go-app/sql/schema/014_quotes.sql new file mode 100644 index 00000000..680fffa5 --- /dev/null +++ b/go-app/sql/schema/014_quotes.sql @@ -0,0 +1,22 @@ +-- cmc.quotes definition (matches existing database) + +CREATE TABLE `quotes` ( + `created` datetime NOT NULL, + `modified` datetime NOT NULL, + `id` int(11) NOT NULL AUTO_INCREMENT, + `enquiry_id` int(50) NOT NULL, + `currency_id` int(11) NOT NULL, + `revision` int(5) NOT NULL COMMENT 'limited at 5 digits. Really, you''re not going to have more revisions of a single quote than that', + `delivery_time` varchar(400) NOT NULL COMMENT 'estimated delivery time for quote', + `delivery_time_frame` varchar(100) NOT NULL, + `payment_terms` varchar(400) NOT NULL, + `days_valid` int(3) NOT NULL, + `date_issued` date NOT NULL, + `valid_until` date NOT NULL, + `delivery_point` varchar(400) NOT NULL, + `exchange_rate` varchar(255) NOT NULL, + `customs_duty` varchar(255) NOT NULL, + `document_id` int(11) NOT NULL, + `commercial_comments` text DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=MyISAM AUTO_INCREMENT=18245 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci; \ No newline at end of file diff --git a/go-app/templates/quotes/index.html b/go-app/templates/quotes/index.html index bdefcfca..c7467fb8 100644 --- a/go-app/templates/quotes/index.html +++ b/go-app/templates/quotes/index.html @@ -1,4 +1,61 @@ {{define "content"}} -
Welcome to the Quotes page. Here you can view and manage your quotes.
+| Quote | +Issued By | +Enquiry | +Date Issued | +Valid Until | +Reminder | +
|---|---|---|---|---|---|
| {{.ID}} | +{{.Username}} | +{{.EnquiryTitle}} | +{{.DateIssued}} | +{{.ValidUntil}} ({{.ValidUntilRelative}}) | +Unsent | +
| No quotes expiring soon. | |||||
| Quote | +Issued By | +Enquiry | +Date Issued | +Valid Until | +Reminder | +
|---|---|---|---|---|---|
| {{.ID}} | +{{.Username}} | +{{.EnquiryTitle}} | +{{.DateIssued}} | +{{.ValidUntil}} ({{.ValidUntilRelative}}) | +Unsent | +
| No recently expired quotes. | |||||