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)
}
// 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:
/*

View file

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

View file

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

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)
}
}
// 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"}}
<h1 class="text-2xl font-bold mb-4">Quotes</h1>
<p>Welcome to the Quotes page. Here you can view and manage your quotes.</p>
<h1 class="text-3xl font-bold mb-4 text-gray-800">Quotes Expiring</h1>
<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}}