Sms & Email
This commit is contained in:
@@ -407,3 +407,8 @@ Spec: `docs/21-FRONTEND-PHASE2.md` (flows/screens) · contract: `docs/30-BACKEND
|
||||
5. **Inline "add variant category":** a "+" icon button next to the checkboxes opens an inline name field that calls `variantCategoriesApi.create()` directly from `/new`, appends the result to the in-memory list, and auto-checks it — so a brand-new dimension (e.g. "Material") can be added without leaving the Item form, and it also then appears on `/dashboard/products/variants`.
|
||||
6. **Color gets a real color picker:** for whichever checked category is literally named "Color" (case-insensitive), the free-text input is replaced with a native `<input type="color">` swatch picker *plus* a required "Color name" text field — picking red alone isn't enough, a name is mandatory too. The pair is encoded as a single string `"<name>|<hex>"` in `valuesByCategory` (helpers `encodeColorValue`/`decodeColorValue`/`partLabel` in `new/page.tsx`) so the existing generic value-list plumbing didn't need a parallel data shape; every place that displays or SKU-generates from a color value decodes it back to just the name (the hex only ever drives the swatch dot next to chips and table cells) — so SKUs read `HAR-CRI` (from "Crimson"), never `HAR-EF4`.
|
||||
- Verified: `tsc --noEmit` clean after every step (same pre-existing `login/page.tsx` error only, confirmed unchanged throughout). Each change was driven end-to-end through a headless Playwright session against the dev server and screenshotted — field removal, subcategory always-visible + disabled state, checkbox show/hide of category builders, cartesian flat table with 2+ active categories, inline category creation followed by its builder appearing immediately, and the color picker + name → chip swatch → table swatch → final SKU/item name chain — with `console --errors` clean at every step and at least one full create-and-redirect-to-Items-list confirmed per major change.
|
||||
|
||||
### 2026-10-19 — Settings → Configuration → Day-End Owner Report
|
||||
- New page `app/dashboard/settings/owner-report` (`lib/api/owner-report.ts`, `types/owner-report.ts`), card on Settings → Configuration, header + breadcrumb titles.
|
||||
- Sections: Delivery (master switch, SMS, Email, branch breakdown, send time), Recipients (≤ 5; name / mobile with the Sri Lankan phone rule + input limiter / email; active switch), Preview (SMS bubble + sandboxed email iframe for any day), Send test now (confirm dialog; blocked while there are unsaved changes), Send log (cards on phones, table on desktop, pagination). Draft + sticky Save/Discard bar; every validation error also toasts.
|
||||
- `tsc` + `eslint` clean for the new files; not yet clicked through in a browser.
|
||||
|
||||
@@ -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'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'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 & 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)}`)
|
||||
},
|
||||
}
|
||||
@@ -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}`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user