diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index c6bd3f0..c9d3725 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -935,6 +935,15 @@ export interface SmsBalances { export const getSmsBalances = (): Promise => apiFetch('/reports/sms-balances') +// D28: operations settings — SMS gateway URL, low-balance threshold, ticket SLA (owner). +export interface OperationsSettings { + smsBalanceApiUrl: string; smsLowThreshold: number; ticketSlaDays: number +} +export const getOperationsSettings = (): Promise => + apiFetch('/settings/operations') +export const putOperationsSettings = (body: Partial): Promise => + apiFetch('/settings/operations', { method: 'PUT', body: JSON.stringify(body) }) + // D28: live balance fetch from the gateway (managerial — decrypts credentials). export interface SmsRefreshSummary { attempted: number; ok: number; failed: number; changed: number; skipped: number diff --git a/apps/hq-web/src/pages/Settings.tsx b/apps/hq-web/src/pages/Settings.tsx index 832347c..fb77a04 100644 --- a/apps/hq-web/src/pages/Settings.tsx +++ b/apps/hq-web/src/pages/Settings.tsx @@ -2,12 +2,13 @@ import { useEffect, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { formatINR } from '@sims/domain' import { - Button, DataTable, Dialog, EmptyState, ErrorState, FormField, FormGrid, + Button, DataTable, Dialog, EmptyState, ErrorState, FormField, FormGrid, Toolbar, Notice, PageHeader, Skeleton, ThemeSwitcher, useToast, } from '@sims/ui' import { createSchedule, getAwsRanking, getCompanyProfile, getEmailStatus, getReminderSettings, getSharingSettings, putCompanyProfile, putReminderSettings, putSharingSettings, role, + getOperationsSettings, putOperationsSettings, type OperationsSettings, } from '../api' import { COMPANY_FIELDS, TemplatePanel } from './DocumentTemplate' import { useData } from './Clients' @@ -15,12 +16,13 @@ import { useData } from './Clients' const inr = (p: number) => formatINR(p, { symbol: false }) type Dict = Record -type SectionKey = 'company' | 'template' | 'reminders' | 'sharing' | 'integrations' | 'appearance' +type SectionKey = 'company' | 'template' | 'reminders' | 'operations' | 'sharing' | 'integrations' | 'appearance' const SECTIONS: { key: SectionKey; label: string; ownerOnly: boolean }[] = [ { key: 'company', label: 'Company', ownerOnly: true }, { key: 'template', label: 'Document template', ownerOnly: true }, { key: 'reminders', label: 'Reminders', ownerOnly: true }, + { key: 'operations', label: 'Operations', ownerOnly: true }, { key: 'sharing', label: 'Sharing', ownerOnly: true }, { key: 'integrations', label: 'Integrations', ownerOnly: true }, { key: 'appearance', label: 'Appearance', ownerOnly: false }, @@ -64,6 +66,7 @@ export function Settings() { {active === 'company' && } {active === 'template' && } {active === 'reminders' && } + {active === 'operations' && } {active === 'sharing' && } {active === 'integrations' && } {active === 'appearance' && } @@ -73,6 +76,59 @@ export function Settings() { ) } +/** Operations (D28) — the running-the-service knobs: SMS gateway + thresholds + ticket SLA. + * All are DB settings resolved by the app (config over code); editing here is the whole point. */ +function OperationsPanel() { + const toast = useToast() + const [s, setS] = useState() + const [saving, setSaving] = useState(false) + + useEffect(() => { + getOperationsSettings().then(setS).catch((e: Error) => toast.err(e.message)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const save = () => { + if (saving || s === undefined) return + setSaving(true) + putOperationsSettings(s) + .then((next) => { setS(next); toast.ok('Operations settings saved.') }) + .catch((e: Error) => toast.err(e.message)) + .finally(() => setSaving(false)) + } + + return ( +
+

Operations

+

The knobs behind the SMS balance screen and the ticket desk — change them here, no release needed.

+ {s === undefined ? : ( + <> + + + setS({ ...s, smsBalanceApiUrl: e.target.value })} /> + The provider panel HQ polls for each SMS account’s balance. Public host only. + + + setS({ ...s, smsLowThreshold: Number(e.target.value) })} /> + Below this many SMS a client is flagged “low” and shows in the upsell list. + + + setS({ ...s, ticketSlaDays: Number(e.target.value) })} /> + A still-open ticket older than this is marked overdue on the desk. + + + + + + + )} +
+ ) +} + /** Company identity — same field list as the letterhead template's Company section * (`COMPANY_FIELDS`, imported so the two never drift apart). */ function CompanyPanel() { diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index e0ce108..480dfce 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -48,7 +48,7 @@ import { listInteractionTypes, updateInteraction, type CreateInteractionInput, type InteractionPatch, } from './repos-interactions' import { - dismissReminder, getNumberSetting, getReminder, insertSchedule, listQueue, listSchedules, setSetting, + dismissReminder, getNumberSetting, getSetting, getReminder, insertSchedule, listQueue, listSchedules, setSetting, type QueueOpts, type ReminderStatus, } from './repos-reminders' import { sendReminder, reminderContext, type SendReminderDeps } from './send-reminder' @@ -62,7 +62,8 @@ import { clientProfitability, duesAging, gstSummary, moduleRevenue, type DateRan import { listUsageRateCards, setUsageRateCard } from './repos-rate-card' import { renewalsDue, generateModuleRenewalQuote } from './repos-renewals' import { misCockpit } from './repos-mis' -import { smsBalances, refreshSmsBalance, refreshAllSmsBalances } from './repos-sms' +import { smsBalances, refreshSmsBalance, refreshAllSmsBalances, DEFAULT_SMS_BALANCE_API } from './repos-sms' +import { assertSafeGatewayUrl } from './sms-gateway' import { makeRateLimiter } from './rate-limit' import { addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard, @@ -1663,6 +1664,47 @@ export function apiRouter( res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) + // ---------- operations knobs (owner): SMS gateway + thresholds + ticket SLA (D28) ---------- + r.get('/settings/operations', requireAuth, requireOwner, async (_req, res) => { + try { + res.json({ + ok: true, + smsBalanceApiUrl: (await getSetting(db, 'sms.balance_api_url')) ?? DEFAULT_SMS_BALANCE_API, + smsLowThreshold: await getNumberSetting(db, 'sms.low_balance_threshold', 10000), + ticketSlaDays: await getNumberSetting(db, 'ticket.sla_days', 7), + }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.put('/settings/operations', requireAuth, requireOwner, async (req, res) => { + const b = req.body as { smsBalanceApiUrl?: unknown; smsLowThreshold?: unknown; ticketSlaDays?: unknown } + try { + if (b.smsBalanceApiUrl !== undefined) { + const url = String(b.smsBalanceApiUrl).trim() + if (url !== '') assertSafeGatewayUrl(url) // reject internal/loopback targets before storing + await setSetting(db, staffId(res), 'sms.balance_api_url', url) + } + if (b.smsLowThreshold !== undefined) { + const n = Number(b.smsLowThreshold) + if (!Number.isInteger(n) || n < 0) throw new Error('Low-balance threshold must be a whole number ≥ 0') + await setSetting(db, staffId(res), 'sms.low_balance_threshold', String(n)) + } + if (b.ticketSlaDays !== undefined) { + const n = Number(b.ticketSlaDays) + if (!Number.isInteger(n) || n < 1) throw new Error('Ticket SLA must be a whole number of days ≥ 1') + await setSetting(db, staffId(res), 'ticket.sla_days', String(n)) + } + res.json({ + ok: true, + smsBalanceApiUrl: (await getSetting(db, 'sms.balance_api_url')) ?? DEFAULT_SMS_BALANCE_API, + smsLowThreshold: await getNumberSetting(db, 'sms.low_balance_threshold', 10000), + ticketSlaDays: await getNumberSetting(db, 'ticket.sla_days', 7), + }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) r.put('/settings/company', requireAuth, requireOwner, async (req, res) => { const body = req.body as Record try {