Adding ability to disable automatic reminders

This commit is contained in:
Finley Ghosh 2025-12-07 12:44:30 +11:00
parent 57047e3b32
commit bbdd035d04
8 changed files with 177 additions and 13 deletions

View file

@ -342,17 +342,20 @@ type Quote struct {
// 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"`
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"`
RemindersDisabled sql.NullBool `json:"reminders_disabled"`
RemindersDisabledAt sql.NullTime `json:"reminders_disabled_at"`
RemindersDisabledBy sql.NullString `json:"reminders_disabled_by"`
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 QuoteReminder struct {

View file

@ -43,6 +43,7 @@ type Querier interface {
DeletePurchaseOrder(ctx context.Context, id int32) error
DeleteState(ctx context.Context, id int32) error
DeleteStatus(ctx context.Context, id int32) error
DisableQuoteReminders(ctx context.Context, arg DisableQuoteRemindersParams) (sql.Result, error)
GetAddress(ctx context.Context, id int32) (Address, error)
GetAllCountries(ctx context.Context) ([]Country, error)
GetAllPrinciples(ctx context.Context) ([]Principle, error)
@ -76,6 +77,7 @@ type Querier interface {
GetPurchaseOrderRevisions(ctx context.Context, parentPurchaseOrderID int32) ([]PurchaseOrder, error)
GetPurchaseOrdersByPrinciple(ctx context.Context, arg GetPurchaseOrdersByPrincipleParams) ([]PurchaseOrder, error)
GetQuoteRemindersByType(ctx context.Context, arg GetQuoteRemindersByTypeParams) ([]QuoteReminder, error)
GetQuoteRemindersDisabled(ctx context.Context, id int32) (GetQuoteRemindersDisabledRow, error)
GetRecentDocuments(ctx context.Context, limit int32) ([]GetRecentDocumentsRow, error)
GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesRow, error)
GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesOnDayRow, error)

View file

@ -11,6 +11,23 @@ import (
"time"
)
const disableQuoteReminders = `-- name: DisableQuoteReminders :execresult
UPDATE quotes
SET reminders_disabled = TRUE,
reminders_disabled_at = NOW(),
reminders_disabled_by = ?
WHERE id = ?
`
type DisableQuoteRemindersParams struct {
RemindersDisabledBy sql.NullString `json:"reminders_disabled_by"`
ID int32 `json:"id"`
}
func (q *Queries) DisableQuoteReminders(ctx context.Context, arg DisableQuoteRemindersParams) (sql.Result, error) {
return q.db.ExecContext(ctx, disableQuoteReminders, arg.RemindersDisabledBy, arg.ID)
}
const getExpiringSoonQuotes = `-- name: GetExpiringSoonQuotes :many
WITH ranked_reminders AS (
SELECT
@ -61,6 +78,7 @@ WHERE
q.valid_until >= CURRENT_DATE
AND q.valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
ORDER BY q.valid_until
`
@ -164,6 +182,7 @@ WHERE
q.valid_until >= CURRENT_DATE
AND q.valid_until = DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
ORDER BY q.valid_until
`
@ -258,6 +277,25 @@ func (q *Queries) GetQuoteRemindersByType(ctx context.Context, arg GetQuoteRemin
return items, nil
}
const getQuoteRemindersDisabled = `-- name: GetQuoteRemindersDisabled :one
SELECT reminders_disabled, reminders_disabled_at, reminders_disabled_by
FROM quotes
WHERE id = ?
`
type GetQuoteRemindersDisabledRow struct {
RemindersDisabled sql.NullBool `json:"reminders_disabled"`
RemindersDisabledAt sql.NullTime `json:"reminders_disabled_at"`
RemindersDisabledBy sql.NullString `json:"reminders_disabled_by"`
}
func (q *Queries) GetQuoteRemindersDisabled(ctx context.Context, id int32) (GetQuoteRemindersDisabledRow, error) {
row := q.db.QueryRowContext(ctx, getQuoteRemindersDisabled, id)
var i GetQuoteRemindersDisabledRow
err := row.Scan(&i.RemindersDisabled, &i.RemindersDisabledAt, &i.RemindersDisabledBy)
return i, err
}
const getRecentlyExpiredQuotes = `-- name: GetRecentlyExpiredQuotes :many
WITH ranked_reminders AS (
SELECT
@ -308,6 +346,7 @@ WHERE
q.valid_until < CURRENT_DATE
AND valid_until >= DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
ORDER BY q.valid_until DESC
`
@ -411,6 +450,7 @@ WHERE
q.valid_until < CURRENT_DATE
AND valid_until = DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
ORDER BY q.valid_until DESC
`

View file

@ -65,13 +65,20 @@ func (es *EmailService) SendTemplateEmail(to string, subject string, templateNam
func (es *EmailService) SendTemplateEmailWithAttachments(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string, attachments []interface{}) error {
// Convert interface{} attachments to []Attachment
var typedAttachments []Attachment
for _, att := range attachments {
fmt.Printf("DEBUG: Received %d attachments to convert\n", len(attachments))
for i, att := range attachments {
fmt.Printf("DEBUG: Attachment %d type: %T\n", i, att)
if a, ok := att.(Attachment); ok {
fmt.Printf("DEBUG: Converted to Attachment type: %s -> %s\n", a.Filename, a.FilePath)
typedAttachments = append(typedAttachments, a)
} else if a, ok := att.(struct{ Filename, FilePath string }); ok {
fmt.Printf("DEBUG: Converted from anonymous struct: %s -> %s\n", a.Filename, a.FilePath)
typedAttachments = append(typedAttachments, Attachment{Filename: a.Filename, FilePath: a.FilePath})
} else {
fmt.Printf("DEBUG: Failed to convert attachment type %T\n", att)
}
}
fmt.Printf("DEBUG: Final typed attachments count: %d\n", len(typedAttachments))
defaultBccs := []string{"carpis@cmctechnologies.com.au"}
bccs = append(defaultBccs, bccs...)

View file

@ -0,0 +1,22 @@
-- +goose Up
-- Add reminders_disabled field to quotes table
ALTER TABLE quotes
ADD COLUMN reminders_disabled BOOLEAN DEFAULT FALSE AFTER valid_until,
ADD COLUMN reminders_disabled_at DATETIME DEFAULT NULL AFTER reminders_disabled,
ADD COLUMN reminders_disabled_by VARCHAR(100) DEFAULT NULL AFTER reminders_disabled_at;
-- +goose StatementBegin
CREATE INDEX idx_reminders_disabled ON quotes(reminders_disabled);
-- +goose StatementEnd
-- +goose Down
-- Remove index
-- +goose StatementBegin
DROP INDEX idx_reminders_disabled ON quotes;
-- +goose StatementEnd
-- Remove columns from quotes
ALTER TABLE quotes
DROP COLUMN reminders_disabled_by,
DROP COLUMN reminders_disabled_at,
DROP COLUMN reminders_disabled;

View file

@ -48,6 +48,7 @@ WHERE
q.valid_until >= CURRENT_DATE
AND q.valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
ORDER BY q.valid_until;
@ -101,6 +102,7 @@ WHERE
q.valid_until >= CURRENT_DATE
AND q.valid_until = DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
ORDER BY q.valid_until;
@ -154,6 +156,7 @@ WHERE
q.valid_until < CURRENT_DATE
AND valid_until >= DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
ORDER BY q.valid_until DESC;
@ -207,6 +210,7 @@ WHERE
q.valid_until < CURRENT_DATE
AND valid_until = DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
ORDER BY q.valid_until DESC;
@ -218,4 +222,16 @@ ORDER BY date_sent;
-- name: InsertQuoteReminder :execresult
INSERT INTO quote_reminders (quote_id, reminder_type, date_sent, username)
VALUES (?, ?, ?, ?);
VALUES (?, ?, ?, ?);
-- name: DisableQuoteReminders :execresult
UPDATE quotes
SET reminders_disabled = TRUE,
reminders_disabled_at = NOW(),
reminders_disabled_by = ?
WHERE id = ?;
-- name: GetQuoteRemindersDisabled :one
SELECT reminders_disabled, reminders_disabled_at, reminders_disabled_by
FROM quotes
WHERE id = ?;

View file

@ -13,6 +13,9 @@ CREATE TABLE IF NOT EXISTS `quotes` (
`days_valid` int(3) NOT NULL,
`date_issued` date NOT NULL,
`valid_until` date NOT NULL,
`reminders_disabled` tinyint(1) DEFAULT 0,
`reminders_disabled_at` datetime DEFAULT NULL,
`reminders_disabled_by` varchar(100) DEFAULT NULL,
`delivery_point` varchar(400) NOT NULL,
`exchange_rate` varchar(255) NOT NULL,
`customs_duty` varchar(255) NOT NULL,

View file

@ -71,6 +71,8 @@
<li><button type="button" onclick="showConfirmModal(this, 1, '{{.EnquiryRef}}', '{{.CustomerName}}', 'First Reminder')" class="block w-full text-left px-4 py-2 hover:bg-gray-100">Send First Reminder</button></li>
<li><button type="button" onclick="showConfirmModal(this, 2, '{{.EnquiryRef}}', '{{.CustomerName}}', 'Second Reminder')" class="block w-full text-left px-4 py-2 hover:bg-gray-100">Send Second Reminder</button></li>
<li><button type="button" onclick="showConfirmModal(this, 3, '{{.EnquiryRef}}', '{{.CustomerName}}', 'Final Reminder')" class="block w-full text-left px-4 py-2 hover:bg-gray-100">Send Final Reminder</button></li>
<li class="border-t border-gray-200"></li>
<li><button type="button" onclick="showDisableModal('{{.ID}}', '{{.EnquiryRef}}')" class="block w-full text-left px-4 py-2 hover:bg-gray-100 text-red-600">Disable Future Reminders</button></li>
</ul>
</div>
</form>
@ -151,6 +153,8 @@
<li><button type="button" onclick="showConfirmModal(this, 1, '{{.EnquiryRef}}', '{{.CustomerName}}', 'First Reminder')" class="block w-full text-left px-4 py-2 hover:bg-gray-100">Send First Reminder</button></li>
<li><button type="button" onclick="showConfirmModal(this, 2, '{{.EnquiryRef}}', '{{.CustomerName}}', 'Second Reminder')" class="block w-full text-left px-4 py-2 hover:bg-gray-100">Send Second Reminder</button></li>
<li><button type="button" onclick="showConfirmModal(this, 3, '{{.EnquiryRef}}', '{{.CustomerName}}', 'Final Reminder')" class="block w-full text-left px-4 py-2 hover:bg-gray-100">Send Final Reminder</button></li>
<li class="border-t border-gray-200"></li>
<li><button type="button" onclick="showDisableModal('{{.ID}}', '{{.EnquiryRef}}')" class="block w-full text-left px-4 py-2 hover:bg-gray-100 text-red-600">Disable Future Reminders</button></li>
</ul>
</div>
</form>
@ -188,9 +192,35 @@
</div>
</div>
<!-- Disable Reminders Modal -->
<div id="disableModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div class="mt-3">
<div class="flex items-center justify-center w-12 h-12 mx-auto bg-red-600 rounded-full">
<i class="fas fa-ban text-white text-xl"></i>
</div>
<h3 class="text-lg leading-6 font-large font-bold text-gray-900 text-center mt-4">Are you sure?</h3>
<div class="px-7 py-3">
<p class="text-sm text-gray-600 text-center">
This will prevent future automatic reminders from being sent for enquiry <strong id="disableModalEnquiryRef"></strong>.
</p>
</div>
<div class="flex gap-3 px-4 py-3">
<button id="disableModalCancelBtn" class="flex-1 px-4 py-2 bg-gray-200 text-gray-800 text-sm font-medium rounded-md hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-300">
Cancel
</button>
<button id="disableModalConfirmBtn" class="flex-1 px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-600 focus:ring-offset-2">
Disable Reminders
</button>
</div>
</div>
</div>
</div>
<script>
let currentForm = null;
let currentReminderType = null;
let currentQuoteID = null;
function showConfirmModal(button, reminderType, enquiryRef, customerName, reminderTypeName) {
currentForm = button.closest('form');
@ -208,6 +238,20 @@ function hideConfirmModal() {
currentReminderType = null;
}
function showDisableModal(quoteID, enquiryRef) {
currentQuoteID = quoteID;
document.getElementById('disableModalEnquiryRef').textContent = enquiryRef;
document.getElementById('disableModal').classList.remove('hidden');
// Close any open dropdowns
document.querySelectorAll('form > div.absolute').forEach(d => d.classList.add('hidden'));
}
function hideDisableModal() {
document.getElementById('disableModal').classList.add('hidden');
currentQuoteID = null;
}
document.getElementById('modalCancelBtn').addEventListener('click', hideConfirmModal);
document.getElementById('modalConfirmBtn').addEventListener('click', function() {
@ -223,6 +267,27 @@ document.getElementById('modalConfirmBtn').addEventListener('click', function()
hideConfirmModal();
});
document.getElementById('disableModalCancelBtn').addEventListener('click', hideDisableModal);
document.getElementById('disableModalConfirmBtn').addEventListener('click', function() {
if (currentQuoteID) {
// Create a form to POST the disable request
const form = document.createElement('form');
form.method = 'POST';
form.action = '/go/quotes/disable-reminders';
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'quote_id';
input.value = currentQuoteID;
form.appendChild(input);
document.body.appendChild(form);
form.submit();
}
hideDisableModal();
});
// Close modal when clicking outside
document.getElementById('confirmModal').addEventListener('click', function(e) {
if (e.target === this) {
@ -230,6 +295,12 @@ document.getElementById('confirmModal').addEventListener('click', function(e) {
}
});
document.getElementById('disableModal').addEventListener('click', function(e) {
if (e.target === this) {
hideDisableModal();
}
});
function toggleDropdown(button) {
const dropdown = button.parentElement.nextElementSibling;
const allDropdowns = document.querySelectorAll('form > div.absolute');