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.
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -105,6 +105,7 @@ function transformQuote(q) {
|
|||||||
responseLockedAt: q.response_locked_at,
|
responseLockedAt: q.response_locked_at,
|
||||||
acceptedAt: q.accepted_at,
|
acceptedAt: q.accepted_at,
|
||||||
declinedAt: q.declined_at,
|
declinedAt: q.declined_at,
|
||||||
|
declineReason: q.decline_reason ?? null,
|
||||||
convertedEventId: q.converted_event_id,
|
convertedEventId: q.converted_event_id,
|
||||||
// Migration 130 lineage. Null until quoteService.createFromQuote
|
// Migration 130 lineage. Null until quoteService.createFromQuote
|
||||||
// sets it. Surfaced so QuoteDetailPage can render a "Linked
|
// 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(
|
router.post(
|
||||||
'/:id/convert',
|
'/:id/convert',
|
||||||
requirePermission('quotes.manage'),
|
requirePermission('quotes.manage'),
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext');
|
|||||||
const pdfService = require('./pdfService');
|
const pdfService = require('./pdfService');
|
||||||
const emailProcessor = require('./emailProcessor');
|
const emailProcessor = require('./emailProcessor');
|
||||||
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
||||||
|
const { hasColumnCached } = require('../utils/schemaCache');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
@@ -1173,6 +1174,69 @@ async function adminAcceptQuote(id, adminId) {
|
|||||||
return { status: 'accepted', lockedAt: responseLockedAt };
|
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.
|
* Convert an accepted quote to an event + scheduled invoices.
|
||||||
* Wraps everything in a transaction so a half-finished conversion
|
* Wraps everything in a transaction so a half-finished conversion
|
||||||
@@ -1786,6 +1850,7 @@ module.exports = {
|
|||||||
duplicateQuote,
|
duplicateQuote,
|
||||||
recordResponse,
|
recordResponse,
|
||||||
adminAcceptQuote,
|
adminAcceptQuote,
|
||||||
|
adminDeclineQuote,
|
||||||
convertToEvent,
|
convertToEvent,
|
||||||
convertToInvoiceOnly,
|
convertToInvoiceOnly,
|
||||||
|
|
||||||
|
|||||||
@@ -3438,6 +3438,9 @@
|
|||||||
"send": "Senden",
|
"send": "Senden",
|
||||||
"resend": "Erneut senden",
|
"resend": "Erneut senden",
|
||||||
"convert": "In Anlass umwandeln",
|
"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": {
|
"field": {
|
||||||
"issueDate": "Ausgestellt am",
|
"issueDate": "Ausgestellt am",
|
||||||
"validUntil": "Gültig bis",
|
"validUntil": "Gültig bis",
|
||||||
@@ -3446,6 +3449,7 @@
|
|||||||
"sentAt": "Gesendet am",
|
"sentAt": "Gesendet am",
|
||||||
"acceptedAt": "Angenommen am",
|
"acceptedAt": "Angenommen am",
|
||||||
"declinedAt": "Abgelehnt am",
|
"declinedAt": "Abgelehnt am",
|
||||||
|
"declineReason": "Ablehnungsgrund",
|
||||||
"responseWindow": "Antwortfrist",
|
"responseWindow": "Antwortfrist",
|
||||||
"eventTimeStart": "Startzeit",
|
"eventTimeStart": "Startzeit",
|
||||||
"eventTimeEnd": "Endzeit",
|
"eventTimeEnd": "Endzeit",
|
||||||
|
|||||||
@@ -3430,6 +3430,9 @@
|
|||||||
"send": "Send",
|
"send": "Send",
|
||||||
"resend": "Resend",
|
"resend": "Resend",
|
||||||
"convert": "Convert to event",
|
"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": {
|
"field": {
|
||||||
"issueDate": "Issued",
|
"issueDate": "Issued",
|
||||||
"validUntil": "Valid until",
|
"validUntil": "Valid until",
|
||||||
@@ -3438,6 +3441,7 @@
|
|||||||
"sentAt": "Sent at",
|
"sentAt": "Sent at",
|
||||||
"acceptedAt": "Accepted at",
|
"acceptedAt": "Accepted at",
|
||||||
"declinedAt": "Declined at",
|
"declinedAt": "Declined at",
|
||||||
|
"declineReason": "Decline reason",
|
||||||
"responseWindow": "Response window",
|
"responseWindow": "Response window",
|
||||||
"eventTimeStart": "Start time",
|
"eventTimeStart": "Start time",
|
||||||
"eventTimeEnd": "End time",
|
"eventTimeEnd": "End time",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import React from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
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 { Button, Card, Loading } from '../../../components/common';
|
||||||
import { DocumentLineageCard } from '../../../components/admin/DocumentLineageCard';
|
import { DocumentLineageCard } from '../../../components/admin/DocumentLineageCard';
|
||||||
import { quotesService } from '../../../services/quotes.service';
|
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 () => {
|
const handleDuplicate = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await quotesService.duplicate(q.id);
|
const result = await quotesService.duplicate(q.id);
|
||||||
@@ -168,6 +188,15 @@ export const QuoteDetailPage: React.FC = () => {
|
|||||||
{t('quotes.acceptOnBehalf', 'Accept on behalf')}
|
{t('quotes.acceptOnBehalf', 'Accept on behalf')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{/* 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) && (
|
||||||
|
<Button variant="outline" onClick={handleDeclineOnBehalf}>
|
||||||
|
<XCircle className="w-4 h-4 mr-1" />
|
||||||
|
{t('quotes.declineOnBehalf', 'Decline on behalf')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{q.status === 'accepted' && (
|
{q.status === 'accepted' && (
|
||||||
<>
|
<>
|
||||||
<Button onClick={handleConvert}>
|
<Button onClick={handleConvert}>
|
||||||
@@ -207,6 +236,7 @@ export const QuoteDetailPage: React.FC = () => {
|
|||||||
{q.sentAt && <div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.sentAt', 'Sent at')}</div><div>{fmtDateTime(q.sentAt)}</div></div>}
|
{q.sentAt && <div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.sentAt', 'Sent at')}</div><div>{fmtDateTime(q.sentAt)}</div></div>}
|
||||||
{q.acceptedAt && <div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.acceptedAt', 'Accepted at')}</div><div>{fmtDateTime(q.acceptedAt)}</div></div>}
|
{q.acceptedAt && <div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.acceptedAt', 'Accepted at')}</div><div>{fmtDateTime(q.acceptedAt)}</div></div>}
|
||||||
{q.declinedAt && <div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.declinedAt', 'Declined at')}</div><div>{fmtDateTime(q.declinedAt)}</div></div>}
|
{q.declinedAt && <div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.declinedAt', 'Declined at')}</div><div>{fmtDateTime(q.declinedAt)}</div></div>}
|
||||||
|
{q.declineReason && <div className="col-span-2 md:col-span-4"><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.declineReason', 'Decline reason')}</div><div className="whitespace-pre-line">{q.declineReason}</div></div>}
|
||||||
{q.respondedAt && !responseLocked && (
|
{q.respondedAt && !responseLocked && (
|
||||||
<div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.responseWindow', 'Response window')}</div>
|
<div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.responseWindow', 'Response window')}</div>
|
||||||
<div className="text-amber-700">{t('quotes.responseWindowOpen', 'Open until {{at}}', { at: q.responseLockedAt ? fmtDateTime(q.responseLockedAt) : '' })}</div></div>
|
<div className="text-amber-700">{t('quotes.responseWindowOpen', 'Open until {{at}}', { at: q.responseLockedAt ? fmtDateTime(q.responseLockedAt) : '' })}</div></div>
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ export interface QuoteDetail extends QuoteSummary {
|
|||||||
ccPdfEmail: string | null;
|
ccPdfEmail: string | null;
|
||||||
respondedAt: string | null;
|
respondedAt: string | null;
|
||||||
responseLockedAt: string | null;
|
responseLockedAt: string | null;
|
||||||
|
/** Free-text reason captured when an admin declines on the customer's
|
||||||
|
* behalf (migration 115). Null for customer-side declines + non-declined
|
||||||
|
* quotes. */
|
||||||
|
declineReason: string | null;
|
||||||
pdfPath: string | null;
|
pdfPath: string | null;
|
||||||
businessBankAccountId: number | null;
|
businessBankAccountId: number | null;
|
||||||
}
|
}
|
||||||
@@ -252,6 +256,14 @@ export const quotesService = {
|
|||||||
return data.data || data;
|
return data.data || data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Admin decline-on-behalf — flips the quote to `declined` without the
|
||||||
|
* customer's public response page. Optional free-text reason. Used
|
||||||
|
* when the customer says no by phone/email. */
|
||||||
|
async declineOnBehalf(id: number, reason?: string): Promise<{ status: string; declinedAt: string }> {
|
||||||
|
const { data } = await api.post(`/admin/quotes/${id}/decline`, reason ? { reason } : {});
|
||||||
|
return data.data || data;
|
||||||
|
},
|
||||||
|
|
||||||
async convert(id: number): Promise<{ eventId: number; alreadyConverted: boolean }> {
|
async convert(id: number): Promise<{ eventId: number; alreadyConverted: boolean }> {
|
||||||
const { data } = await api.post(`/admin/quotes/${id}/convert`);
|
const { data } = await api.post(`/admin/quotes/${id}/convert`);
|
||||||
return data.data || data;
|
return data.data || data;
|
||||||
|
|||||||
Reference in New Issue
Block a user