prod #123

Merged
finley merged 122 commits from prod into master 2025-11-22 17:52:40 -08:00
9 changed files with 344 additions and 12 deletions
Showing only changes of commit 9fee1677e2 - Show all commits

View file

@ -52,8 +52,8 @@ func main() {
log.Fatal("Failed to initialize templates:", err) log.Fatal("Failed to initialize templates:", err)
} }
// Only pageHandler is needed // Load handlers
pageHandler := handlers.NewPageHandler(queries, tmpl) quoteHandler := handlers.ExpiringQuotesHandler(queries, tmpl)
// Setup routes // Setup routes
r := mux.NewRouter() r := mux.NewRouter()
@ -65,7 +65,7 @@ func main() {
// PDF files // PDF files
goRouter.PathPrefix("/pdf/").Handler(http.StripPrefix("/go/pdf/", http.FileServer(http.Dir("webroot/pdf")))) 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: // The following routes are currently disabled:
/* /*

View file

@ -333,6 +333,28 @@ type PurchaseOrder struct {
ParentPurchaseOrderID int32 `json:"parent_purchase_order_id"` 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 { type State struct {
ID int32 `json:"id"` ID int32 `json:"id"`
Name string `json:"name"` Name string `json:"name"`

View file

@ -60,6 +60,7 @@ type Querier interface {
GetEnquiriesByCustomer(ctx context.Context, arg GetEnquiriesByCustomerParams) ([]GetEnquiriesByCustomerRow, error) GetEnquiriesByCustomer(ctx context.Context, arg GetEnquiriesByCustomerParams) ([]GetEnquiriesByCustomerRow, error)
GetEnquiriesByUser(ctx context.Context, arg GetEnquiriesByUserParams) ([]GetEnquiriesByUserRow, error) GetEnquiriesByUser(ctx context.Context, arg GetEnquiriesByUserParams) ([]GetEnquiriesByUserRow, error)
GetEnquiry(ctx context.Context, id int32) (GetEnquiryRow, 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) GetLineItem(ctx context.Context, id int32) (LineItem, error)
GetLineItemsByProduct(ctx context.Context, productID sql.NullInt32) ([]LineItem, error) GetLineItemsByProduct(ctx context.Context, productID sql.NullInt32) ([]LineItem, error)
GetLineItemsTable(ctx context.Context, documentID int32) ([]GetLineItemsTableRow, error) GetLineItemsTable(ctx context.Context, documentID int32) ([]GetLineItemsTableRow, error)
@ -74,6 +75,7 @@ type Querier interface {
GetPurchaseOrderRevisions(ctx context.Context, parentPurchaseOrderID int32) ([]PurchaseOrder, error) GetPurchaseOrderRevisions(ctx context.Context, parentPurchaseOrderID int32) ([]PurchaseOrder, error)
GetPurchaseOrdersByPrinciple(ctx context.Context, arg GetPurchaseOrdersByPrincipleParams) ([]PurchaseOrder, error) GetPurchaseOrdersByPrinciple(ctx context.Context, arg GetPurchaseOrdersByPrincipleParams) ([]PurchaseOrder, error)
GetRecentDocuments(ctx context.Context, limit int32) ([]GetRecentDocumentsRow, 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) GetState(ctx context.Context, id int32) (State, error)
GetStatus(ctx context.Context, id int32) (Status, error) GetStatus(ctx context.Context, id int32) (Status, error)
GetUser(ctx context.Context, id int32) (GetUserRow, error) GetUser(ctx context.Context, id int32) (GetUserRow, error)

View file

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

View file

@ -813,10 +813,3 @@ func (h *PageHandler) DocumentsView(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError) 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)
}
}

View file

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

View file

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

View file

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

View file

@ -1,4 +1,61 @@
{{define "content"}} {{define "content"}}
<h1 class="text-2xl font-bold mb-4">Quotes</h1> <h1 class="text-3xl font-bold mb-4 text-gray-800">Quotes Expiring</h1>
<p>Welcome to the Quotes page. Here you can view and manage your quotes.</p> <div class="pl-4">
<!-- Expiring Soon Section -->
<h2 class="text-xl font-semibold mt-6 mb-2">Expiring Soon</h2>
<table class="min-w-full border text-center align-middle mt-1 ml-4">
<thead>
<tr>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Quote</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Issued By</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Enquiry</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Date Issued</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Valid Until</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Reminder</th>
</tr>
</thead>
<tbody>
{{range .ExpiringSoonQuotes}}
<tr class="hover:bg-slate-50 transition">
<td class="px-4 py-2 border align-middle"><a href="/documents/view/{{.ID}}" class="text-blue-600 underline">{{.ID}}</a></td>
<td class="px-4 py-2 border align-middle">{{.Username}}</td>
<td class="px-4 py-2 border align-middle"><a href="/enquiries/{{.EnquiryID}}" class="text-blue-600 underline">{{.EnquiryTitle}}</a></td>
<td class="px-4 py-2 border align-middle">{{.DateIssued}}</td>
<td class="px-4 py-2 border align-middle">{{.ValidUntil}} <span class="text-gray-500">({{.ValidUntilRelative}})</span></td>
<td class="px-4 py-2 border align-middle"><span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-gray-200 text-gray-700">Unsent</span></td>
</tr>
{{else}}
<tr><td colspan="6" class="px-4 py-2 border text-center align-middle">No quotes expiring soon.</td></tr>
{{end}}
</tbody>
</table>
<!-- Recently Expired Quotes Section -->
<h2 class="text-xl font-semibold mt-6 mb-2">Recently Expired</h2>
<table class="min-w-full border mb-6 text-center align-middle mt-1 ml-4">
<thead>
<tr>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Quote</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Issued By</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Enquiry</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Date Issued</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Valid Until</th>
<th class="px-4 py-3 border font-semibold text-gray-700 align-middle">Reminder</th>
</tr>
</thead>
<tbody>
{{range .RecentlyExpiredQuotes}}
<tr class="hover:bg-slate-50 transition">
<td class="px-4 py-2 border align-middle"><a href="/documents/view/{{.ID}}" class="text-blue-600 underline">{{.ID}}</a></td>
<td class="px-4 py-2 border align-middle">{{.Username}}</td>
<td class="px-4 py-2 border align-middle"><a href="/enquiries/view/{{.EnquiryID}}" class="text-blue-600 underline">{{.EnquiryTitle}}</a></td>
<td class="px-4 py-2 border align-middle">{{.DateIssued}}</td>
<td class="px-4 py-2 border align-middle">{{.ValidUntil}} <span class="text-gray-500">({{.ValidUntilRelative}})</span></td>
<td class="px-4 py-2 border align-middle"><span class="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-gray-200 text-gray-700">Unsent</span></td>
</tr>
{{else}}
<tr><td colspan="6" class="px-4 py-2 border text-center align-middle">No recently expired quotes.</td></tr>
{{end}}
</tbody>
</table>
</div>
{{end}} {{end}}