Add toasts, Settings tabs, Clients page and item presets (Phase E1)
- Global Carbon toast stack replaces scattered inline notices; errors persist until closed. - Settings is split into eight tabs with one Save and a remembered last tab; Numbering embeds the series settings. - New Clients page with a DataTable, shared add/edit modal, structured addresses, GST category (informational), per-client notes and payment terms. The composed address string is kept for back-compat. Deleting a client warns about invoice count; issued invoices keep their own snapshot. - Migration M7 adds the client columns and item_presets; existing clients with a GSTIN are backfilled to registered_regular. - New invoice form: client ComboBox with type-ahead and inline create, client defaults applied on selection, and an Add from preset control. - Rust tests for M7 (fresh and v6 upgrade), client validation and CRUD, presets CRUD; vitest for the toast store and new helpers. PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
This commit is contained in:
@@ -7,6 +7,7 @@ import type {
|
||||
InvoiceInput,
|
||||
InvoiceSeries,
|
||||
InvoiceSummary,
|
||||
ItemPreset,
|
||||
Settings,
|
||||
} from "./types";
|
||||
import type { LogoAsset } from "./logo";
|
||||
@@ -36,6 +37,10 @@ export const api = {
|
||||
saveClient: (client: Client) => invoke<Client>("save_client", { client }),
|
||||
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
|
||||
|
||||
listItemPresets: () => invoke<ItemPreset[]>("list_item_presets"),
|
||||
saveItemPreset: (preset: ItemPreset) => invoke<ItemPreset>("save_item_preset", { preset }),
|
||||
deleteItemPreset: (id: number) => invoke<void>("delete_item_preset", { id }),
|
||||
|
||||
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
|
||||
issueInvoice: (input: InvoiceInput, renderPrefs: object) =>
|
||||
invoke<Invoice>("issue_invoice", { input, renderPrefs }),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { composeAddress, pincodeError, splitLegacyAddress } from "./clientAddress";
|
||||
|
||||
describe("composeAddress", () => {
|
||||
it("joins lines and the city/state/pincode line", () => {
|
||||
expect(
|
||||
composeAddress({ line1: "12 MG Road", line2: "Floor 2", city: "Bengaluru", stateCode: "29", pincode: "560001" }),
|
||||
).toBe("12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001");
|
||||
});
|
||||
it("skips empty parts", () => {
|
||||
expect(composeAddress({ line1: " A ", line2: "", city: "", stateCode: "", pincode: "560001" })).toBe("A\n560001");
|
||||
expect(composeAddress({ line1: "", line2: "", city: "Pune", stateCode: "", pincode: "" })).toBe("Pune");
|
||||
expect(composeAddress({ line1: "", line2: "", city: "", stateCode: "29", pincode: "" })).toBe("Karnataka");
|
||||
expect(composeAddress({ line1: "", line2: "", city: "", stateCode: "", pincode: "" })).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitLegacyAddress", () => {
|
||||
it("puts the first line in line1 and the rest in line2", () => {
|
||||
expect(splitLegacyAddress("Old style\nfree text\r\nBangalore")).toEqual({
|
||||
line1: "Old style",
|
||||
line2: "free text, Bangalore",
|
||||
});
|
||||
expect(splitLegacyAddress("")).toEqual({ line1: "", line2: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("pincodeError", () => {
|
||||
it("accepts empty and 6 digits only", () => {
|
||||
expect(pincodeError("")).toBeUndefined();
|
||||
expect(pincodeError("560001")).toBeUndefined();
|
||||
expect(pincodeError("56001")).toBeDefined();
|
||||
expect(pincodeError("56000A")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { INDIAN_STATES } from "./types";
|
||||
|
||||
export interface AddressParts {
|
||||
line1: string;
|
||||
line2: string;
|
||||
city: string;
|
||||
stateCode: string;
|
||||
pincode: string;
|
||||
}
|
||||
|
||||
export const EMPTY_ADDRESS: AddressParts = { line1: "", line2: "", city: "", stateCode: "", pincode: "" };
|
||||
|
||||
/**
|
||||
* The stored multi-line address: line 1, line 2, then "City, State - Pincode".
|
||||
* Mirrors `compose_address` in src-tauri/src/commands/clients.rs, which is authoritative on save.
|
||||
*/
|
||||
export function composeAddress(parts: AddressParts): string {
|
||||
const stateName = INDIAN_STATES.find((s) => s.code === parts.stateCode)?.name ?? "";
|
||||
const place = [parts.city.trim(), stateName].filter(Boolean).join(", ");
|
||||
const pin = parts.pincode.trim();
|
||||
const last = place && pin ? `${place} - ${pin}` : place || pin;
|
||||
return [parts.line1.trim(), parts.line2.trim(), last].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
/** Best effort for clients saved before structured fields existed: first line to line 1, the rest to line 2. */
|
||||
export function splitLegacyAddress(address: string): Pick<AddressParts, "line1" | "line2"> {
|
||||
const lines = address
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
return { line1: lines[0] ?? "", line2: lines.slice(1).join(", ") };
|
||||
}
|
||||
|
||||
export function isPincode(value: string): boolean {
|
||||
return /^[0-9]{6}$/.test(value.trim());
|
||||
}
|
||||
|
||||
export function pincodeError(value: string): string | undefined {
|
||||
if (!value.trim()) return undefined;
|
||||
return isPincode(value) ? undefined : "Pincode is 6 digits";
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { itemFromPreset, paiseToRupees, rupeesToPaise } from "./presets";
|
||||
import { blankPreset } from "./types";
|
||||
|
||||
describe("preset money helpers", () => {
|
||||
it("rounds rupees to whole paise", () => {
|
||||
expect(rupeesToPaise(1500.5)).toBe(150050);
|
||||
expect(rupeesToPaise(0.1 + 0.2)).toBe(30);
|
||||
expect(rupeesToPaise(19.99)).toBe(1999);
|
||||
expect(rupeesToPaise(Number.NaN)).toBe(0);
|
||||
expect(paiseToRupees(150050)).toBe(1500.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("itemFromPreset", () => {
|
||||
it("fills a fixed line", () => {
|
||||
const item = itemFromPreset({ ...blankPreset(), description: "Retainer", mode: "fixed", ratePaise: 250000, hsnSac: "9983" }, { hsnSac: "0000" });
|
||||
expect(item).toMatchObject({ description: "Retainer", mode: "fixed", amount: 2500, rate: 0, quantity: 1, hsnSac: "9983" });
|
||||
});
|
||||
it("fills a rate line and falls back to the default HSN/SAC", () => {
|
||||
const item = itemFromPreset({ ...blankPreset(), description: "Hours", mode: "rate", unit: "hour", ratePaise: 150050 }, { hsnSac: "998397" });
|
||||
expect(item).toMatchObject({ mode: "rate", unit: "hour", rate: 1500.5, amount: 0, quantity: 1, hsnSac: "998397" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { InvoiceItem, ItemPreset } from "./types";
|
||||
|
||||
export const rupeesToPaise = (rupees: number): number => Math.round((Number(rupees) || 0) * 100);
|
||||
export const paiseToRupees = (paise: number): number => paise / 100;
|
||||
|
||||
/**
|
||||
* A new invoice line pre-filled from a preset. Invoice items store rupee floats, so the integer-paise
|
||||
* preset amount is converted only here.
|
||||
*/
|
||||
export function itemFromPreset(preset: ItemPreset, defaults: { hsnSac: string }): InvoiceItem {
|
||||
const rupees = paiseToRupees(preset.ratePaise);
|
||||
return {
|
||||
description: preset.description,
|
||||
mode: preset.mode,
|
||||
rate: preset.mode === "rate" ? rupees : 0,
|
||||
unit: preset.unit,
|
||||
quantity: 1,
|
||||
amount: preset.mode === "fixed" ? rupees : 0,
|
||||
sortOrder: 0,
|
||||
hsnSac: preset.hsnSac || defaults.hsnSac,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SETTINGS_TABS, loadSettingsTab, parseSettingsTab, saveSettingsTab } from "./settingsTab";
|
||||
|
||||
describe("settings tab persistence", () => {
|
||||
it("maps stored ids to indexes and falls back to the first tab", () => {
|
||||
expect(parseSettingsTab("fonts")).toBe(SETTINGS_TABS.findIndex((t) => t.id === "fonts"));
|
||||
expect(parseSettingsTab("nope")).toBe(0);
|
||||
expect(parseSettingsTab(null)).toBe(0);
|
||||
});
|
||||
|
||||
it("does not throw when storage is unavailable", () => {
|
||||
// The node test environment has no localStorage at all.
|
||||
expect(loadSettingsTab()).toBe(0);
|
||||
expect(() => saveSettingsTab(2)).not.toThrow();
|
||||
expect(() => saveSettingsTab(99)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
const KEY = "voiced.settingsTab";
|
||||
|
||||
export const SETTINGS_TABS = [
|
||||
{ id: "business", label: "Business" },
|
||||
{ id: "branding", label: "Branding" },
|
||||
{ id: "bank", label: "Bank & payments" },
|
||||
{ id: "defaults", label: "Invoice defaults" },
|
||||
{ id: "numbering", label: "Numbering" },
|
||||
{ id: "templates", label: "Templates & export" },
|
||||
{ id: "fonts", label: "Fonts" },
|
||||
{ id: "data", label: "Data" },
|
||||
] as const;
|
||||
|
||||
export type SettingsTabId = (typeof SETTINGS_TABS)[number]["id"];
|
||||
|
||||
/** Index of a stored tab id; 0 (Business) for anything unknown. */
|
||||
export function parseSettingsTab(value: string | null | undefined): number {
|
||||
const index = SETTINGS_TABS.findIndex((t) => t.id === value);
|
||||
return index < 0 ? 0 : index;
|
||||
}
|
||||
|
||||
/** The last Settings tab this viewer used. A per-viewer convenience only. */
|
||||
export function loadSettingsTab(): number {
|
||||
try {
|
||||
return parseSettingsTab(localStorage.getItem(KEY));
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSettingsTab(index: number): void {
|
||||
try {
|
||||
const tab = SETTINGS_TABS[index];
|
||||
if (tab) localStorage.setItem(KEY, tab.id);
|
||||
} catch {
|
||||
// Not remembering is harmless.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AUTO_DISMISS_MS, MAX_TOASTS, createToastStore } from "./toastStore";
|
||||
|
||||
function fakeTimers() {
|
||||
let next = 1;
|
||||
const pending = new Map<number, { fn: () => void; ms: number }>();
|
||||
return {
|
||||
setTimer: (fn: () => void, ms: number) => {
|
||||
const id = next++;
|
||||
pending.set(id, { fn, ms });
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id: unknown) => void pending.delete(id as number),
|
||||
fireAll: () => [...pending.entries()].forEach(([id, t]) => (pending.delete(id), t.fn())),
|
||||
pending,
|
||||
};
|
||||
}
|
||||
|
||||
describe("toast store", () => {
|
||||
it("auto-dismisses non-error toasts after the delay", () => {
|
||||
const timers = fakeTimers();
|
||||
const store = createToastStore(timers);
|
||||
store.add("success", "Saved");
|
||||
store.add("info", "FYI");
|
||||
expect([...timers.pending.values()].map((t) => t.ms)).toEqual([AUTO_DISMISS_MS, AUTO_DISMISS_MS]);
|
||||
timers.fireAll();
|
||||
expect(store.getSnapshot()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps errors until they are closed", () => {
|
||||
const timers = fakeTimers();
|
||||
const store = createToastStore(timers);
|
||||
const id = store.add("error", "Failed", "details");
|
||||
expect(timers.pending.size).toBe(0);
|
||||
timers.fireAll();
|
||||
expect(store.getSnapshot()).toEqual([{ id, kind: "error", title: "Failed", subtitle: "details" }]);
|
||||
store.dismiss(id);
|
||||
expect(store.getSnapshot()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("dismiss cancels the timer and is idempotent", () => {
|
||||
const timers = fakeTimers();
|
||||
const store = createToastStore(timers);
|
||||
const id = store.add("success", "x");
|
||||
store.dismiss(id);
|
||||
expect(timers.pending.size).toBe(0);
|
||||
store.dismiss(id);
|
||||
expect(store.getSnapshot()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("notifies subscribers with a new snapshot reference per change", () => {
|
||||
const store = createToastStore(fakeTimers());
|
||||
let calls = 0;
|
||||
const off = store.subscribe(() => calls++);
|
||||
const before = store.getSnapshot();
|
||||
store.add("info", "a");
|
||||
expect(calls).toBe(1);
|
||||
expect(store.getSnapshot()).not.toBe(before);
|
||||
const same = store.getSnapshot();
|
||||
store.dismiss(999);
|
||||
expect(calls).toBe(1);
|
||||
expect(store.getSnapshot()).toBe(same);
|
||||
off();
|
||||
store.add("info", "b");
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("caps the stack and drops the oldest first, cancelling its timer", () => {
|
||||
const timers = fakeTimers();
|
||||
const store = createToastStore(timers);
|
||||
for (let i = 0; i < MAX_TOASTS + 2; i++) store.add("success", `t${i}`);
|
||||
const titles = store.getSnapshot().map((t) => t.title);
|
||||
expect(titles).toHaveLength(MAX_TOASTS);
|
||||
expect(titles[0]).toBe("t2");
|
||||
expect(timers.pending.size).toBe(MAX_TOASTS);
|
||||
});
|
||||
|
||||
it("clear empties the stack and cancels timers", () => {
|
||||
const timers = fakeTimers();
|
||||
const store = createToastStore(timers);
|
||||
store.add("success", "a");
|
||||
store.add("error", "b");
|
||||
store.clear();
|
||||
expect(store.getSnapshot()).toHaveLength(0);
|
||||
expect(timers.pending.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
export type ToastKind = "success" | "info" | "warning" | "error";
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
kind: ToastKind;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
/** Success, info and warning toasts leave by themselves; errors stay until closed. */
|
||||
export const AUTO_DISMISS_MS = 5000;
|
||||
/** Oldest toasts are dropped beyond this, so a failing loop cannot fill the screen. */
|
||||
export const MAX_TOASTS = 5;
|
||||
|
||||
type TimerId = unknown;
|
||||
|
||||
export interface ToastStoreOptions {
|
||||
setTimer?: (fn: () => void, ms: number) => TimerId;
|
||||
clearTimer?: (id: TimerId) => void;
|
||||
}
|
||||
|
||||
export interface ToastStore {
|
||||
add: (kind: ToastKind, title: string, subtitle?: string) => number;
|
||||
dismiss: (id: number) => void;
|
||||
clear: () => void;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
getSnapshot: () => readonly Toast[];
|
||||
}
|
||||
|
||||
/** A tiny external store (useSyncExternalStore-compatible); the timers are injectable for tests. */
|
||||
export function createToastStore(options: ToastStoreOptions = {}): ToastStore {
|
||||
const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
||||
const clearTimer = options.clearTimer ?? ((id) => clearTimeout(id as ReturnType<typeof setTimeout>));
|
||||
let toasts: readonly Toast[] = [];
|
||||
let nextId = 1;
|
||||
const timers = new Map<number, TimerId>();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const emit = () => listeners.forEach((l) => l());
|
||||
|
||||
const drop = (id: number) => {
|
||||
const timer = timers.get(id);
|
||||
if (timer !== undefined) {
|
||||
clearTimer(timer);
|
||||
timers.delete(id);
|
||||
}
|
||||
};
|
||||
|
||||
const dismiss = (id: number) => {
|
||||
drop(id);
|
||||
if (!toasts.some((t) => t.id === id)) return;
|
||||
toasts = toasts.filter((t) => t.id !== id);
|
||||
emit();
|
||||
};
|
||||
|
||||
return {
|
||||
add(kind, title, subtitle) {
|
||||
const id = nextId++;
|
||||
toasts = [...toasts, { id, kind, title, subtitle }];
|
||||
while (toasts.length > MAX_TOASTS) {
|
||||
drop(toasts[0].id);
|
||||
toasts = toasts.slice(1);
|
||||
}
|
||||
if (kind !== "error") timers.set(id, setTimer(() => dismiss(id), AUTO_DISMISS_MS));
|
||||
emit();
|
||||
return id;
|
||||
},
|
||||
dismiss,
|
||||
clear() {
|
||||
timers.forEach((t) => clearTimer(t));
|
||||
timers.clear();
|
||||
toasts = [];
|
||||
emit();
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
getSnapshot: () => toasts,
|
||||
};
|
||||
}
|
||||
@@ -58,16 +58,80 @@ export interface InvoiceSeries {
|
||||
nextInvoiceNumber: string;
|
||||
}
|
||||
|
||||
export type GstCategory = "registered_regular" | "unregistered" | "composition" | "sez" | "overseas";
|
||||
|
||||
/** Informational only; the tax heads still derive from supplier state and place of supply. */
|
||||
export const GST_CATEGORY_LABELS: Record<GstCategory, string> = {
|
||||
registered_regular: "Registered (regular)",
|
||||
unregistered: "Unregistered",
|
||||
composition: "Composition",
|
||||
sez: "SEZ",
|
||||
overseas: "Overseas",
|
||||
};
|
||||
|
||||
export interface Client {
|
||||
id: number | null;
|
||||
name: string;
|
||||
/** The composed multi-line address; what invoices store and print. */
|
||||
address: string;
|
||||
gstin: string;
|
||||
/** Doubles as the address state and the default place of supply. */
|
||||
stateCode: string;
|
||||
poNumber: string;
|
||||
createdAt: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
city: string;
|
||||
pincode: string;
|
||||
gstCategory: GstCategory;
|
||||
/** Notes / payment terms text copied onto a new invoice when the client is picked. */
|
||||
defaultNotes: string;
|
||||
/** Null means the settings default. */
|
||||
paymentTermsDays: number | null;
|
||||
/** Read-only: how many invoices reference this client. */
|
||||
invoiceCount?: number;
|
||||
}
|
||||
|
||||
export const blankClient = (): Client => ({
|
||||
id: null,
|
||||
name: "",
|
||||
address: "",
|
||||
gstin: "",
|
||||
stateCode: "",
|
||||
poNumber: "",
|
||||
createdAt: "",
|
||||
addressLine1: "",
|
||||
addressLine2: "",
|
||||
city: "",
|
||||
pincode: "",
|
||||
gstCategory: "unregistered",
|
||||
defaultNotes: "",
|
||||
paymentTermsDays: null,
|
||||
});
|
||||
|
||||
/** A saved line item. `ratePaise` is the amount for "fixed" and the per-unit rate for "rate". */
|
||||
export interface ItemPreset {
|
||||
id: number | null;
|
||||
description: string;
|
||||
hsnSac: string;
|
||||
mode: LineMode;
|
||||
unit: LineUnit;
|
||||
ratePaise: number;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const blankPreset = (): ItemPreset => ({
|
||||
id: null,
|
||||
description: "",
|
||||
hsnSac: "",
|
||||
mode: "fixed",
|
||||
unit: "unit",
|
||||
ratePaise: 0,
|
||||
sortOrder: 0,
|
||||
createdAt: "",
|
||||
});
|
||||
|
||||
export interface InvoiceItem {
|
||||
id?: number | null;
|
||||
description: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { gstinChecksumError, gstinFullError, vendorGstinError } from "./validators";
|
||||
import { clientGstinStateError, gstinChecksumError, gstinFullError, vendorGstinError } from "./validators";
|
||||
|
||||
// Same GSTINs as the Rust tests in src-tauri/src/gst.rs.
|
||||
describe("GSTIN checksum", () => {
|
||||
@@ -25,3 +25,19 @@ describe("GSTIN checksum", () => {
|
||||
expect(vendorGstinError("regular", "27AAPFU0939F1ZV", "AAPFU0939F", "27")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clientGstinStateError", () => {
|
||||
it("allows empty and NA", () => {
|
||||
expect(clientGstinStateError("", "29")).toBeUndefined();
|
||||
expect(clientGstinStateError("NA", "29")).toBeUndefined();
|
||||
});
|
||||
it("checks shape and checksum", () => {
|
||||
expect(clientGstinStateError("29ABCDE1234F1Z5", "29")).toMatch(/check digit/);
|
||||
expect(clientGstinStateError("29ABC", "")).toMatch(/15-character/);
|
||||
});
|
||||
it("checks the state prefix only when a state is chosen", () => {
|
||||
expect(clientGstinStateError("29ABCDE1234F1ZW", "29")).toBeUndefined();
|
||||
expect(clientGstinStateError("29abcde1234f1zw", "")).toBeUndefined();
|
||||
expect(clientGstinStateError("29ABCDE1234F1ZW", "27")).toMatch(/starts with 29/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,3 +84,18 @@ export function accountError(value: string): string | undefined {
|
||||
export const isPan = (v: string) => PAN_RE.test(norm(v));
|
||||
export const isIfsc = (v: string) => IFSC_RE.test(norm(v));
|
||||
export const isGstin = (v: string) => GSTIN_RE.test(norm(v));
|
||||
|
||||
/**
|
||||
* Client GSTIN rules: shape, check digit, and that its state prefix matches the client's state when both
|
||||
* are given. Empty (or "NA") is valid; a client may be unregistered.
|
||||
*/
|
||||
export function clientGstinStateError(gstin: string, stateCode: string): string | undefined {
|
||||
const g = norm(gstin);
|
||||
if (!g || g === "NA") return undefined;
|
||||
const base = gstinFullError(g);
|
||||
if (base) return base;
|
||||
if (stateCode && g.slice(0, 2) !== stateCode) {
|
||||
return `GSTIN starts with ${g.slice(0, 2)}, but the state is ${stateCode}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user