Sms & Email

This commit is contained in:
2026-10-20 10:45:03 +05:30
parent a40e463e8b
commit 0e423b806c
29 changed files with 13845 additions and 26 deletions
@@ -1,5 +1,5 @@
import Link from "next/link"
import { BookOpen, Building2, ClipboardList, LayoutTemplate, MapPin, PackageCheck, ShieldCheck, ShoppingCart, SlidersHorizontal } from "lucide-react"
import { BookOpen, Building2, ClipboardList, LayoutTemplate, MapPin, PackageCheck, Send, ShieldCheck, ShoppingCart, SlidersHorizontal } from "lucide-react"
const cards = [
{
@@ -56,6 +56,12 @@ const cards = [
icon: LayoutTemplate,
description: "Logo, footer, terms, and template style for printed invoices",
},
{
title: "Day-End Owner Report",
href: "/dashboard/settings/owner-report",
icon: Send,
description: "Send the day's revenue to the owner by SMS and email",
},
]
export default function ConfigurationPage() {
@@ -0,0 +1,524 @@
"use client"
import { useEffect, useState } from "react"
import { Clock, Eye, Mail, MessageSquare, Plus, Send, Trash2, Users } from "lucide-react"
import { ownerReportApi } from "@/lib/api/owner-report"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { limitPhoneInput, phoneError } from "@/lib/validations/phone"
import { PaginationMeta } from "@/types/common"
import { OwnerReportConfig, OwnerReportDispatch, OwnerReportPreview, OwnerReportRecipientInput } from "@/types/owner-report"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const MAX_RECIPIENTS = 5
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const PANEL = "flex flex-col gap-4 rounded-2xl border bg-card p-4 shadow-sm sm:p-5"
/** Editable copy of the settings — saved together with one PUT (the recipients are a list). */
type Draft = {
enabled: boolean
smsEnabled: boolean
emailEnabled: boolean
sendAtLocalTime: string
includeBranchBreakdown: boolean
recipients: (OwnerReportRecipientInput & { key: number })[]
}
function toDraft(c: OwnerReportConfig): Draft {
return {
enabled: c.enabled,
smsEnabled: c.smsEnabled,
emailEnabled: c.emailEnabled,
sendAtLocalTime: c.sendAtLocalTime,
includeBranchBreakdown: c.includeBranchBreakdown,
recipients: c.recipients.map((r) => ({ ...r, key: r.recipientId })),
}
}
function localToday(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
/** Per-recipient field errors, keyed `${key}-name|mobile|email`. */
function validate(draft: Draft): Record<string, string> {
const errors: Record<string, string> = {}
for (const r of draft.recipients) {
if (!r.name.trim()) errors[`${r.key}-name`] = "Enter a name"
const mobile = r.mobile?.trim() ?? ""
const email = r.email?.trim() ?? ""
if (!mobile && !email) errors[`${r.key}-mobile`] = "Add a mobile number, an email or both"
const pe = mobile ? phoneError(mobile) : null
if (pe) errors[`${r.key}-mobile`] = pe
if (email && !EMAIL_PATTERN.test(email)) errors[`${r.key}-email`] = "Enter a valid email address"
}
if (draft.enabled && !draft.recipients.some((r) => r.isActive)) errors.recipients = "Add at least one active recipient before switching the report on"
if (draft.enabled && !draft.smsEnabled && !draft.emailEnabled) errors.channels = "Switch on SMS, email or both"
return errors
}
/**
* Day-End Owner Report — sends the day's revenue and details to the owner by SMS and email at a
* set time (docs/DAY_END_OWNER_REPORT_PLAN.md). Messages go out through the auth service's own
* SMTP / SMS settings, so nothing here holds mail credentials.
*/
export default function OwnerReportSettingsPage() {
const [config, setConfig] = useState<OwnerReportConfig | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [draft, setDraft] = useState<Draft | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
const [nextKey, setNextKey] = useState(-1)
const [previewDate, setPreviewDate] = useState(localToday())
const [preview, setPreview] = useState<OwnerReportPreview | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [testOpen, setTestOpen] = useState(false)
const [testing, setTesting] = useState(false)
const [log, setLog] = useState<OwnerReportDispatch[] | null>(null)
const [logPagination, setLogPagination] = useState<PaginationMeta | null>(null)
const [logPage, setLogPage] = useState(1)
const [logPageSize, setLogPageSize] = useState(10)
const [logReload, setLogReload] = useState(0)
function load() {
ownerReportApi
.get()
.then((res) => {
setConfig(res.data)
setEtag(res.etag)
setDraft(toDraft(res.data))
setErrors({})
})
.catch((err) => {
setLoadError(errorMessage(err))
toast.error("Could not load the owner report settings", errorMessage(err))
})
}
useEffect(load, [])
useEffect(() => {
ownerReportApi
.dispatches({ page: logPage, pageSize: logPageSize })
.then((res) => {
setLog(res.items)
setLogPagination(res.pagination)
})
.catch((err) => {
setLog([])
toast.error("Could not load the send log", errorMessage(err))
})
}, [logPage, logPageSize, logReload])
const dirty = config !== null && draft !== null && JSON.stringify(toDraft(config)) !== JSON.stringify(draft)
function patch(next: Partial<Draft>) {
setDraft((d) => (d ? { ...d, ...next } : d))
}
function patchRecipient(key: number, next: Partial<OwnerReportRecipientInput>) {
setDraft((d) => (d ? { ...d, recipients: d.recipients.map((r) => (r.key === key ? { ...r, ...next } : r)) } : d))
setErrors((e) => {
const copy = { ...e }
for (const field of Object.keys(next)) delete copy[`${key}-${field}`]
delete copy.recipients
return copy
})
}
function addRecipient() {
if (!draft || draft.recipients.length >= MAX_RECIPIENTS) return
patch({ recipients: [...draft.recipients, { key: nextKey, recipientId: null, name: draft.recipients.length === 0 ? "Owner" : "", mobile: "", email: "", isActive: true }] })
setNextKey((k) => k - 1)
}
async function save() {
if (!draft) return
const nextErrors = validate(draft)
setErrors(nextErrors)
const messages = [...new Set(Object.values(nextErrors))]
if (messages.length > 0) {
toast.error("Please fix the highlighted fields", messages.length === 1 ? messages[0] : `${messages[0]} (and ${messages.length - 1} more)`)
return
}
setSaving(true)
try {
const res = await ownerReportApi.update(
{
enabled: draft.enabled,
smsEnabled: draft.smsEnabled,
emailEnabled: draft.emailEnabled,
sendAtLocalTime: draft.sendAtLocalTime,
includeBranchBreakdown: draft.includeBranchBreakdown,
recipients: draft.recipients.map((r) => ({
recipientId: r.recipientId,
name: r.name.trim(),
mobile: r.mobile?.trim() || null,
email: r.email?.trim() || null,
isActive: r.isActive,
})),
},
etag ?? "",
)
setConfig(res.data)
setEtag(res.etag)
setDraft(toDraft(res.data))
toast.success("Owner report saved", res.data.enabled ? `It will be sent daily at ${res.data.sendAtLocalTime}.` : "The daily report is switched off.")
} catch (err) {
toast.error("Could not save the owner report settings", errorMessage(err))
} finally {
setSaving(false)
}
}
async function loadPreview() {
setPreviewLoading(true)
try {
setPreview(await ownerReportApi.preview(previewDate))
} catch (err) {
toast.error("Could not build the preview", errorMessage(err))
} finally {
setPreviewLoading(false)
}
}
async function sendTest() {
setTesting(true)
try {
const results = await ownerReportApi.sendTest(previewDate)
const failed = results.filter((r) => !r.ok)
if (failed.length === 0) toast.success("Test sent", `${results.length} message${results.length === 1 ? "" : "s"} sent.`)
else toast.error(`${failed.length} of ${results.length} messages failed`, failed.map((f) => `${f.destination}: ${f.error ?? "failed"}`).join(" · "))
setTestOpen(false)
setLogPage(1)
setLogReload((n) => n + 1)
} catch (err) {
toast.error("Could not send the test", errorMessage(err))
} finally {
setTesting(false)
}
}
return (
<div className="flex flex-col gap-4 pb-24 sm:gap-6">
<div>
<h1 className="text-xl font-bold text-foreground sm:text-2xl">Day-End Owner Report</h1>
<p className="text-sm text-muted-foreground sm:text-base">
Send the day&apos;s revenue and details to the owner by SMS and email, automatically every evening.
</p>
</div>
{loadError && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{loadError}</div>}
{!loadError && !draft && <Skeleton className="h-64 w-full rounded-2xl" />}
{draft && config && (
<>
{/* Delivery */}
<section className={PANEL}>
<div className="flex items-center gap-2">
<Clock className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Delivery</h2>
</div>
<ToggleRow
checked={draft.enabled}
onChange={(v) => patch({ enabled: v })}
label="Send the daily report"
help="Off: nothing is sent automatically (you can still send a test)."
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<ToggleRow checked={draft.smsEnabled} onChange={(v) => patch({ smsEnabled: v })} label="SMS" help="A short revenue summary." icon={MessageSquare} />
<ToggleRow checked={draft.emailEnabled} onChange={(v) => patch({ emailEnabled: v })} label="Email" help="The full details: summary, cash, top sales and expenses." icon={Mail} />
</div>
{errors.channels && <FieldError errors={[{ message: errors.channels }]} />}
<ToggleRow
checked={draft.includeBranchBreakdown}
onChange={(v) => patch({ includeBranchBreakdown: v })}
label="Include a per-branch breakdown"
help="Adds one row per branch to the email when branches are switched on."
/>
<div className="flex flex-col gap-2 sm:max-w-xs">
<Label htmlFor="or-time" className="text-base">Send time</Label>
<Input
id="or-time"
type="time"
value={draft.sendAtLocalTime}
onChange={(e) => patch({ sendAtLocalTime: e.target.value })}
className="h-11 text-base"
/>
<p className="text-xs text-muted-foreground">Time zone: {config.timeZone}. If the server is down then, it sends as soon as it is back the same day.</p>
</div>
</section>
{/* Recipients */}
<section className={PANEL}>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Users className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Recipients</h2>
<span className="text-sm text-muted-foreground">({draft.recipients.length}/{MAX_RECIPIENTS})</span>
</div>
<Button type="button" variant="outline" className="h-10" onClick={addRecipient} disabled={draft.recipients.length >= MAX_RECIPIENTS}>
<Plus className="size-4" />
Add recipient
</Button>
</div>
{errors.recipients && <FieldError errors={[{ message: errors.recipients }]} />}
{draft.recipients.length === 0 ? (
<p className="rounded-xl border border-dashed p-6 text-center text-sm text-muted-foreground">
No recipients yet — add the owner&apos;s mobile number and email.
</p>
) : (
<ul className="flex flex-col gap-3">
{draft.recipients.map((r) => (
<li key={r.key} className={cn("grid grid-cols-1 gap-3 rounded-xl border p-3 sm:grid-cols-[1fr_1fr_1.4fr_auto] sm:items-start", !r.isActive && "opacity-60")}>
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">Name</Label>
<Input value={r.name} maxLength={100} onChange={(e) => patchRecipient(r.key, { name: e.target.value })} placeholder="Owner" aria-invalid={!!errors[`${r.key}-name`]} className="h-11 text-base sm:h-10" />
<FieldError errors={[errors[`${r.key}-name`] ? { message: errors[`${r.key}-name`] } : undefined]} />
</div>
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">Mobile (SMS)</Label>
<Input
type="tel"
inputMode="tel"
maxLength={20}
value={r.mobile ?? ""}
onChange={(e) => patchRecipient(r.key, { mobile: limitPhoneInput(e.target.value) })}
placeholder="0771234567"
aria-invalid={!!errors[`${r.key}-mobile`]}
className="h-11 text-base sm:h-10"
/>
<FieldError errors={[errors[`${r.key}-mobile`] ? { message: errors[`${r.key}-mobile`] } : undefined]} />
</div>
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">Email</Label>
<Input
type="email"
inputMode="email"
maxLength={200}
value={r.email ?? ""}
onChange={(e) => patchRecipient(r.key, { email: e.target.value })}
placeholder="owner@company.com"
aria-invalid={!!errors[`${r.key}-email`]}
className="h-11 text-base sm:h-10"
/>
<FieldError errors={[errors[`${r.key}-email`] ? { message: errors[`${r.key}-email`] } : undefined]} />
</div>
<div className="flex items-center justify-between gap-3 sm:flex-col sm:items-end sm:pt-5">
<label className="flex items-center gap-2 text-sm">
<Switch checked={r.isActive} onCheckedChange={(v) => patchRecipient(r.key, { isActive: v })} aria-label={`${r.name || "Recipient"} active`} />
Active
</label>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => patch({ recipients: draft.recipients.filter((x) => x.key !== r.key) })}
aria-label={`Remove ${r.name || "recipient"}`}
className="text-destructive hover:bg-destructive/10"
>
<Trash2 className="size-4" />
</Button>
</div>
</li>
))}
</ul>
)}
</section>
{/* Preview & test */}
<section className={PANEL}>
<div className="flex items-center gap-2">
<Eye className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Preview &amp; test</h2>
</div>
<div className="grid grid-cols-2 items-end gap-2 sm:flex sm:flex-wrap sm:gap-3">
<div className="col-span-2 flex flex-col gap-1 sm:col-span-1">
<Label htmlFor="or-date" className="text-xs text-muted-foreground">Day</Label>
<Input id="or-date" type="date" value={previewDate} max={localToday()} onChange={(e) => setPreviewDate(e.target.value)} className="h-11 text-base sm:h-10 sm:w-44" />
</div>
<Button type="button" variant="outline" className="h-11 sm:h-10" onClick={loadPreview} disabled={previewLoading}>
<Eye className="size-4" />
{previewLoading ? "Building…" : "Preview"}
</Button>
<Button
type="button"
className="h-11 sm:h-10"
onClick={() => {
if (dirty) {
toast.error("Save your changes first", "The test is sent to the saved recipients.")
return
}
setTestOpen(true)
}}
>
<Send className="size-4" />
Send test now
</Button>
</div>
{preview && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,18rem)_1fr]">
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-foreground">SMS</p>
<div className="rounded-2xl rounded-tl-sm bg-muted px-4 py-3 font-mono text-sm leading-relaxed whitespace-pre-wrap">{preview.smsText}</div>
<p className="text-xs text-muted-foreground">{preview.smsText.length} characters</p>
</div>
<div className="flex min-w-0 flex-col gap-2">
<p className="text-sm font-medium text-foreground">Email — {preview.emailSubject}</p>
{/* Sandboxed: the HTML is our own template, but it never gets scripts or same-origin access. */}
<iframe title="Email preview" sandbox="" srcDoc={preview.emailHtml} className="h-112 w-full rounded-xl border bg-white" />
</div>
</div>
)}
</section>
{/* Send log */}
<section className={PANEL}>
<div className="flex items-center gap-2">
<Send className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Send log</h2>
</div>
{log === null ? (
<Skeleton className="h-24 w-full" />
) : log.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">Nothing sent yet.</p>
) : (
<>
<ul className="flex flex-col gap-2 md:hidden">
{log.map((d) => (
<li key={d.dispatchId} className="flex flex-col gap-1 rounded-lg border p-3 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{d.businessDate} · {d.channel === "Sms" ? "SMS" : "Email"}</span>
<StatusBadge status={d.status} isTest={d.isTest} />
</div>
<span className="truncate text-muted-foreground">{d.recipientName} — {d.destination}</span>
{d.error && <span className="text-xs text-destructive">{d.error}</span>}
</li>
))}
</ul>
<div className="hidden overflow-x-auto md:block">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead>Day</TableHead>
<TableHead>Channel</TableHead>
<TableHead>Recipient</TableHead>
<TableHead>Status</TableHead>
<TableHead>Sent at</TableHead>
<TableHead>Error</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{log.map((d) => (
<TableRow key={d.dispatchId}>
<TableCell>{d.businessDate}</TableCell>
<TableCell>{d.channel === "Sms" ? "SMS" : "Email"}</TableCell>
<TableCell>
{d.recipientName}
<span className="block text-xs text-muted-foreground">{d.destination}</span>
</TableCell>
<TableCell><StatusBadge status={d.status} isTest={d.isTest} /></TableCell>
<TableCell>{d.sentAt ? new Date(d.sentAt + (d.sentAt.endsWith("Z") ? "" : "Z")).toLocaleString() : "—"}</TableCell>
<TableCell className="max-w-64 truncate text-destructive" title={d.error ?? undefined}>{d.error ?? ""}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{logPagination && logPagination.totalItems > 10 && (
<PaginationFooter
pagination={logPagination}
onPageChange={setLogPage}
pageSizeOptions={[10, 25, 50]}
onPageSizeChange={(size) => {
setLogPageSize(size)
setLogPage(1)
}}
/>
)}
</>
)}
</section>
{/* Save bar — sticky so it stays reachable on a long page / phone. */}
<div className="sticky bottom-0 z-10 -mx-6 -mb-6 flex flex-col-reverse gap-3 border-t bg-background/95 px-6 py-4 backdrop-blur sm:flex-row sm:items-center sm:justify-end">
{dirty && <p className="text-center text-sm text-warning sm:mr-auto sm:text-left">You have unsaved changes.</p>}
<Button type="button" variant="outline" size="lg" className="w-full sm:w-auto" disabled={!dirty || saving} onClick={() => { setDraft(toDraft(config)); setErrors({}) }}>
Discard
</Button>
<Button type="button" size="lg" className="w-full sm:w-auto sm:min-w-40" disabled={!dirty || saving} onClick={save}>
{saving ? "Saving…" : "Save"}
</Button>
</div>
</>
)}
<Dialog open={testOpen} onOpenChange={(open) => !testing && setTestOpen(open)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Send a test now?</DialogTitle>
<DialogDescription>
The report for {previewDate} will be sent right away to every active recipient
{config ? ` (${[config.smsEnabled && "SMS", config.emailEnabled && "email"].filter(Boolean).join(" + ")})` : ""}. It is marked as a test in the send log.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" className="h-11 sm:h-9" onClick={() => setTestOpen(false)} disabled={testing}>
Cancel
</Button>
<Button className="h-11 sm:h-9" onClick={sendTest} disabled={testing}>
{testing ? "Sending…" : "Send test"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function ToggleRow({
checked, onChange, label, help, icon: Icon,
}: { checked: boolean; onChange: (v: boolean) => void; label: string; help: string; icon?: typeof Mail }) {
return (
<label className="flex cursor-pointer items-start gap-3 rounded-xl border p-3 hover:bg-muted/30">
<Switch checked={checked} onCheckedChange={onChange} className="mt-0.5 shrink-0" aria-label={label} />
<span className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-sm font-medium text-foreground">
{Icon && <Icon className="size-4 text-muted-foreground" />}
{label}
</span>
<span className="text-xs text-muted-foreground">{help}</span>
</span>
</label>
)
}
function StatusBadge({ status, isTest }: { status: OwnerReportDispatch["status"]; isTest: boolean }) {
const tone =
status === "Sent" ? "border-success/30 bg-success/10 text-success" : status === "Failed" ? "border-destructive/30 bg-destructive/10 text-destructive" : "border-warning/30 bg-warning/10 text-warning"
return (
<span className="flex items-center gap-1.5">
<Badge variant="outline" className={cn("text-xs", tone)}>{status}</Badge>
{isTest && <Badge variant="outline" className="text-xs">Test</Badge>}
</span>
)
}
@@ -45,6 +45,7 @@ const SEGMENT_LABELS: Record<string, string> = {
runs: "Runs",
invoices: "Sales",
"day-end": "Approved Invoices",
"owner-report": "Day-End Owner Report",
}
function labelFor(segment: string): string {
@@ -110,6 +110,7 @@ function titleFromPath(pathname: string) {
if (pathname === "/dashboard/sales/warranties") return "Warranties"
if (pathname === "/dashboard/sales/day-end-report") return "Day-End Report"
if (pathname === "/dashboard/sales/day-end") return "Approved Invoices"
if (pathname === "/dashboard/settings/owner-report") return "Day-End Owner Report"
if (pathname === "/dashboard/hrm") return "HRM"
if (pathname === "/dashboard/receiving/grn") return "Goods Received Notes"
@@ -0,0 +1,37 @@
// Day-end owner report settings: recipients, schedule, preview, test send and send log
// (docs/DAY_END_OWNER_REPORT_PLAN.md §3.5). Singleton settings — no id; saves need the ETag.
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, PagedResponse } from "@/types/common"
import {
OwnerReportConfig,
OwnerReportDispatch,
OwnerReportPreview,
OwnerReportSendResult,
UpdateOwnerReportConfigRequest,
} from "@/types/owner-report"
const BASE = "/settings/owner-report"
export const ownerReportApi = {
get(): Promise<ApiResult<OwnerReportConfig>> {
return apiRequestWithETag<OwnerReportConfig>(BASE)
},
update(request: UpdateOwnerReportConfigRequest, ifMatch: string): Promise<ApiResult<OwnerReportConfig>> {
return apiRequestWithETag<OwnerReportConfig>(BASE, { method: "PUT", body: request, ifMatch })
},
/** The report as it would be sent for `date` (yyyy-MM-dd; default today) — nothing is sent. */
preview(date?: string): Promise<OwnerReportPreview> {
return apiRequest<OwnerReportPreview>(`${BASE}/preview${buildQuery({ date })}`)
},
/** Sends the report now to every active recipient (logged as a test). One per minute. */
sendTest(date?: string): Promise<OwnerReportSendResult[]> {
return apiRequest<OwnerReportSendResult[]>(`${BASE}/test`, { method: "POST", body: { date: date || null } })
},
dispatches(params: { page?: number; pageSize?: number } = {}): Promise<PagedResponse<OwnerReportDispatch>> {
return apiRequest<PagedResponse<OwnerReportDispatch>>(`${BASE}/dispatches${buildQuery(params)}`)
},
}
+15 -13
View File
@@ -1,17 +1,18 @@
/**
* Sri Lankan phone number rule shared by master-data forms (warehouses, …). Mirrors the
* backend's `PhoneRules.Pattern` so the client rejects exactly what the API would.
* Sri Lankan phone number rule shared by master-data forms (warehouses, owner report, …). Mirrors
* the backend's `PhoneRules.Pattern` so the client rejects exactly what the API would.
*
* Accepted: 10 digits starting with 0 (0771234567, 011 234 5678) or +94 followed by 9 digits
* (+94771234567, +94 77 123 4567). Spaces and hyphens are allowed between digits.
* Accepted: 10 digits starting with 0 (0771234567, 011 234 5678), +94 followed by 9 digits
* (+94771234567, +94 77 123 4567), or 94 followed by 9 digits without the "+" (94771234567 — the
* SMS gateway's own format). Spaces and hyphens are allowed between digits.
*/
export const PHONE_PATTERN = /^(?:0(?:[\s-]*\d){9}|\+94(?:[\s-]*\d){9})[\s-]*$/
export const PHONE_PATTERN = /^(?:0(?:[\s-]*\d){9}|\+?94(?:[\s-]*\d){9})[\s-]*$/
/** Digit count allowed after the prefix's own digits, per format. */
const LOCAL_DIGITS = 10 // 0 + 9
const INTL_DIGITS = 11 // 94 + 9
const PHONE_EXAMPLE = "e.g. 0771234567 or +94 77 123 4567"
const PHONE_EXAMPLE = "e.g. 0771234567, 94771234567 or +94 77 123 4567"
/**
* Input filter for a phone field's onChange: drops disallowed characters and stops accepting
@@ -19,7 +20,7 @@ const PHONE_EXAMPLE = "e.g. 0771234567 or +94 77 123 4567"
*/
export function limitPhoneInput(raw: string): string {
let out = ""
let digits = 0
let digits = ""
for (const ch of raw) {
if (ch === "+") {
if (out.length === 0) out += ch // "+" only as the very first character
@@ -30,10 +31,11 @@ export function limitPhoneInput(raw: string): string {
continue
}
if (!/\d/.test(ch)) continue
const max = out.startsWith("+") ? INTL_DIGITS : LOCAL_DIGITS
if (digits >= max) continue
// International (+94… or 94…) numbers carry two more digits than a local 0… number.
const max = out.startsWith("+") || digits.startsWith("94") || (digits === "9" && ch === "4") ? INTL_DIGITS : LOCAL_DIGITS
if (digits.length >= max) continue
out += ch
digits += 1
digits += ch
}
return out
}
@@ -44,11 +46,11 @@ export function phoneError(value: string, { required = false }: { required?: boo
if (!phone) return required ? "Enter a phone number" : null
if (/[^\d\s\-+]/.test(phone)) return "Phone number can only contain digits, spaces, - and a leading +"
const digits = phone.replace(/\D/g, "")
if (phone.startsWith("+")) {
if (phone.startsWith("+") || digits.startsWith("94")) {
if (!digits.startsWith("94")) return `International numbers must start with +94 — ${PHONE_EXAMPLE}`
if (digits.length !== INTL_DIGITS) return `+94 numbers need exactly 9 digits after +94 (${digits.length - 2} entered)`
if (digits.length !== INTL_DIGITS) return `94 numbers need exactly 9 digits after 94 (${digits.length - 2} entered)`
} else {
if (!digits.startsWith("0")) return `Phone number must start with 0 or +94 — ${PHONE_EXAMPLE}`
if (!digits.startsWith("0")) return `Phone number must start with 0, 94 or +94 — ${PHONE_EXAMPLE}`
if (digits.length !== LOCAL_DIGITS) return `Phone number must be exactly 10 digits (${digits.length} entered)`
}
if (!PHONE_PATTERN.test(phone)) return `Enter a valid phone number, ${PHONE_EXAMPLE}`
+69
View File
@@ -0,0 +1,69 @@
/** Day-end owner report (docs/DAY_END_OWNER_REPORT_PLAN.md). */
export interface OwnerReportRecipient {
recipientId: number
name: string
/** Stored as +94XXXXXXXXX. */
mobile: string | null
email: string | null
isActive: boolean
}
export interface OwnerReportConfig {
enabled: boolean
smsEnabled: boolean
emailEnabled: boolean
/** "HH:mm" in `timeZone`. */
sendAtLocalTime: string
includeBranchBreakdown: boolean
recipients: OwnerReportRecipient[]
timeZone: string
updatedAt: string | null
updatedBy: number | null
}
export interface OwnerReportRecipientInput {
/** Null for a new recipient. */
recipientId: number | null
name: string
mobile: string | null
email: string | null
isActive: boolean
}
export interface UpdateOwnerReportConfigRequest {
enabled: boolean
smsEnabled: boolean
emailEnabled: boolean
sendAtLocalTime: string
includeBranchBreakdown: boolean
recipients: OwnerReportRecipientInput[]
}
export interface OwnerReportPreview {
date: string
smsText: string
emailSubject: string
emailHtml: string
}
export interface OwnerReportSendResult {
channel: "Sms" | "Email"
recipientName: string
destination: string
ok: boolean
error: string | null
}
export interface OwnerReportDispatch {
dispatchId: number
businessDate: string
channel: "Sms" | "Email"
recipientName: string
destination: string
status: "Pending" | "Sent" | "Failed"
attempts: number
error: string | null
isTest: boolean
createdAt: string
sentAt: string | null
}