From 8d14441df492ac46235de247f59500da3eedfc2e Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:47:31 +0200 Subject: [PATCH] feat(quotes): admin decline-on-behalf with optional reason Add a "Decline on behalf" action mirroring accept-on-behalf, for when a customer says no by phone/email. Flips a draft/sent/expired quote to declined, stamps declined_at, closes the public response window, and invalidates outstanding accept/decline tokens so the emailed link can't toggle it back. Optional free-text reason persisted to a new quotes.decline_reason column (migration 115) and shown on the quote detail page. Hard-delete intentionally not included. --- .../core/115_add_quote_decline_reason.js | 31 +++++++++ backend/src/routes/adminQuotes.js | 19 ++++++ backend/src/services/quoteService.js | 65 +++++++++++++++++++ frontend/src/i18n/locales/de.json | 4 ++ frontend/src/i18n/locales/en.json | 4 ++ .../pages/admin/quotes/QuoteDetailPage.tsx | 32 ++++++++- frontend/src/services/quotes.service.ts | 12 ++++ 7 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/core/115_add_quote_decline_reason.js diff --git a/backend/migrations/core/115_add_quote_decline_reason.js b/backend/migrations/core/115_add_quote_decline_reason.js new file mode 100644 index 00000000..9915b0a3 --- /dev/null +++ b/backend/migrations/core/115_add_quote_decline_reason.js @@ -0,0 +1,31 @@ +/** + * Migration: admin decline-quote reason. + * + * Background: admins can now decline a quote on the customer's behalf + * ("customer told us by phone they're not going ahead") instead of + * waiting for the public response link. This stores an optional free-text + * reason alongside the existing `declined_at` timestamp so the quote + * detail page can show WHY it was declined. + * + * Nullable, no default — existing declined rows simply carry no reason, + * which is exactly how customer-side declines already look. No behaviour + * change on upgrade. + * + * Idempotent: guarded by hasColumn so a re-run is a no-op. + */ + +exports.up = async function(knex) { + if (!(await knex.schema.hasTable('quotes'))) return; + if (await knex.schema.hasColumn('quotes', 'decline_reason')) return; + await knex.schema.alterTable('quotes', (table) => { + table.text('decline_reason'); + }); +}; + +exports.down = async function(knex) { + if (!(await knex.schema.hasTable('quotes'))) return; + if (!(await knex.schema.hasColumn('quotes', 'decline_reason'))) return; + await knex.schema.alterTable('quotes', (table) => { + table.dropColumn('decline_reason'); + }); +}; diff --git a/backend/src/routes/adminQuotes.js b/backend/src/routes/adminQuotes.js index b0769ec0..fd8664b4 100644 --- a/backend/src/routes/adminQuotes.js +++ b/backend/src/routes/adminQuotes.js @@ -105,6 +105,7 @@ function transformQuote(q) { responseLockedAt: q.response_locked_at, acceptedAt: q.accepted_at, declinedAt: q.declined_at, + declineReason: q.decline_reason ?? null, convertedEventId: q.converted_event_id, // Migration 130 lineage. Null until quoteService.createFromQuote // sets it. Surfaced so QuoteDetailPage can render a "Linked @@ -450,6 +451,24 @@ router.post( }) ); +// Admin "decline on behalf" — flips a draft/sent/expired quote to +// `declined` without the customer's public link. For "they said no by +// phone" workflows. Optional free-text reason persisted on the row. +router.post( + '/:id/decline', + requirePermission('quotes.manage'), + [ + param('id').isInt({ min: 1 }), + body('reason').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }), + ], + handleAsync(async (req, res) => { + validateRequest(req); + const id = parseInt(req.params.id, 10); + const result = await quoteService.adminDeclineQuote(id, req.admin.id, req.body.reason); + return successResponse(res, result, 200, 'Quote declined'); + }) +); + router.post( '/:id/convert', requirePermission('quotes.manage'), diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index af277091..c6304309 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -39,6 +39,7 @@ const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext'); const pdfService = require('./pdfService'); const emailProcessor = require('./emailProcessor'); const { getFrontendBaseUrl } = require('../utils/frontendUrl'); +const { hasColumnCached } = require('../utils/schemaCache'); const fs = require('fs'); const path = require('path'); @@ -1173,6 +1174,69 @@ async function adminAcceptQuote(id, adminId) { return { status: 'accepted', lockedAt: responseLockedAt }; } +/** + * Admin "decline on behalf of customer" — records the quote as + * `declined` directly, bypassing the public token + response window. + * Used when the customer says no by phone/email and the admin wants the + * pipeline reflected without asking them to click the decline link. + * + * Mirrors adminAcceptQuote's guards: refuses quotes that are already + * terminal (`accepted`, `declined`, `converted`) — those would overwrite + * history. Allowed from `draft` / `sent` / `expired`. + * + * `reason` is optional free text persisted to `quotes.decline_reason` + * (migration 115) and surfaced on the quote detail page. + * + * Any outstanding accept/decline tokens are invalidated so the customer + * can't flip the quote back to accepted via a still-live emailed link. + */ +async function adminDeclineQuote(id, adminId, reason = null) { + const quote = await db('quotes').where({ id }).first(); + if (!quote) throw new AppError('Quote not found', 404); + if (quote.status === 'declined') { + throw new AppError('Quote already declined', 409, 'QUOTE_ALREADY_DECLINED'); + } + if (quote.status === 'accepted') { + throw new AppError('Quote already accepted; duplicate it to start a fresh round.', 409, 'QUOTE_ALREADY_ACCEPTED'); + } + if (quote.status === 'converted') { + throw new AppError('Quote already converted to an event/invoice', 409, 'QUOTE_CONVERTED'); + } + + const now = new Date(); + const cleanReason = typeof reason === 'string' && reason.trim() ? reason.trim().slice(0, 5000) : null; + const hasReasonColumn = await hasColumnCached('quotes', 'decline_reason'); + + await db.transaction(async (trx) => { + const updates = { + status: 'declined', + responded_at: quote.responded_at || now, + // Close the public response window immediately so a customer link + // can't toggle the quote afterwards (recordResponse rejects once + // now > response_locked_at). + response_locked_at: now, + declined_at: now, + accepted_at: null, + updated_at: now, + }; + if (hasReasonColumn) updates.decline_reason = cleanReason; + await trx('quotes').where({ id }).update(updates); + + // Burn any unused tokens for this quote — defense in depth alongside + // the closed response window above. + await trx('quote_action_tokens') + .where({ quote_id: id }) + .whereNull('used_at') + .update({ used_at: now, used_action: 'declined' }); + }); + + try { + await logActivity('quote_declined_by_admin', { quoteId: id, reason: cleanReason }, null, `admin:${adminId}`); + } catch (_) {} + + return { status: 'declined', declinedAt: now }; +} + /** * Convert an accepted quote to an event + scheduled invoices. * Wraps everything in a transaction so a half-finished conversion @@ -1786,6 +1850,7 @@ module.exports = { duplicateQuote, recordResponse, adminAcceptQuote, + adminDeclineQuote, convertToEvent, convertToInvoiceOnly, diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index afa5c587..4161ca43 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3438,6 +3438,9 @@ "send": "Senden", "resend": "Erneut senden", "convert": "In Anlass umwandeln", + "declineOnBehalf": "Im Namen ablehnen", + "declineReasonPrompt": "Dieses Angebot im Namen des Kunden als abgelehnt markieren? Optional einen Grund angeben (leer lassen zum Überspringen).", + "declinedOnBehalfToast": "Angebot als abgelehnt markiert.", "field": { "issueDate": "Ausgestellt am", "validUntil": "Gültig bis", @@ -3446,6 +3449,7 @@ "sentAt": "Gesendet am", "acceptedAt": "Angenommen am", "declinedAt": "Abgelehnt am", + "declineReason": "Ablehnungsgrund", "responseWindow": "Antwortfrist", "eventTimeStart": "Startzeit", "eventTimeEnd": "Endzeit", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c8a90ea7..515ae02a 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3430,6 +3430,9 @@ "send": "Send", "resend": "Resend", "convert": "Convert to event", + "declineOnBehalf": "Decline on behalf", + "declineReasonPrompt": "Mark this quote as declined on behalf of the customer? Optionally note why (leave blank to skip).", + "declinedOnBehalfToast": "Quote marked as declined.", "field": { "issueDate": "Issued", "validUntil": "Valid until", @@ -3438,6 +3441,7 @@ "sentAt": "Sent at", "acceptedAt": "Accepted at", "declinedAt": "Declined at", + "declineReason": "Decline reason", "responseWindow": "Response window", "eventTimeStart": "Start time", "eventTimeEnd": "End time", diff --git a/frontend/src/pages/admin/quotes/QuoteDetailPage.tsx b/frontend/src/pages/admin/quotes/QuoteDetailPage.tsx index ca2226b8..fe1f4c37 100644 --- a/frontend/src/pages/admin/quotes/QuoteDetailPage.tsx +++ b/frontend/src/pages/admin/quotes/QuoteDetailPage.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { ArrowLeft, Eye, Send, Copy, ArrowRightCircle, Edit2, Receipt, CheckCircle2, ScrollText } from 'lucide-react'; +import { ArrowLeft, Eye, Send, Copy, ArrowRightCircle, Edit2, Receipt, CheckCircle2, ScrollText, XCircle } from 'lucide-react'; import { Button, Card, Loading } from '../../../components/common'; import { DocumentLineageCard } from '../../../components/admin/DocumentLineageCard'; import { quotesService } from '../../../services/quotes.service'; @@ -125,6 +125,26 @@ export const QuoteDetailPage: React.FC = () => { } }; + /** + * Admin decline-on-behalf. Used when the customer says no by phone/ + * email — admin flips the quote to declined and (optionally) records + * why. The quote can still be duplicated to start a fresh round. + */ + const handleDeclineOnBehalf = async () => { + const reason = window.prompt(t('quotes.declineReasonPrompt', + 'Mark this quote as declined on behalf of the customer? Optionally note why (leave blank to skip).')); + // prompt returns null on Cancel; '' (empty) means "decline, no reason". + if (reason === null) return; + try { + await quotesService.declineOnBehalf(q.id, reason.trim() || undefined); + toast.success(t('quotes.declinedOnBehalfToast', 'Quote marked as declined.')); + qc.invalidateQueries({ queryKey: ['quote', id] }); + qc.invalidateQueries({ queryKey: ['quotes'] }); + } catch (err: any) { + toast.error(err?.response?.data?.error || 'Decline failed'); + } + }; + const handleDuplicate = async () => { try { const result = await quotesService.duplicate(q.id); @@ -168,6 +188,15 @@ export const QuoteDetailPage: React.FC = () => { {t('quotes.acceptOnBehalf', 'Accept on behalf')} )} + {/* Decline-on-behalf — same states as accept-on-behalf. Flips + the quote to declined for "customer said no by phone" + cases; hidden once accepted / declined / converted. */} + {['draft', 'sent', 'expired'].includes(q.status) && ( + + )} {q.status === 'accepted' && ( <>