notification module

This commit is contained in:
Dhananjaya99
2026-08-21 12:05:47 +05:30
parent 9b8c2493ad
commit d413ad4064
41 changed files with 16868 additions and 228 deletions
@@ -105,6 +105,7 @@ export default function NewItemPage() {
const [contentQty, setContentQty] = useState("")
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
const [trackingMode, setTrackingMode] = useState<"None" | "Batch" | "Serial">("None")
const [loadError, setLoadError] = useState<string | null>(null)
const [productName, setProductName] = useState("")
@@ -129,15 +130,12 @@ export default function NewItemPage() {
const [removedVariantKeys, setRemovedVariantKeys] = useState<Set<string>>(new Set())
// Sales pricing (FR-MD-01). "stock" ⇒ sellingFlatPrice sent as null (sell at FIFO value);
// "fixed" ⇒ every variant must carry a price. `fixValue` is the shared default that
// pre-fills rows; a per-key entry in `pricesByKey` overrides it for that one row only.
// "fixed" ⇒ every variant must carry a price. `defaultFlatPrice` is a bulk-fill helper;
// each row keeps its own flat price in `pricesByKey`.
const [priceMode, setPriceMode] = useState<"stock" | "fixed">("stock")
const [fixValue, setFixValue] = useState<string>("")
const [defaultFlatPrice, setDefaultFlatPrice] = useState<string>("")
const [pricesByKey, setPricesByKey] = useState<Record<string, string>>({})
const [priceErrors, setPriceErrors] = useState<Record<string, string>>({})
const [sellingMinPrice, setSellingMinPrice] = useState("")
const [sellingMaxPrice, setSellingMaxPrice] = useState("")
const [sellingFlatPrice, setSellingFlatPrice] = useState("")
// Warranty (FR-MD-01). Applies to every generated variant — there is no per-variant override.
const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
@@ -147,8 +145,9 @@ export default function NewItemPage() {
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
// A row shows its own override if set, otherwise it follows the shared fix value.
const priceFor = (key: string) => pricesByKey[key] ?? fixValue
// Each row owns its own flat price. The default field is only a bulk-fill helper.
const priceFor = (key: string) => pricesByKey[key] ?? ""
const variantPriceFor = (variantKey: string, kind: "min" | "max" | "flat") => pricesByKey[`${variantKey}::${kind}`] ?? ""
useEffect(() => {
Promise.all([
@@ -184,6 +183,35 @@ export default function NewItemPage() {
.catch(() => setSubCategories([]))
}, [categoryId, config?.subcategoriesEnabled])
useEffect(() => {
if (!config) return
if (!config.serialNumbersEnabled && trackingMode === "Serial") {
setTrackingMode("None")
}
if (!config.batchListsEnabled && trackingMode === "Batch") {
setTrackingMode("None")
}
if (!config.warrantyPeriodsEnabled && warranty === "Warranty") {
setWarranty("NonWarranty")
setWarrantyPeriodMonths(null)
}
if (!config.sellingPricesEnabled) {
setPriceMode("stock")
setDefaultFlatPrice("")
setPricesByKey({})
setPriceErrors({})
}
}, [
config,
trackingMode,
warranty,
config?.serialNumbersEnabled,
config?.batchListsEnabled,
config?.warrantyPeriodsEnabled,
config?.sellingPricesEnabled,
])
const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? ""
/**
* The product being built ("Coca Cola"), typed rather than derived. It names every generated
@@ -358,7 +386,7 @@ export default function NewItemPage() {
if (measurableCheckedCount > 1) {
nextErrors.measurable = "Only one measurement dimension can be used at a time."
}
if (warranty === "Warranty" && warrantyPeriodMonths === null) {
if (config?.warrantyPeriodsEnabled && warranty === "Warranty" && warrantyPeriodMonths === null) {
nextErrors.warrantyPeriodMonths = "Select a warranty period"
}
// Should never fire — addValue is the real gate — so it catches stale state only.
@@ -377,7 +405,7 @@ export default function NewItemPage() {
setErrors(nextErrors)
// In fixed mode, block the whole submit until every variant has a price > 0.
const nextPriceErrors =
priceMode === "fixed" ? validateVariantPrices(variants.map((v) => v.key), priceFor) : {}
config?.sellingPricesEnabled && priceMode === "fixed" ? validateVariantPrices(variants.map((v) => v.key), priceFor) : {}
setPriceErrors(nextPriceErrors)
if (Object.keys(nextErrors).length > 0 || Object.keys(nextPriceErrors).length > 0) return
if (baseUomId === null) {
@@ -388,6 +416,17 @@ export default function NewItemPage() {
setSubmitting(true)
let created = 0
try {
const effectiveTrackingMode =
!config?.serialNumbersEnabled && trackingMode === "Serial"
? "None"
: !config?.batchListsEnabled && trackingMode === "Batch"
? "None"
: trackingMode
const effectiveWarranty = config?.warrantyPeriodsEnabled ? warranty : "NonWarranty"
const effectiveWarrantyPeriodMonths =
config?.warrantyPeriodsEnabled && effectiveWarranty === "Warranty" ? warrantyPeriodMonths : null
const allowSellingPrices = !!config?.sellingPricesEnabled
const sendFlatPrices = allowSellingPrices && priceMode === "fixed"
for (const variant of variants) {
const content = contentForVariant(variant)
await itemsApi.create({
@@ -401,14 +440,21 @@ export default function NewItemPage() {
brandId,
baseUomId,
stockNature,
trackingMode: "None",
warranty,
warrantyPeriodMonths: warranty === "Warranty" ? warrantyPeriodMonths : null,
sellingMinPrice: null,
sellingMaxPrice: null,
sellingMinPrice: sellingMinPrice.trim() ? Number(sellingMinPrice) : null,
sellingMaxPrice: sellingMaxPrice.trim() ? Number(sellingMaxPrice) : null,
sellingFlatPrice: priceMode === "fixed" ? Number(priceFor(variant.key)) : (sellingFlatPrice.trim() ? Number(sellingFlatPrice) : null),
trackingMode: effectiveTrackingMode,
warranty: effectiveWarranty,
warrantyPeriodMonths: effectiveWarrantyPeriodMonths,
sellingMinPrice:
allowSellingPrices && variantPriceFor(variant.key, "min").trim()
? Number(variantPriceFor(variant.key, "min"))
: null,
sellingMaxPrice:
allowSellingPrices && variantPriceFor(variant.key, "max").trim()
? Number(variantPriceFor(variant.key, "max"))
: null,
sellingFlatPrice:
allowSellingPrices && sendFlatPrices && priceFor(variant.key).trim()
? Number(priceFor(variant.key))
: null,
// Each variant carries its OWN size when a measurement dimension supplied one;
// otherwise the shared form-level pair, which is correct when the varying dimension
// isn't size (colour variants of one 500 ml bottle all hold 500 ml).
@@ -644,168 +690,170 @@ export default function NewItemPage() {
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Selling prices</h2>
<div className="flex flex-col gap-2">
<Label className="text-base">Tracking mode</Label>
<Select<"None" | "Batch" | "Serial"> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="None" className="text-base">None</SelectItem>
{config?.batchListsEnabled && <SelectItem value="Batch" className="text-base">Batch</SelectItem>}
{config?.serialNumbersEnabled && <SelectItem value="Serial" className="text-base">Serial</SelectItem>}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Optional values for the item. Leave any field blank if it does not apply.
{config?.serialNumbersEnabled ? (
<>
Choose <span className="font-medium">Serial</span> if each unit needs its own serial number on receipt.
</>
) : config?.batchListsEnabled ? (
<>
Batch tracking is available; serial tracking is currently disabled in product settings.
</>
) : (
<>
Tracking is currently disabled in product settings.
</>
)}
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2">
<Label className="text-base">Selling min price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={sellingMinPrice}
onChange={(e) => setSellingMinPrice(e.target.value)}
placeholder="Optional"
className="h-11 text-base"
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling max price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={sellingMaxPrice}
onChange={(e) => setSellingMaxPrice(e.target.value)}
placeholder="Optional"
className="h-11 text-base"
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Selling flat price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={sellingFlatPrice}
onChange={(e) => setSellingFlatPrice(e.target.value)}
placeholder="Optional"
className="h-11 text-base"
/>
</div>
</div>
</div>
{/* Sales pricing (FR-MD-01). The toggle is frontend-only: "stock" sends
sellingFlatPrice=null (sold at FIFO value); "fixed" requires a price per variant. */}
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Sale price</h2>
<p className="text-sm text-muted-foreground">
Choose a fixed selling price, or leave it to the item&apos;s stock value.
</p>
</div>
{config?.sellingPricesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">Variant selling prices</h2>
<p className="text-sm text-muted-foreground">
Edit the min, max, and flat price per generated variant. The bulk default only fills the flat column.
</p>
</div>
<div className="inline-flex w-fit rounded-lg border p-1">
<button
type="button"
onClick={() => setPriceMode("stock")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
priceMode === "stock" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Use stock value
</button>
<button
type="button"
onClick={() => setPriceMode("fixed")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
priceMode === "fixed" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Fixed price
</button>
</div>
<div className="inline-flex w-fit rounded-lg border p-1">
<button
type="button"
onClick={() => setPriceMode("stock")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
priceMode === "stock" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Use stock value
</button>
<button
type="button"
onClick={() => setPriceMode("fixed")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
priceMode === "fixed" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Fixed price
</button>
</div>
</div>
{priceMode === "fixed" && (
<div className="flex max-w-xs flex-col gap-2">
<Label className="text-base">Fix value (applies to all variants)</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={fixValue}
onChange={(e) => setFixValue(e.target.value)}
placeholder="0.00"
className="h-11 text-base"
/>
{priceMode === "fixed" && (
<div className="flex max-w-xl flex-wrap items-end gap-3">
<div className="flex min-w-56 flex-1 flex-col gap-2">
<Label className="text-base">Bulk default flat price</Label>
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={defaultFlatPrice}
onChange={(e) => setDefaultFlatPrice(e.target.value)}
placeholder="0.00"
className="h-11 text-base"
/>
</div>
<Button
type="button"
variant="outline"
onClick={() => {
const raw = defaultFlatPrice.trim()
if (!raw) return
setPricesByKey((prev) => {
const next = { ...prev }
for (const variant of variants) {
if (next[variant.key] == null || next[variant.key].trim() === "") {
next[variant.key] = raw
}
}
return next
})
}}
>
Fill blank flat prices
</Button>
<p className="text-sm text-muted-foreground">
This copies the same value into blank flat-price cells only.
</p>
</div>
)}
</div>
)}
{config?.warrantyPeriodsEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
<p className="text-sm text-muted-foreground">
Edit any row below to give that variant a different price.
Mark whether every generated variant is sold under warranty.
</p>
</div>
)}
</div>
{/* Warranty (FR-MD-01). Frontend-only toggle, mirrors the Sale price bar above. */}
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
<p className="text-sm text-muted-foreground">
Mark whether every generated variant is sold under warranty.
</p>
</div>
<div className="inline-flex w-fit rounded-lg border p-1">
<button
type="button"
onClick={() => {
setWarranty("NonWarranty")
setWarrantyPeriodMonths(null)
}}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
warranty === "NonWarranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Non-Warranty
</button>
<button
type="button"
onClick={() => setWarranty("Warranty")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
warranty === "Warranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Warranty
</button>
</div>
{warranty === "Warranty" && (
<div className="flex flex-col gap-1.5">
<Label>Warranty period</Label>
<Select<WarrantyPeriodMonths>
value={warrantyPeriodMonths}
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
<div className="inline-flex w-fit rounded-lg border p-1">
<button
type="button"
onClick={() => {
setWarranty("NonWarranty")
setWarrantyPeriodMonths(null)
}}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
warranty === "NonWarranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
<SelectItem key={months} value={months} className="text-base">
{months} months
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
Non-Warranty
</button>
<button
type="button"
onClick={() => setWarranty("Warranty")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
warranty === "Warranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Warranty
</button>
</div>
)}
</div>
{warranty === "Warranty" && (
<div className="flex flex-col gap-1.5">
<Label>Warranty period</Label>
<Select<WarrantyPeriodMonths>
value={warrantyPeriodMonths}
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
>
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
<SelectItem key={months} value={months} className="text-base">
{months} months
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
</div>
)}
</div>
)}
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
item-type reference), so this section IS the enforcement. */}
@@ -975,7 +1023,11 @@ export default function NewItemPage() {
<TableHead className="h-11 px-3 text-sm">SKU</TableHead>
<TableHead className="h-11 px-3 text-sm">Content</TableHead>
{priceMode === "fixed" && (
<TableHead className="h-11 px-3 text-sm">Sale price</TableHead>
<>
<TableHead className="h-11 px-3 text-sm">Min price</TableHead>
<TableHead className="h-11 px-3 text-sm">Max price</TableHead>
<TableHead className="h-11 px-3 text-sm">Flat price</TableHead>
</>
)}
{/* Quantity column removed 2026-07-17: there is no `initialQty` on the
Item contract and no initial-receipt flow — stock arrives via a GRN.
@@ -999,29 +1051,55 @@ export default function NewItemPage() {
{contentLabelFor(variant)}
</TableCell>
{priceMode === "fixed" && (
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={priceFor(variant.key)}
onChange={(e) => {
const value = e.target.value
setPricesByKey((prev) => ({ ...prev, [variant.key]: value }))
setPriceErrors((prev) => {
if (!prev[variant.key]) return prev
const next = { ...prev }
delete next[variant.key]
return next
})
}}
placeholder="0.00"
aria-invalid={!!priceErrors[variant.key]}
className="h-10 w-28 text-base"
/>
<FieldError errors={[priceErrors[variant.key] ? { message: priceErrors[variant.key] } : undefined]} />
</TableCell>
<>
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={pricesByKey[`${variant.key}::min`] ?? ""}
onChange={(e) => setPricesByKey((prev) => ({ ...prev, [`${variant.key}::min`]: e.target.value }))}
placeholder="0.00"
className="h-10 w-28 text-base"
/>
</TableCell>
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={pricesByKey[`${variant.key}::max`] ?? ""}
onChange={(e) => setPricesByKey((prev) => ({ ...prev, [`${variant.key}::max`]: e.target.value }))}
placeholder="0.00"
className="h-10 w-28 text-base"
/>
</TableCell>
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={priceFor(variant.key)}
onChange={(e) => {
const value = e.target.value
setPricesByKey((prev) => ({ ...prev, [variant.key]: value }))
setPriceErrors((prev) => {
if (!prev[variant.key]) return prev
const next = { ...prev }
delete next[variant.key]
return next
})
}}
placeholder="0.00"
aria-invalid={!!priceErrors[variant.key]}
className="h-10 w-28 text-base"
/>
<FieldError errors={[priceErrors[variant.key] ? { message: priceErrors[variant.key] } : undefined]} />
</TableCell>
</>
)}
<TableCell className="py-2.5 pr-3 pl-0">
<Button
@@ -12,12 +12,11 @@ import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast"
/**
* Product Configuration (docs/11 §2.8; FR-MD-11) — the singleton feature gate.
* Product Configuration (docs/11 §2.8; FR-MD-11) - the singleton feature gate.
*
* `subcategoriesEnabled`/`brandsEnabled` are the only user-toggleable flags here and are
* enforced by the server (an item write carrying a gated field gets 422 CONFIG_DISABLED).
* `itemTypesEnabled` has no control on this screen but is still part of the record, so
* every save round-trips its current value unchanged (the server rejects a partial body).
* Item types are the product-variant builder, while the other flags control whether item
* pricing, tracking and warranty-period fields are available when creating or editing an item.
* The server enforces the fields that are truly written to the item record.
*/
export default function ProductSettingsPage() {
const [config, setConfig] = useState<ProductConfig | null>(null)
@@ -38,17 +37,28 @@ export default function ProductSettingsPage() {
useEffect(load, [])
async function toggle(flag: "subcategoriesEnabled" | "brandsEnabled", next: boolean) {
async function toggle(flag: keyof Pick<
ProductConfig,
| "subcategoriesEnabled"
| "brandsEnabled"
| "itemTypesEnabled"
| "sellingPricesEnabled"
| "serialNumbersEnabled"
| "batchListsEnabled"
| "warrantyPeriodsEnabled"
>, next: boolean) {
if (!config) return
setSaving(flag)
try {
// All three flags are always sent — the server rejects a partial body (400), which
// is what stops an omitted flag from silently switching a feature off.
const res = await productConfigApi.update(
{
subcategoriesEnabled: config.subcategoriesEnabled,
brandsEnabled: config.brandsEnabled,
itemTypesEnabled: config.itemTypesEnabled,
sellingPricesEnabled: config.sellingPricesEnabled,
serialNumbersEnabled: config.serialNumbersEnabled,
batchListsEnabled: config.batchListsEnabled,
warrantyPeriodsEnabled: config.warrantyPeriodsEnabled,
[flag]: next,
},
etag ?? "",
@@ -58,7 +68,7 @@ export default function ProductSettingsPage() {
toast.success("Configuration saved", `${LABELS[flag]} ${next ? "enabled" : "disabled"}.`)
} catch (err) {
toast.error("Could not save configuration", errorMessage(err))
load() // a 412 means someone else changed it — resync rather than retry blind
load()
} finally {
setSaving(null)
}
@@ -79,7 +89,7 @@ export default function ProductSettingsPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && !config && <Skeleton className="h-64 w-full" />}
{!error && !config && <Skeleton className="h-80 w-full" />}
{!error && config && (
<div className="flex flex-col gap-4 rounded-xl border p-6">
@@ -90,9 +100,49 @@ export default function ProductSettingsPage() {
<div className="border-t" />
<div className="grid grid-cols-1 gap-x-10 md:grid-cols-2">
<ToggleRow
label="Product variants"
description="Controls the variant builder on item create. Off hides the item-type section."
checked={config.itemTypesEnabled}
busy={saving === "itemTypesEnabled"}
onChange={(v) => toggle("itemTypesEnabled", v)}
/>
<ToggleRow
label="Selling prices"
description="Controls the min, max and flat sale price fields on item create and edit."
checked={config.sellingPricesEnabled}
busy={saving === "sellingPricesEnabled"}
onChange={(v) => toggle("sellingPricesEnabled", v)}
/>
<ToggleRow
label="Serial numbers"
description="Allows items to be created with Serial tracking."
checked={config.serialNumbersEnabled}
busy={saving === "serialNumbersEnabled"}
onChange={(v) => toggle("serialNumbersEnabled", v)}
/>
<ToggleRow
label="Batch lists"
description="Allows items to be created with Batch tracking."
checked={config.batchListsEnabled}
busy={saving === "batchListsEnabled"}
onChange={(v) => toggle("batchListsEnabled", v)}
/>
<ToggleRow
label="Warranty periods"
description="Allows warranty-tracked items to pick a coverage period."
checked={config.warrantyPeriodsEnabled}
busy={saving === "warrantyPeriodsEnabled"}
onChange={(v) => toggle("warrantyPeriodsEnabled", v)}
/>
<ToggleRow
label="Subcategories"
description="Category hierarchy includes a subcategory level. Off ⇒ products attach directly to a category."
description="Category hierarchy includes a subcategory level. Off means products attach directly to a category."
checked={config.subcategoriesEnabled}
busy={saving === "subcategoriesEnabled"}
onChange={(v) => toggle("subcategoriesEnabled", v)}
@@ -119,9 +169,16 @@ export default function ProductSettingsPage() {
)
}
const LABELS: Record<string, string> = {
const LABELS: Record<keyof ProductConfig, string> = {
subcategoriesEnabled: "Subcategories",
brandsEnabled: "Brands",
itemTypesEnabled: "Product variants",
sellingPricesEnabled: "Selling prices",
serialNumbersEnabled: "Serial numbers",
batchListsEnabled: "Batch lists",
warrantyPeriodsEnabled: "Warranty periods",
updatedAt: "Updated at",
updatedBy: "Updated by",
}
function ToggleRow({
@@ -0,0 +1,214 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { BellRing, Check, ShieldAlert } from "lucide-react"
import { notificationConfigApi } from "@/lib/api/notification-config"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { NotificationChannel, NotificationConfig } from "@/types/notifications"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const CHANNELS: { key: NotificationChannel; label: string }[] = [
{ key: "Email", label: "Email" },
{ key: "Sms", label: "SMS" },
{ key: "InApp", label: "In-App" },
]
export default function NotificationConfigPage() {
const [config, setConfig] = useState<NotificationConfig | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
function load() {
setError(null)
notificationConfigApi
.get()
.then((res) => {
setConfig(res.data)
setEtag(res.etag)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
const modules = config?.modules ?? []
const hasModules = modules.length > 0
const rows = useMemo(
() => modules.map((m) => ({
...m,
enabledCount: [m.emailEnabled, m.smsEnabled, m.inAppEnabled].filter(Boolean).length,
})),
[modules]
)
function updateModule(moduleKey: string, channel: NotificationChannel, checked: boolean) {
setConfig((prev) => {
if (!prev) return prev
return {
...prev,
modules: prev.modules.map((m) =>
m.moduleKey === moduleKey
? {
...m,
emailEnabled: channel === "Email" ? checked : m.emailEnabled,
smsEnabled: channel === "Sms" ? checked : m.smsEnabled,
inAppEnabled: channel === "InApp" ? checked : m.inAppEnabled,
}
: m
),
}
})
}
async function handleSave() {
if (!config) return
setSaving(true)
try {
const res = await notificationConfigApi.update(
{
modules: config.modules.map((m) => ({
moduleKey: m.moduleKey,
emailEnabled: m.emailEnabled,
smsEnabled: m.smsEnabled,
inAppEnabled: m.inAppEnabled,
})),
},
etag ?? "",
)
setConfig(res.data)
setEtag(res.etag)
toast.success("Notification configuration saved")
} catch (err) {
const detail = errorMessage(err)
toast.error("Could not save notification configuration", detail)
if ((err as { code?: string })?.code === "CONCURRENCY_CONFLICT") {
load()
} else {
setError(detail)
}
} finally {
setSaving(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10">
<BellRing className="size-5 text-primary" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">Notification Config</h1>
<p className="text-base text-muted-foreground">
Choose which channels each module can use. A module may use none, one, or many channels.
</p>
</div>
</div>
<Button size="lg" onClick={handleSave} disabled={saving || !config}>
<Check className="size-5" />
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && !config && <Skeleton className="h-72 w-full" />}
{!error && config && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div className="flex items-start gap-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
<ShieldAlert className="mt-0.5 size-5 shrink-0 text-primary" />
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-foreground">
Empty selections are allowed.
</p>
<p className="text-sm text-muted-foreground">
If a module has no channels enabled, the notification resolver skips delivery for that module.
</p>
</div>
</div>
{!hasModules ? (
<div className="rounded-lg border border-dashed px-6 py-16 text-center text-muted-foreground">
No notification modules were discovered in the backend.
</div>
) : (
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Module</TableHead>
{CHANNELS.map((channel) => (
<TableHead key={channel.key} className="h-12 px-3 text-sm text-center">
{channel.label}
</TableHead>
))}
<TableHead className="h-12 px-3 text-sm text-center">Enabled</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((module) => (
<TableRow key={module.moduleKey}>
<TableCell className="px-3 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium text-foreground">{module.label}</span>
<span className="text-sm text-muted-foreground">{module.description}</span>
<span className="text-xs text-muted-foreground">{module.moduleKey}</span>
</div>
</TableCell>
<TableCell className="px-3 py-4 text-center">
<Checkbox
checked={module.emailEnabled}
onCheckedChange={(checked) => updateModule(module.moduleKey, "Email", checked === true)}
aria-label={`${module.label} email channel`}
/>
</TableCell>
<TableCell className="px-3 py-4 text-center">
<Checkbox
checked={module.smsEnabled}
onCheckedChange={(checked) => updateModule(module.moduleKey, "Sms", checked === true)}
aria-label={`${module.label} sms channel`}
/>
</TableCell>
<TableCell className="px-3 py-4 text-center">
<Checkbox
checked={module.inAppEnabled}
onCheckedChange={(checked) => updateModule(module.moduleKey, "InApp", checked === true)}
aria-label={`${module.label} in-app channel`}
/>
</TableCell>
<TableCell className="px-3 py-4 text-center">
<Badge
variant="outline"
className={cn(
"h-7 min-w-16 justify-center border-transparent px-2.5 text-sm",
module.enabledCount > 0 ? "bg-success/10 text-success" : "bg-muted text-muted-foreground",
)}
>
{module.enabledCount}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)}
</div>
)
}
@@ -1,5 +1,5 @@
import Link from "next/link"
import { Building2, ShieldCheck, SlidersHorizontal, Users } from "lucide-react"
import { BellRing, Building2, ShieldCheck, SlidersHorizontal, Users } from "lucide-react"
const cards = [
{
@@ -14,6 +14,12 @@ const cards = [
icon: Users,
description: "Create users and assign their roles",
},
{
title: "Notification Config",
href: "/dashboard/settings/notification-config",
icon: BellRing,
description: "Choose which channels each module can use",
},
{
title: "Product Configuration",
href: "/dashboard/products/settings",