2023-06-07 14:35:31 +00:00
package pkg
import (
"archive/zip"
"bytes"
"context"
"errors"
"fmt"
"github.com/julienschmidt/httprouter"
"html/template"
"io"
"log"
"math"
"net/http"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"time"
)
type QuoteEntry struct {
Slug string
Date time . Time
Number string
Total string
CustomerName string
Tags [ ] string
Status string
StatusLabel string
}
type QuotesIndexPage struct {
Quotes [ ] * QuoteEntry
2023-06-20 09:33:28 +00:00
TotalAmount string
2023-06-07 14:35:31 +00:00
Filters * quoteFilterForm
QuoteStatuses map [ string ] string
}
func IndexQuotes ( w http . ResponseWriter , r * http . Request , _ httprouter . Params ) {
conn := getConn ( r )
locale := getLocale ( r )
company := mustGetCompany ( r )
filters := newQuoteFilterForm ( r . Context ( ) , conn , locale , company )
if err := filters . Parse ( r ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusBadRequest )
return
}
page := & QuotesIndexPage {
2023-06-20 09:33:28 +00:00
Quotes : mustCollectQuoteEntries ( r . Context ( ) , conn , locale , filters ) ,
TotalAmount : mustComputeQuotesTotalAmount ( r . Context ( ) , conn , filters ) ,
2023-06-07 14:35:31 +00:00
Filters : filters ,
QuoteStatuses : mustCollectQuoteStatuses ( r . Context ( ) , conn , locale ) ,
}
mustRenderMainTemplate ( w , r , "quotes/index.gohtml" , page )
}
2023-06-20 09:33:28 +00:00
func mustCollectQuoteEntries ( ctx context . Context , conn * Conn , locale * Locale , filters * quoteFilterForm ) [ ] * QuoteEntry {
where , args := filters . BuildQuery ( [ ] interface { } { locale . Language . String ( ) } )
2023-06-07 14:35:31 +00:00
rows := conn . MustQuery ( ctx , fmt . Sprintf ( `
select quote . slug
, quote_date
, quote_number
Allow empty contact and payment method for quotes
I have to use a value to be used as “none” for payment method and
contact. In PL/pgSQL add_quote and edit_quote functions, that value is
NULL, while in forms it is the empty string. I can not simply pass the
empty string for either of these fields because PL/pgSQL expects
(nullable) integers, and "" is not a valid integer and is not NULL
either. A conversion is necessary.
Apparently, Go’s nil is not a valid representation for SQL’s NULL with
pgx, and had to use sql.NullString instead.
I also needed to coalesce contact’s VATIN and phone, because null values
can not be scanned to *string. I did not do that before because
`coalesce(vatin, '')` throws an error that '' is not a valid VATIN and
just left as is, wrongly expecting that pgx would do the job of leaving
the string blank for me. It does not.
Lastly, i can not blindly write Quotee’s tax details in the quote’s view
page, or we would see the (), characters for the empty address info.
2023-06-08 11:05:41 +00:00
, coalesce ( contact . business_name , ' ' )
2023-06-07 14:35:31 +00:00
, quote . tags
, quote . quote_status
, isi18n . name
, to_price ( total , decimal_digits )
from quote
left join quote_contact using ( quote_id )
left join contact using ( contact_id )
join quote_status_i18n isi18n on quote . quote_status = isi18n . quote_status and isi18n . lang_tag = $ 1
join quote_amount using ( quote_id )
join currency using ( currency_code )
where ( % s )
order by quote_date desc
, quote_number desc
2023-06-20 09:33:28 +00:00
` , where ) , args ... )
2023-06-07 14:35:31 +00:00
defer rows . Close ( )
var entries [ ] * QuoteEntry
for rows . Next ( ) {
entry := & QuoteEntry { }
if err := rows . Scan ( & entry . Slug , & entry . Date , & entry . Number , & entry . CustomerName , & entry . Tags , & entry . Status , & entry . StatusLabel , & entry . Total ) ; err != nil {
panic ( err )
}
entries = append ( entries , entry )
}
if rows . Err ( ) != nil {
panic ( rows . Err ( ) )
}
return entries
}
2023-06-20 09:33:28 +00:00
func mustComputeQuotesTotalAmount ( ctx context . Context , conn * Conn , filters * quoteFilterForm ) string {
where , args := filters . BuildQuery ( nil )
return conn . MustGetText ( ctx , "0" , fmt . Sprintf ( `
select to_price ( sum ( total ) : : integer , decimal_digits )
from quote
left join quote_contact using ( quote_id )
join quote_amount using ( quote_id )
join currency using ( currency_code )
where ( % s )
group by decimal_digits
` , where ) , args ... )
}
2023-06-07 14:35:31 +00:00
func mustCollectQuoteStatuses ( ctx context . Context , conn * Conn , locale * Locale ) map [ string ] string {
rows := conn . MustQuery ( ctx , "select quote_status.quote_status, isi18n.name from quote_status join quote_status_i18n isi18n using(quote_status) where isi18n.lang_tag = $1 order by quote_status" , locale . Language . String ( ) )
defer rows . Close ( )
statuses := map [ string ] string { }
for rows . Next ( ) {
var key , name string
if err := rows . Scan ( & key , & name ) ; err != nil {
panic ( err )
}
statuses [ key ] = name
}
if rows . Err ( ) != nil {
panic ( rows . Err ( ) )
}
return statuses
}
type quoteFilterForm struct {
locale * Locale
company * Company
Customer * SelectField
QuoteStatus * SelectField
QuoteNumber * InputField
FromDate * InputField
ToDate * InputField
Tags * TagsField
TagsCondition * ToggleField
}
func newQuoteFilterForm ( ctx context . Context , conn * Conn , locale * Locale , company * Company ) * quoteFilterForm {
return & quoteFilterForm {
locale : locale ,
company : company ,
Customer : & SelectField {
Name : "customer" ,
2023-06-20 09:37:02 +00:00
Label : pgettext ( "input" , "Customer" , locale ) ,
2023-06-07 14:35:31 +00:00
EmptyLabel : gettext ( "All customers" , locale ) ,
Options : mustGetContactOptions ( ctx , conn , company ) ,
} ,
QuoteStatus : & SelectField {
Name : "quote_status" ,
Label : pgettext ( "input" , "Quotation Status" , locale ) ,
EmptyLabel : gettext ( "All status" , locale ) ,
Options : MustGetOptions ( ctx , conn , "select quote_status.quote_status, isi18n.name from quote_status join quote_status_i18n isi18n using(quote_status) where isi18n.lang_tag = $1 order by quote_status" , locale . Language . String ( ) ) ,
} ,
QuoteNumber : & InputField {
Name : "number" ,
Label : pgettext ( "input" , "Quotation Number" , locale ) ,
Type : "search" ,
} ,
FromDate : & InputField {
Name : "from_date" ,
Label : pgettext ( "input" , "From Date" , locale ) ,
Type : "date" ,
} ,
ToDate : & InputField {
Name : "to_date" ,
Label : pgettext ( "input" , "To Date" , locale ) ,
Type : "date" ,
} ,
Tags : & TagsField {
Name : "tags" ,
Label : pgettext ( "input" , "Tags" , locale ) ,
} ,
TagsCondition : & ToggleField {
Name : "tags_condition" ,
Label : pgettext ( "input" , "Tags Condition" , locale ) ,
Selected : "and" ,
FirstOption : & ToggleOption {
Value : "and" ,
Label : pgettext ( "tag condition" , "All" , locale ) ,
Description : gettext ( "Quotations must have all the specified labels." , locale ) ,
} ,
SecondOption : & ToggleOption {
Value : "or" ,
Label : pgettext ( "tag condition" , "Any" , locale ) ,
Description : gettext ( "Quotations must have at least one of the specified labels." , locale ) ,
} ,
} ,
}
}
func ( form * quoteFilterForm ) Parse ( r * http . Request ) error {
if err := r . ParseForm ( ) ; err != nil {
return err
}
form . Customer . FillValue ( r )
form . QuoteStatus . FillValue ( r )
form . QuoteNumber . FillValue ( r )
form . FromDate . FillValue ( r )
form . ToDate . FillValue ( r )
form . Tags . FillValue ( r )
form . TagsCondition . FillValue ( r )
return nil
}
2023-06-20 09:33:28 +00:00
func ( form * quoteFilterForm ) BuildQuery ( args [ ] interface { } ) ( string , [ ] interface { } ) {
var where [ ] string
appendWhere := func ( expression string , value interface { } ) {
args = append ( args , value )
where = append ( where , fmt . Sprintf ( expression , len ( args ) ) )
}
maybeAppendWhere := func ( expression string , value string , conv func ( string ) interface { } ) {
if value != "" {
if conv == nil {
appendWhere ( expression , value )
} else {
appendWhere ( expression , conv ( value ) )
}
}
}
appendWhere ( "quote.company_id = $%d" , form . company . Id )
maybeAppendWhere ( "contact_id = $%d" , form . Customer . String ( ) , func ( v string ) interface { } {
customerId , _ := strconv . Atoi ( form . Customer . Selected [ 0 ] )
return customerId
} )
maybeAppendWhere ( "quote.quote_status = $%d" , form . QuoteStatus . String ( ) , nil )
maybeAppendWhere ( "quote_number = $%d" , form . QuoteNumber . String ( ) , nil )
maybeAppendWhere ( "quote_date >= $%d" , form . FromDate . String ( ) , nil )
maybeAppendWhere ( "quote_date <= $%d" , form . ToDate . String ( ) , nil )
if len ( form . Tags . Tags ) > 0 {
if form . TagsCondition . Selected == "and" {
appendWhere ( "quote.tags @> $%d" , form . Tags )
} else {
appendWhere ( "quote.tags && $%d" , form . Tags )
}
}
return strings . Join ( where , ") AND (" ) , args
}
2023-06-07 14:35:31 +00:00
func ServeQuote ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
conn := getConn ( r )
company := mustGetCompany ( r )
slug := params [ 0 ] . Value
switch slug {
case "new" :
locale := getLocale ( r )
form := newQuoteForm ( r . Context ( ) , conn , locale , company )
if quoteToDuplicate := r . URL . Query ( ) . Get ( "duplicate" ) ; quoteToDuplicate != "" {
form . MustFillFromDatabase ( r . Context ( ) , conn , quoteToDuplicate )
form . QuoteStatus . Selected = [ ] string { "created" }
}
form . Date . Val = time . Now ( ) . Format ( "2006-01-02" )
w . WriteHeader ( http . StatusOK )
mustRenderNewQuoteForm ( w , r , form )
case "product-form" :
query := r . URL . Query ( )
index , _ := strconv . Atoi ( query . Get ( "index" ) )
form := newQuoteProductForm ( index , company , getLocale ( r ) , mustGetTaxOptions ( r . Context ( ) , conn , company ) )
productSlug := query . Get ( "slug" )
if len ( productSlug ) > 0 {
if ! form . MustFillFromDatabase ( r . Context ( ) , conn , productSlug ) {
http . NotFound ( w , r )
return
}
quantity , _ := strconv . Atoi ( query . Get ( "product.quantity." + strconv . Itoa ( index ) ) )
if quantity > 0 {
form . Quantity . Val = strconv . Itoa ( quantity )
}
w . Header ( ) . Set ( HxTriggerAfterSettle , "recompute" )
}
mustRenderStandaloneTemplate ( w , r , "quotes/product-form.gohtml" , form )
default :
pdf := false
if strings . HasSuffix ( slug , ".pdf" ) {
pdf = true
slug = slug [ : len ( slug ) - len ( ".pdf" ) ]
}
quo := mustGetQuote ( r . Context ( ) , conn , company , slug )
if quo == nil {
http . NotFound ( w , r )
return
}
if pdf {
w . Header ( ) . Set ( "Content-Type" , "application/pdf" )
mustWriteQuotePdf ( w , r , quo )
} else {
mustRenderMainTemplate ( w , r , "quotes/view.gohtml" , quo )
}
}
}
func mustWriteQuotePdf ( w io . Writer , r * http . Request , quo * quote ) {
2023-06-15 21:16:53 +00:00
cmd := exec . Command ( "weasyprint" , "--stylesheet" , "web/static/invoice.css" , "-" , "-" )
2023-06-07 14:35:31 +00:00
var stderr bytes . Buffer
cmd . Stderr = & stderr
stdin , err := cmd . StdinPipe ( )
if err != nil {
panic ( err )
}
stdout , err := cmd . StdoutPipe ( )
if err != nil {
panic ( err )
}
defer func ( ) {
err := stdout . Close ( )
if ! errors . Is ( err , os . ErrClosed ) {
panic ( err )
}
} ( )
if err = cmd . Start ( ) ; err != nil {
panic ( err )
}
go func ( ) {
defer mustClose ( stdin )
mustRenderAppTemplate ( stdin , r , "quotes/view.gohtml" , quo )
} ( )
if _ , err = io . Copy ( w , stdout ) ; err != nil {
panic ( err )
}
if err := cmd . Wait ( ) ; err != nil {
log . Printf ( "ERR - %v\n" , stderr . String ( ) )
panic ( err )
}
}
type quote struct {
Number string
Slug string
Date time . Time
Quoter taxDetails
Allow empty contact and payment method for quotes
I have to use a value to be used as “none” for payment method and
contact. In PL/pgSQL add_quote and edit_quote functions, that value is
NULL, while in forms it is the empty string. I can not simply pass the
empty string for either of these fields because PL/pgSQL expects
(nullable) integers, and "" is not a valid integer and is not NULL
either. A conversion is necessary.
Apparently, Go’s nil is not a valid representation for SQL’s NULL with
pgx, and had to use sql.NullString instead.
I also needed to coalesce contact’s VATIN and phone, because null values
can not be scanned to *string. I did not do that before because
`coalesce(vatin, '')` throws an error that '' is not a valid VATIN and
just left as is, wrongly expecting that pgx would do the job of leaving
the string blank for me. It does not.
Lastly, i can not blindly write Quotee’s tax details in the quote’s view
page, or we would see the (), characters for the empty address info.
2023-06-08 11:05:41 +00:00
HasQuotee bool
2023-06-07 14:35:31 +00:00
Quotee taxDetails
TermsAndConditions string
Notes string
PaymentInstructions string
Products [ ] * quoteProduct
Subtotal string
Taxes [ ] [ ] string
TaxClasses [ ] string
HasDiscounts bool
Total string
LegalDisclaimer string
}
type quoteProduct struct {
Name string
Description string
Price string
Discount int
Quantity int
Taxes map [ string ] int
Subtotal string
Total string
}
func mustGetQuote ( ctx context . Context , conn * Conn , company * Company , slug string ) * quote {
quo := & quote {
Slug : slug ,
}
var quoteId int
var decimalDigits int
if notFoundErrorOrPanic ( conn . QueryRow ( ctx , `
select quote_id
, decimal_digits
, quote_number
, quote_date
, terms_and_conditions
, notes
, coalesce ( instructions , ' ' )
Allow empty contact and payment method for quotes
I have to use a value to be used as “none” for payment method and
contact. In PL/pgSQL add_quote and edit_quote functions, that value is
NULL, while in forms it is the empty string. I can not simply pass the
empty string for either of these fields because PL/pgSQL expects
(nullable) integers, and "" is not a valid integer and is not NULL
either. A conversion is necessary.
Apparently, Go’s nil is not a valid representation for SQL’s NULL with
pgx, and had to use sql.NullString instead.
I also needed to coalesce contact’s VATIN and phone, because null values
can not be scanned to *string. I did not do that before because
`coalesce(vatin, '')` throws an error that '' is not a valid VATIN and
just left as is, wrongly expecting that pgx would do the job of leaving
the string blank for me. It does not.
Lastly, i can not blindly write Quotee’s tax details in the quote’s view
page, or we would see the (), characters for the empty address info.
2023-06-08 11:05:41 +00:00
, contact_id is not null
2023-06-07 14:35:31 +00:00
, coalesce ( business_name , ' ' )
Allow empty contact and payment method for quotes
I have to use a value to be used as “none” for payment method and
contact. In PL/pgSQL add_quote and edit_quote functions, that value is
NULL, while in forms it is the empty string. I can not simply pass the
empty string for either of these fields because PL/pgSQL expects
(nullable) integers, and "" is not a valid integer and is not NULL
either. A conversion is necessary.
Apparently, Go’s nil is not a valid representation for SQL’s NULL with
pgx, and had to use sql.NullString instead.
I also needed to coalesce contact’s VATIN and phone, because null values
can not be scanned to *string. I did not do that before because
`coalesce(vatin, '')` throws an error that '' is not a valid VATIN and
just left as is, wrongly expecting that pgx would do the job of leaving
the string blank for me. It does not.
Lastly, i can not blindly write Quotee’s tax details in the quote’s view
page, or we would see the (), characters for the empty address info.
2023-06-08 11:05:41 +00:00
, coalesce ( vatin : : text , ' ' )
, coalesce ( phone : : text , ' ' )
2023-06-07 14:35:31 +00:00
, coalesce ( email , ' ' )
, coalesce ( address , ' ' )
, coalesce ( city , ' ' )
, coalesce ( province , ' ' )
, coalesce ( postal_code , ' ' )
, to_price ( subtotal , decimal_digits )
, to_price ( total , decimal_digits )
from quote
left join quote_payment_method using ( quote_id )
left join payment_method using ( payment_method_id )
left join quote_contact using ( quote_id )
left join contact using ( contact_id )
join quote_amount using ( quote_id )
join currency using ( currency_code )
where quote . slug = $ 1 ` , slug ) . Scan (
& quoteId ,
& decimalDigits ,
& quo . Number ,
& quo . Date ,
& quo . TermsAndConditions ,
& quo . Notes ,
& quo . PaymentInstructions ,
Allow empty contact and payment method for quotes
I have to use a value to be used as “none” for payment method and
contact. In PL/pgSQL add_quote and edit_quote functions, that value is
NULL, while in forms it is the empty string. I can not simply pass the
empty string for either of these fields because PL/pgSQL expects
(nullable) integers, and "" is not a valid integer and is not NULL
either. A conversion is necessary.
Apparently, Go’s nil is not a valid representation for SQL’s NULL with
pgx, and had to use sql.NullString instead.
I also needed to coalesce contact’s VATIN and phone, because null values
can not be scanned to *string. I did not do that before because
`coalesce(vatin, '')` throws an error that '' is not a valid VATIN and
just left as is, wrongly expecting that pgx would do the job of leaving
the string blank for me. It does not.
Lastly, i can not blindly write Quotee’s tax details in the quote’s view
page, or we would see the (), characters for the empty address info.
2023-06-08 11:05:41 +00:00
& quo . HasQuotee ,
2023-06-07 14:35:31 +00:00
& quo . Quotee . Name ,
& quo . Quotee . VATIN ,
& quo . Quotee . Phone ,
& quo . Quotee . Email ,
& quo . Quotee . Address ,
& quo . Quotee . City ,
& quo . Quotee . Province ,
& quo . Quotee . PostalCode ,
& quo . Subtotal ,
& quo . Total ) ) {
return nil
}
if err := conn . QueryRow ( ctx , "select business_name, vatin, phone, email, address, city, province, postal_code, legal_disclaimer from company where company_id = $1" , company . Id ) . Scan ( & quo . Quoter . Name , & quo . Quoter . VATIN , & quo . Quoter . Phone , & quo . Quoter . Email , & quo . Quoter . Address , & quo . Quoter . City , & quo . Quoter . Province , & quo . Quoter . PostalCode , & quo . LegalDisclaimer ) ; err != nil {
panic ( err )
}
if err := conn . QueryRow ( ctx , "select array_agg(array[name, to_price(amount, $2)]) from quote_tax_amount join tax using (tax_id) where quote_id = $1" , quoteId , decimalDigits ) . Scan ( & quo . Taxes ) ; err != nil {
panic ( err )
}
rows := conn . MustQuery ( ctx , "select quote_product.name, description, to_price(price, $2), (discount_rate * 100)::integer, quantity, to_price(subtotal, $2), to_price(total, $2), array_agg(array[tax_class.name, (tax_rate * 100)::integer::text]) filter (where tax_rate is not null) from quote_product join quote_product_amount using (quote_product_id) left join quote_product_tax using (quote_product_id) left join tax using (tax_id) left join tax_class using (tax_class_id) where quote_id = $1 group by quote_product.name, description, discount_rate, price, quantity, subtotal, total" , quoteId , decimalDigits )
defer rows . Close ( )
taxClasses := map [ string ] bool { }
for rows . Next ( ) {
product := & quoteProduct {
Taxes : make ( map [ string ] int ) ,
}
var taxes [ ] [ ] string
if err := rows . Scan ( & product . Name , & product . Description , & product . Price , & product . Discount , & product . Quantity , & product . Subtotal , & product . Total , & taxes ) ; err != nil {
panic ( err )
}
for _ , tax := range taxes {
taxClass := tax [ 0 ]
taxClasses [ taxClass ] = true
product . Taxes [ taxClass ] , _ = strconv . Atoi ( tax [ 1 ] )
}
if product . Discount > 0 {
quo . HasDiscounts = true
}
quo . Products = append ( quo . Products , product )
}
for taxClass := range taxClasses {
quo . TaxClasses = append ( quo . TaxClasses , taxClass )
}
sort . Strings ( quo . TaxClasses )
if rows . Err ( ) != nil {
panic ( rows . Err ( ) )
}
return quo
}
type newQuotePage struct {
Form * quoteForm
Subtotal string
Taxes [ ] [ ] string
Total string
}
func newNewQuotePage ( form * quoteForm , r * http . Request ) * newQuotePage {
page := & newQuotePage {
Form : form ,
}
conn := getConn ( r )
company := mustGetCompany ( r )
err := conn . QueryRow ( r . Context ( ) , "select subtotal, taxes, total from compute_new_quote_amount($1, $2)" , company . Id , NewQuoteProductArray ( form . Products ) ) . Scan ( & page . Subtotal , & page . Taxes , & page . Total )
if err != nil {
panic ( err )
}
if len ( form . Products ) == 0 {
form . Products = append ( form . Products , newQuoteProductForm ( 0 , company , getLocale ( r ) , mustGetTaxOptions ( r . Context ( ) , conn , company ) ) )
}
return page
}
func mustRenderNewQuoteForm ( w http . ResponseWriter , r * http . Request , form * quoteForm ) {
page := newNewQuotePage ( form , r )
mustRenderMainTemplate ( w , r , "quotes/new.gohtml" , page )
}
func mustRenderNewQuoteProductsForm ( w http . ResponseWriter , r * http . Request , action string , form * quoteForm ) {
conn := getConn ( r )
company := mustGetCompany ( r )
page := newQuoteProductsPage {
Action : companyURI ( company , action ) ,
Form : form ,
Products : mustGetProductChoices ( r . Context ( ) , conn , company ) ,
}
mustRenderMainTemplate ( w , r , "quotes/products.gohtml" , page )
}
type newQuoteProductsPage struct {
Action string
Form * quoteForm
Products [ ] * productChoice
}
func HandleAddQuote ( w http . ResponseWriter , r * http . Request , _ httprouter . Params ) {
locale := getLocale ( r )
conn := getConn ( r )
company := mustGetCompany ( r )
form := newQuoteForm ( r . Context ( ) , conn , locale , company )
if err := form . Parse ( r ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusBadRequest )
return
}
if err := verifyCsrfTokenValid ( r ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusForbidden )
return
}
if ! form . Validate ( ) {
if ! IsHTMxRequest ( r ) {
w . WriteHeader ( http . StatusUnprocessableEntity )
}
mustRenderNewQuoteForm ( w , r , form )
return
}
Allow empty contact and payment method for quotes
I have to use a value to be used as “none” for payment method and
contact. In PL/pgSQL add_quote and edit_quote functions, that value is
NULL, while in forms it is the empty string. I can not simply pass the
empty string for either of these fields because PL/pgSQL expects
(nullable) integers, and "" is not a valid integer and is not NULL
either. A conversion is necessary.
Apparently, Go’s nil is not a valid representation for SQL’s NULL with
pgx, and had to use sql.NullString instead.
I also needed to coalesce contact’s VATIN and phone, because null values
can not be scanned to *string. I did not do that before because
`coalesce(vatin, '')` throws an error that '' is not a valid VATIN and
just left as is, wrongly expecting that pgx would do the job of leaving
the string blank for me. It does not.
Lastly, i can not blindly write Quotee’s tax details in the quote’s view
page, or we would see the (), characters for the empty address info.
2023-06-08 11:05:41 +00:00
slug := conn . MustGetText ( r . Context ( ) , "" , "select add_quote($1, $2, $3, $4, $5, $6, $7, $8)" , company . Id , form . Date , form . Customer . OrNull ( ) , form . TermsAndConditions , form . Notes , form . PaymentMethod . OrNull ( ) , form . Tags , NewQuoteProductArray ( form . Products ) )
2023-06-07 14:35:31 +00:00
htmxRedirect ( w , r , companyURI ( company , "/quotes/" + slug ) )
}
func HandleNewQuoteAction ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
switch params [ 0 ] . Value {
case "new" :
handleQuoteAction ( w , r , "/quotes/new" , mustRenderNewQuoteForm )
case "batch" :
HandleBatchQuoteAction ( w , r , params )
default :
http . Error ( w , "Method Not Allowed" , http . StatusMethodNotAllowed )
}
}
func HandleBatchQuoteAction ( w http . ResponseWriter , r * http . Request , _ httprouter . Params ) {
if err := r . ParseForm ( ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusBadRequest )
return
}
if err := verifyCsrfTokenValid ( r ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusForbidden )
return
}
slugs := r . Form [ "quote" ]
if len ( slugs ) == 0 {
http . Redirect ( w , r , companyURI ( mustGetCompany ( r ) , "/quotes" ) , http . StatusSeeOther )
return
}
locale := getLocale ( r )
switch r . Form . Get ( "action" ) {
case "download" :
quotes := mustWriteQuotesPdf ( r , slugs )
w . Header ( ) . Set ( "Content-Type" , "application/zip" )
w . Header ( ) . Set ( "Content-Disposition" , fmt . Sprintf ( "attachment; filename=%s" , gettext ( "quotations.zip" , locale ) ) )
w . WriteHeader ( http . StatusOK )
if _ , err := w . Write ( quotes ) ; err != nil {
panic ( err )
}
default :
http . Error ( w , gettext ( "Invalid action" , locale ) , http . StatusBadRequest )
}
}
func mustWriteQuotesPdf ( r * http . Request , slugs [ ] string ) [ ] byte {
conn := getConn ( r )
company := mustGetCompany ( r )
buf := new ( bytes . Buffer )
w := zip . NewWriter ( buf )
for _ , slug := range slugs {
quo := mustGetQuote ( r . Context ( ) , conn , company , slug )
if quo == nil {
continue
}
f , err := w . Create ( quo . Number + ".pdf" )
if err != nil {
panic ( err )
}
mustWriteQuotePdf ( f , r , quo )
}
mustClose ( w )
return buf . Bytes ( )
}
type quoteForm struct {
locale * Locale
company * Company
Number string
QuoteStatus * SelectField
Customer * SelectField
Date * InputField
TermsAndConditions * InputField
Notes * InputField
PaymentMethod * SelectField
Tags * TagsField
Products [ ] * quoteProductForm
RemovedProduct * quoteProductForm
}
func newQuoteForm ( ctx context . Context , conn * Conn , locale * Locale , company * Company ) * quoteForm {
return & quoteForm {
locale : locale ,
company : company ,
QuoteStatus : & SelectField {
Name : "quote_status" ,
Required : true ,
Label : pgettext ( "input" , "Quotation Status" , locale ) ,
Selected : [ ] string { "created" } ,
Options : MustGetOptions ( ctx , conn , "select quote_status.quote_status, isi18n.name from quote_status join quote_status_i18n isi18n using(quote_status) where isi18n.lang_tag = $1 order by quote_status" , locale . Language . String ( ) ) ,
} ,
Customer : & SelectField {
Name : "customer" ,
2023-06-20 09:37:02 +00:00
Label : pgettext ( "input" , "Customer" , locale ) ,
2023-06-07 14:35:31 +00:00
EmptyLabel : gettext ( "Select a customer to quote." , locale ) ,
Options : mustGetContactOptions ( ctx , conn , company ) ,
} ,
Date : & InputField {
Name : "date" ,
Label : pgettext ( "input" , "Quotation Date" , locale ) ,
Type : "date" ,
Required : true ,
} ,
TermsAndConditions : & InputField {
Name : "terms_and_conditions" ,
Label : pgettext ( "input" , "Terms and conditions" , locale ) ,
Type : "textarea" ,
} ,
Notes : & InputField {
Name : "notes" ,
Label : pgettext ( "input" , "Notes" , locale ) ,
Type : "textarea" ,
} ,
Tags : & TagsField {
Name : "tags" ,
Label : pgettext ( "input" , "Tags" , locale ) ,
} ,
PaymentMethod : & SelectField {
Name : "payment_method" ,
Label : pgettext ( "input" , "Payment Method" , locale ) ,
EmptyLabel : gettext ( "Select a payment method." , locale ) ,
Options : mustGetPaymentMethodOptions ( ctx , conn , company ) ,
} ,
}
}
func ( form * quoteForm ) Parse ( r * http . Request ) error {
if err := r . ParseForm ( ) ; err != nil {
return err
}
form . QuoteStatus . FillValue ( r )
form . Customer . FillValue ( r )
form . Date . FillValue ( r )
2023-06-08 10:50:16 +00:00
form . TermsAndConditions . FillValue ( r )
2023-06-07 14:35:31 +00:00
form . Notes . FillValue ( r )
form . Tags . FillValue ( r )
form . PaymentMethod . FillValue ( r )
if _ , ok := r . Form [ "product.id.0" ] ; ok {
taxOptions := mustGetTaxOptions ( r . Context ( ) , getConn ( r ) , form . company )
for index := 0 ; true ; index ++ {
if _ , ok := r . Form [ "product.id." + strconv . Itoa ( index ) ] ; ! ok {
break
}
productForm := newQuoteProductForm ( index , form . company , form . locale , taxOptions )
if err := productForm . Parse ( r ) ; err != nil {
return err
}
form . Products = append ( form . Products , productForm )
}
}
return nil
}
func ( form * quoteForm ) Validate ( ) bool {
validator := newFormValidator ( )
validator . CheckValidSelectOption ( form . QuoteStatus , gettext ( "Selected quotation status is not valid." , form . locale ) )
if form . Customer . String ( ) != "" {
validator . CheckValidSelectOption ( form . Customer , gettext ( "Selected customer is not valid." , form . locale ) )
}
if validator . CheckRequiredInput ( form . Date , gettext ( "Quotation date can not be empty." , form . locale ) ) {
validator . CheckValidDate ( form . Date , gettext ( "Quotation date must be a valid date." , form . locale ) )
}
if form . PaymentMethod . String ( ) != "" {
validator . CheckValidSelectOption ( form . PaymentMethod , gettext ( "Selected payment method is not valid." , form . locale ) )
}
allOK := validator . AllOK ( )
for _ , product := range form . Products {
allOK = product . Validate ( ) && allOK
}
return allOK
}
func ( form * quoteForm ) Update ( ) {
products := form . Products
form . Products = nil
for n , product := range products {
if product . Quantity . Val != "0" {
product . Update ( )
if n != len ( form . Products ) {
product . Index = len ( form . Products )
product . Rename ( )
}
form . Products = append ( form . Products , product )
}
}
}
func ( form * quoteForm ) RemoveProduct ( index int ) {
products := form . Products
form . Products = nil
for n , product := range products {
if n == index {
form . RemovedProduct = product
} else {
if n != len ( form . Products ) {
product . Index = len ( form . Products )
product . Rename ( )
}
form . Products = append ( form . Products , product )
}
}
if form . RemovedProduct != nil {
form . RemovedProduct . RenameWithSuffix ( removedProductSuffix )
}
}
func ( form * quoteForm ) AddProducts ( ctx context . Context , conn * Conn , productsSlug [ ] string ) {
form . mustAddProductsFromQuery ( ctx , conn , selectProductBySlug , productsSlug )
}
func ( form * quoteForm ) mustAddProductsFromQuery ( ctx context . Context , conn * Conn , sql string , args ... interface { } ) {
index := len ( form . Products )
taxOptions := mustGetTaxOptions ( ctx , conn , form . company )
rows := conn . MustQuery ( ctx , sql , args ... )
defer rows . Close ( )
for rows . Next ( ) {
product := newQuoteProductForm ( index , form . company , form . locale , taxOptions )
if err := rows . Scan ( product . QuoteProductId , product . ProductId , product . Name , product . Description , product . Price , product . Quantity , product . Discount , product . Tax ) ; err != nil {
panic ( err )
}
form . Products = append ( form . Products , product )
index ++
}
if rows . Err ( ) != nil {
panic ( rows . Err ( ) )
}
}
func ( form * quoteForm ) InsertProduct ( product * quoteProductForm ) {
replaced := false
for n , existing := range form . Products {
if existing . Quantity . Val == "" || existing . Quantity . Val == "0" {
product . Index = n
form . Products [ n ] = product
replaced = true
break
}
}
if ! replaced {
product . Index = len ( form . Products )
form . Products = append ( form . Products , product )
}
product . Rename ( )
}
func ( form * quoteForm ) MustFillFromDatabase ( ctx context . Context , conn * Conn , slug string ) bool {
var quoteId int
selectedQuoteStatus := form . QuoteStatus . Selected
form . QuoteStatus . Clear ( )
selectedPaymentMethod := form . PaymentMethod . Selected
form . PaymentMethod . Clear ( )
if notFoundErrorOrPanic ( conn . QueryRow ( ctx , `
select quote_id
, quote_status
, contact_id
, quote_number
, quote_date
2023-06-08 10:50:16 +00:00
, terms_and_conditions
2023-06-07 14:35:31 +00:00
, notes
, payment_method_id
, tags
from quote
left join quote_contact using ( quote_id )
left join quote_payment_method using ( quote_id )
where slug = $ 1
2023-06-08 10:50:16 +00:00
` , slug ) . Scan ( & quoteId , form . QuoteStatus , form . Customer , & form . Number , form . Date , form . TermsAndConditions , form . Notes , form . PaymentMethod , form . Tags ) ) {
2023-06-07 14:35:31 +00:00
form . PaymentMethod . Selected = selectedPaymentMethod
form . QuoteStatus . Selected = selectedQuoteStatus
return false
}
form . Products = [ ] * quoteProductForm { }
form . mustAddProductsFromQuery ( ctx , conn , "select quote_product_id::text, coalesce(product_id, 0), name, description, to_price(price, $2), quantity, (discount_rate * 100)::integer, array_remove(array_agg(tax_id), null) from quote_product left join quote_product_product using (quote_product_id) left join quote_product_tax using (quote_product_id) where quote_id = $1 group by quote_product_id, coalesce(product_id, 0), name, description, discount_rate, price, quantity" , quoteId , form . company . DecimalDigits )
return true
}
type quoteProductForm struct {
locale * Locale
company * Company
Index int
QuoteProductId * InputField
ProductId * InputField
Name * InputField
Description * InputField
Price * InputField
Quantity * InputField
Discount * InputField
Tax * SelectField
}
func newQuoteProductForm ( index int , company * Company , locale * Locale , taxOptions [ ] * SelectOption ) * quoteProductForm {
triggerRecompute := template . HTMLAttr ( ` data-hx-on="change: this.dispatchEvent(new CustomEvent('recompute', { bubbles: true}))" ` )
form := & quoteProductForm {
locale : locale ,
company : company ,
Index : index ,
QuoteProductId : & InputField {
Label : pgettext ( "input" , "Id" , locale ) ,
Type : "hidden" ,
Required : true ,
} ,
ProductId : & InputField {
Label : pgettext ( "input" , "Id" , locale ) ,
Type : "hidden" ,
Required : true ,
} ,
Name : & InputField {
Label : pgettext ( "input" , "Name" , locale ) ,
Type : "text" ,
Required : true ,
Is : "numerus-product-search" ,
Attributes : [ ] template . HTMLAttr {
` autocomplete="off" ` ,
` data-hx-trigger="keyup changed delay:200" ` ,
` data-hx-target="next .options" ` ,
` data-hx-indicator="closest div" ` ,
` data-hx-swap="innerHTML" ` ,
template . HTMLAttr ( fmt . Sprintf ( ` data-hx-get="%v" ` , companyURI ( company , "/search/products" ) ) ) ,
} ,
} ,
Description : & InputField {
Label : pgettext ( "input" , "Description" , locale ) ,
Type : "textarea" ,
} ,
Price : & InputField {
Label : pgettext ( "input" , "Price" , locale ) ,
Type : "number" ,
Required : true ,
Attributes : [ ] template . HTMLAttr {
triggerRecompute ,
` min="0" ` ,
template . HTMLAttr ( fmt . Sprintf ( ` step="%v" ` , company . MinCents ( ) ) ) ,
} ,
} ,
Quantity : & InputField {
Label : pgettext ( "input" , "Quantity" , locale ) ,
Type : "number" ,
Required : true ,
Attributes : [ ] template . HTMLAttr {
triggerRecompute ,
` min="0" ` ,
} ,
} ,
Discount : & InputField {
Label : pgettext ( "input" , "Discount (%)" , locale ) ,
Type : "number" ,
Required : true ,
Attributes : [ ] template . HTMLAttr {
triggerRecompute ,
` min="0" ` ,
` max="100" ` ,
} ,
} ,
Tax : & SelectField {
Label : pgettext ( "input" , "Taxes" , locale ) ,
Multiple : true ,
Options : taxOptions ,
Attributes : [ ] template . HTMLAttr {
triggerRecompute ,
} ,
} ,
}
form . Rename ( )
return form
}
func ( form * quoteProductForm ) Rename ( ) {
form . RenameWithSuffix ( "." + strconv . Itoa ( form . Index ) )
}
func ( form * quoteProductForm ) RenameWithSuffix ( suffix string ) {
form . QuoteProductId . Name = "product.quote_product_id" + suffix
form . ProductId . Name = "product.id" + suffix
form . Name . Name = "product.name" + suffix
form . Description . Name = "product.description" + suffix
form . Price . Name = "product.price" + suffix
form . Quantity . Name = "product.quantity" + suffix
form . Discount . Name = "product.discount" + suffix
form . Tax . Name = "product.tax" + suffix
}
func ( form * quoteProductForm ) Parse ( r * http . Request ) error {
if err := r . ParseForm ( ) ; err != nil {
return err
}
form . QuoteProductId . FillValue ( r )
form . ProductId . FillValue ( r )
form . Name . FillValue ( r )
form . Description . FillValue ( r )
form . Price . FillValue ( r )
form . Quantity . FillValue ( r )
form . Discount . FillValue ( r )
form . Tax . FillValue ( r )
return nil
}
func ( form * quoteProductForm ) Validate ( ) bool {
validator := newFormValidator ( )
if form . QuoteProductId . Val != "" {
validator . CheckValidInteger ( form . QuoteProductId , 1 , math . MaxInt32 , gettext ( "Quotation product ID must be a number greater than zero." , form . locale ) )
}
if form . ProductId . Val != "" {
validator . CheckValidInteger ( form . ProductId , 0 , math . MaxInt32 , gettext ( "Product ID must be a positive number or zero." , form . locale ) )
}
validator . CheckRequiredInput ( form . Name , gettext ( "Name can not be empty." , form . locale ) )
if validator . CheckRequiredInput ( form . Price , gettext ( "Price can not be empty." , form . locale ) ) {
validator . CheckValidDecimal ( form . Price , form . company . MinCents ( ) , math . MaxFloat64 , gettext ( "Price must be a number greater than zero." , form . locale ) )
}
if validator . CheckRequiredInput ( form . Quantity , gettext ( "Quantity can not be empty." , form . locale ) ) {
validator . CheckValidInteger ( form . Quantity , 1 , math . MaxInt32 , gettext ( "Quantity must be a number greater than zero." , form . locale ) )
}
if validator . CheckRequiredInput ( form . Discount , gettext ( "Discount can not be empty." , form . locale ) ) {
validator . CheckValidInteger ( form . Discount , 0 , 100 , gettext ( "Discount must be a percentage between 0 and 100." , form . locale ) )
}
validator . CheckValidSelectOption ( form . Tax , gettext ( "Selected tax is not valid." , form . locale ) )
validator . CheckAtMostOneOfEachGroup ( form . Tax , gettext ( "You can only select a tax of each class." , form . locale ) )
return validator . AllOK ( )
}
func ( form * quoteProductForm ) Update ( ) {
validator := newFormValidator ( )
if ! validator . CheckValidDecimal ( form . Price , form . company . MinCents ( ) , math . MaxFloat64 , "" ) {
form . Price . Val = "0.0"
form . Price . Errors = nil
}
if ! validator . CheckValidInteger ( form . Quantity , 0 , math . MaxInt32 , "" ) {
form . Quantity . Val = "1"
form . Quantity . Errors = nil
}
if ! validator . CheckValidInteger ( form . Discount , 0 , 100 , "" ) {
form . Discount . Val = "0"
form . Discount . Errors = nil
}
}
func ( form * quoteProductForm ) MustFillFromDatabase ( ctx context . Context , conn * Conn , slug string ) bool {
return ! notFoundErrorOrPanic ( conn . QueryRow ( ctx , selectProductBySlug , [ ] string { slug } ) . Scan (
form . QuoteProductId ,
form . ProductId ,
form . Name ,
form . Description ,
form . Price ,
form . Quantity ,
form . Discount ,
form . Tax ) )
}
func HandleUpdateQuote ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
locale := getLocale ( r )
conn := getConn ( r )
company := mustGetCompany ( r )
form := newQuoteForm ( r . Context ( ) , conn , locale , company )
if err := form . Parse ( r ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusBadRequest )
return
}
if err := verifyCsrfTokenValid ( r ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusForbidden )
return
}
if r . FormValue ( "quick" ) == "status" {
slug := conn . MustGetText ( r . Context ( ) , "" , "update quote set quote_status = $1 where slug = $2 returning slug" , form . QuoteStatus , params [ 0 ] . Value )
if slug == "" {
http . NotFound ( w , r )
}
htmxRedirect ( w , r , companyURI ( mustGetCompany ( r ) , "/quotes" ) )
} else {
slug := params [ 0 ] . Value
if ! form . Validate ( ) {
if ! IsHTMxRequest ( r ) {
w . WriteHeader ( http . StatusUnprocessableEntity )
}
mustRenderEditQuoteForm ( w , r , slug , form )
return
}
Allow empty contact and payment method for quotes
I have to use a value to be used as “none” for payment method and
contact. In PL/pgSQL add_quote and edit_quote functions, that value is
NULL, while in forms it is the empty string. I can not simply pass the
empty string for either of these fields because PL/pgSQL expects
(nullable) integers, and "" is not a valid integer and is not NULL
either. A conversion is necessary.
Apparently, Go’s nil is not a valid representation for SQL’s NULL with
pgx, and had to use sql.NullString instead.
I also needed to coalesce contact’s VATIN and phone, because null values
can not be scanned to *string. I did not do that before because
`coalesce(vatin, '')` throws an error that '' is not a valid VATIN and
just left as is, wrongly expecting that pgx would do the job of leaving
the string blank for me. It does not.
Lastly, i can not blindly write Quotee’s tax details in the quote’s view
page, or we would see the (), characters for the empty address info.
2023-06-08 11:05:41 +00:00
slug = conn . MustGetText ( r . Context ( ) , "" , "select edit_quote($1, $2, $3, $4, $5, $6, $7, $8)" , slug , form . QuoteStatus , form . Customer . OrNull ( ) , form . TermsAndConditions , form . Notes , form . PaymentMethod . OrNull ( ) , form . Tags , EditedQuoteProductArray ( form . Products ) )
2023-06-07 14:35:31 +00:00
if slug == "" {
http . NotFound ( w , r )
return
}
htmxRedirect ( w , r , companyURI ( company , "/quotes/" + slug ) )
}
}
func ServeEditQuote ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
conn := getConn ( r )
company := mustGetCompany ( r )
slug := params [ 0 ] . Value
locale := getLocale ( r )
form := newQuoteForm ( r . Context ( ) , conn , locale , company )
if ! form . MustFillFromDatabase ( r . Context ( ) , conn , slug ) {
http . NotFound ( w , r )
return
}
w . WriteHeader ( http . StatusOK )
mustRenderEditQuoteForm ( w , r , slug , form )
}
type editQuotePage struct {
* newQuotePage
Slug string
Number string
}
func newEditQuotePage ( slug string , form * quoteForm , r * http . Request ) * editQuotePage {
return & editQuotePage {
newNewQuotePage ( form , r ) ,
slug ,
form . Number ,
}
}
func mustRenderEditQuoteForm ( w http . ResponseWriter , r * http . Request , slug string , form * quoteForm ) {
page := newEditQuotePage ( slug , form , r )
mustRenderMainTemplate ( w , r , "quotes/edit.gohtml" , page )
}
func HandleEditQuoteAction ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
slug := params [ 0 ] . Value
actionUri := fmt . Sprintf ( "/quotes/%s/edit" , slug )
handleQuoteAction ( w , r , actionUri , func ( w http . ResponseWriter , r * http . Request , form * quoteForm ) {
conn := getConn ( r )
form . Number = conn . MustGetText ( r . Context ( ) , "" , "select quote_number from quote where slug = $1" , slug )
mustRenderEditQuoteForm ( w , r , slug , form )
} )
}
type renderQuoteFormFunc func ( w http . ResponseWriter , r * http . Request , form * quoteForm )
func handleQuoteAction ( w http . ResponseWriter , r * http . Request , action string , renderForm renderQuoteFormFunc ) {
locale := getLocale ( r )
conn := getConn ( r )
company := mustGetCompany ( r )
form := newQuoteForm ( r . Context ( ) , conn , locale , company )
if err := form . Parse ( r ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusBadRequest )
return
}
if err := verifyCsrfTokenValid ( r ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusForbidden )
return
}
actionField := r . Form . Get ( "action" )
switch actionField {
case "update" :
form . Update ( )
w . WriteHeader ( http . StatusOK )
renderForm ( w , r , form )
case "select-products" :
w . WriteHeader ( http . StatusOK )
mustRenderNewQuoteProductsForm ( w , r , action , form )
case "add-products" :
form . AddProducts ( r . Context ( ) , conn , r . Form [ "slug" ] )
w . WriteHeader ( http . StatusOK )
renderForm ( w , r , form )
case "restore-product" :
restoredProduct := newQuoteProductForm ( 0 , company , locale , mustGetTaxOptions ( r . Context ( ) , conn , company ) )
restoredProduct . RenameWithSuffix ( removedProductSuffix )
if err := restoredProduct . Parse ( r ) ; err != nil {
panic ( err )
}
form . InsertProduct ( restoredProduct )
form . Update ( )
w . WriteHeader ( http . StatusOK )
renderForm ( w , r , form )
default :
prefix := "remove-product."
if strings . HasPrefix ( actionField , prefix ) {
index , err := strconv . Atoi ( actionField [ len ( prefix ) : ] )
if err != nil {
http . Error ( w , gettext ( "Invalid action" , locale ) , http . StatusBadRequest )
} else {
form . RemoveProduct ( index )
form . Update ( )
w . WriteHeader ( http . StatusOK )
renderForm ( w , r , form )
}
} else {
http . Error ( w , gettext ( "Invalid action" , locale ) , http . StatusBadRequest )
}
}
}
func ServeEditQuoteTags ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
conn := getConn ( r )
locale := getLocale ( r )
company := getCompany ( r )
slug := params [ 0 ] . Value
form := newTagsForm ( companyURI ( company , "/quotes/" + slug + "/tags" ) , slug , locale )
if notFoundErrorOrPanic ( conn . QueryRow ( r . Context ( ) , ` select tags from quote where slug = $1 ` , form . Slug ) . Scan ( form . Tags ) ) {
http . NotFound ( w , r )
return
}
mustRenderStandaloneTemplate ( w , r , "tags/edit.gohtml" , form )
}
func HandleUpdateQuoteTags ( w http . ResponseWriter , r * http . Request , params httprouter . Params ) {
locale := getLocale ( r )
conn := getConn ( r )
company := getCompany ( r )
slug := params [ 0 ] . Value
form := newTagsForm ( companyURI ( company , "/quotes/" + slug + "/tags/edit" ) , slug , locale )
if err := form . Parse ( r ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusBadRequest )
return
}
if err := verifyCsrfTokenValid ( r ) ; err != nil {
http . Error ( w , err . Error ( ) , http . StatusForbidden )
return
}
if conn . MustGetText ( r . Context ( ) , "" , "update quote set tags = $1 where slug = $2 returning slug" , form . Tags , form . Slug ) == "" {
http . NotFound ( w , r )
}
mustRenderStandaloneTemplate ( w , r , "tags/view.gohtml" , form )
}