feat(customers): a shelf folder number the office can hand back out
The office files every customer in a numbered paper folder and reuses the number once an account closes. That number was the legacy NUMid, which is also an import key and the portal login, so reusing it meant re-pointing an identity. It is now customers.folderNumber: a plain label nothing joins on. - Unique only among non-archived customers, checked in CustomersService (create, update, restore). Archived customers keep theirs as history. - GET /customers/folder/next suggests the lowest free number; the new customer form pre-fills it. - Backfilled from the utilities NUMid (dev: 1,171 folders, 0 shared); the importer copies it on load. Insurance-only customers start without one. - Reports, the printed statement and the customer file show "Carpeta" instead of NUMid. The list searches by it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
031b13f8c8
commit
b016e53893
15 files changed
+261
-58
No files matched your search
@@ -256,6 +256,7 @@ function Hero({
|
||||
.join(" · ");
|
||||
|
||||
const facts: { label: string; value: string }[] = [
|
||||
{ label: "Carpeta", value: data.folderNumber != null ? String(data.folderNumber) : "—" },
|
||||
{ label: "Cliente desde", value: formatDate(data.customerSince) },
|
||||
{
|
||||
label: "Cuota",
|
||||
|
||||
@@ -125,7 +125,7 @@ function ClientesBrowser() {
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Buscar por nombre, ciudad, teléfono…"
|
||||
placeholder="Buscar por nombre, carpeta, ciudad, teléfono…"
|
||||
aria-label="Buscar clientes"
|
||||
/>
|
||||
</div>
|
||||
@@ -261,6 +261,8 @@ function CustomerRow({ c }: { c: CustomerListItem }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="cust-sub">
|
||||
{c.folderNumber != null && <span>Carpeta {c.folderNumber}</span>}
|
||||
{c.folderNumber != null && (location || contact) && <span className="sep">·</span>}
|
||||
{location && <span>{location}</span>}
|
||||
{location && contact && <span className="sep">·</span>}
|
||||
{contact && <span>{contact}</span>}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { CustomerDetail, CustomerInput, Currency } from "@/lib/types";
|
||||
import { createCustomer, updateCustomer } from "@/lib/api";
|
||||
import { createCustomer, getNextFolderNumber, updateCustomer } from "@/lib/api";
|
||||
|
||||
/** ISO date (yyyy-mm-dd) for a date input, from an API date string. */
|
||||
function toDateInput(v: string | null | undefined): string {
|
||||
@@ -20,6 +20,7 @@ function numOrUndef(v: string | number | null | undefined): number | undefined {
|
||||
|
||||
type Values = {
|
||||
name: string;
|
||||
folderNumber: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
city: string;
|
||||
@@ -45,6 +46,7 @@ type Values = {
|
||||
function initial(c?: CustomerDetail): Values {
|
||||
return {
|
||||
name: c?.name ?? "",
|
||||
folderNumber: c?.folderNumber != null ? String(c.folderNumber) : "",
|
||||
addressLine1: c?.addressLine1 ?? "",
|
||||
addressLine2: c?.addressLine2 ?? "",
|
||||
city: c?.city ?? "",
|
||||
@@ -85,6 +87,17 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// A new customer is offered the lowest free folder, the way the office
|
||||
// hands numbers back out; staff can type any other.
|
||||
useEffect(() => {
|
||||
if (editing) return;
|
||||
getNextFolderNumber()
|
||||
.then(({ folderNumber }) =>
|
||||
setV((prev) => (prev.folderNumber ? prev : { ...prev, folderNumber: String(folderNumber) })),
|
||||
)
|
||||
.catch(() => {});
|
||||
}, [editing]);
|
||||
|
||||
function set<K extends keyof Values>(key: K, val: Values[K]) {
|
||||
setV((prev) => ({ ...prev, [key]: val }));
|
||||
}
|
||||
@@ -95,6 +108,8 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
|
||||
setError(null);
|
||||
const payload: CustomerInput = {
|
||||
name: v.name.trim(),
|
||||
// Blank clears it on edit; s() would leave the old number in place.
|
||||
folderNumber: v.folderNumber.trim() === "" ? (editing ? null : undefined) : Number(v.folderNumber),
|
||||
addressLine1: s(v.addressLine1),
|
||||
addressLine2: s(v.addressLine2),
|
||||
city: s(v.city),
|
||||
@@ -138,6 +153,10 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
|
||||
<input className="input" required value={v.name}
|
||||
onChange={(e) => set("name", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Carpeta">
|
||||
<input className="input" type="number" min={1} step={1} value={v.folderNumber}
|
||||
onChange={(e) => set("folderNumber", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Correo">
|
||||
<input className="input" type="email" value={v.email}
|
||||
onChange={(e) => set("email", e.target.value)} />
|
||||
|
||||
@@ -390,8 +390,8 @@ function StatementLayout({ result }: { result: ReportRunResult }) {
|
||||
<div className="statement">
|
||||
<header className="statement-head">
|
||||
<h2 className="statement-name">{String(header.name ?? "—")}</h2>
|
||||
{header.numid && header.numid !== "—" ? (
|
||||
<p className="muted">NUMid {String(header.numid)}</p>
|
||||
{header.folder && header.folder !== "—" ? (
|
||||
<p className="muted">Carpeta {String(header.folder)}</p>
|
||||
) : null}
|
||||
{Boolean(header.address) && (
|
||||
<p className="muted">{String(header.address)}</p>
|
||||
|
||||
@@ -215,6 +215,11 @@ export function listCustomers(
|
||||
return apiFetch<CustomerListResponse>(`/customers${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
/** Lowest folder number no open account holds — the suggestion for a new customer. */
|
||||
export function getNextFolderNumber(): Promise<{ folderNumber: number }> {
|
||||
return apiFetch<{ folderNumber: number }>("/customers/folder/next");
|
||||
}
|
||||
|
||||
export function getCustomer(id: string): Promise<CustomerDetail> {
|
||||
return apiFetch<CustomerDetail>(`/customers/${id}`);
|
||||
}
|
||||
|
||||
@@ -220,6 +220,7 @@ export interface CustomerListItem {
|
||||
name: string;
|
||||
/** Legacy table the name was recovered from; null when it came from DATGRAL. */
|
||||
nameSource: string | null;
|
||||
folderNumber: number | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
email: string | null;
|
||||
@@ -1113,6 +1114,7 @@ export interface CustomerDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
nameSource: string | null;
|
||||
folderNumber: number | null;
|
||||
addressLine1: string | null;
|
||||
addressLine2: string | null;
|
||||
city: string | null;
|
||||
@@ -1146,6 +1148,8 @@ export interface CustomerDetail {
|
||||
/** Editable customer fields — shared by the create/edit form and the API. */
|
||||
export interface CustomerInput {
|
||||
name: string;
|
||||
/** null clears it. */
|
||||
folderNumber?: number | null;
|
||||
addressLine1?: string;
|
||||
addressLine2?: string;
|
||||
city?: string;
|
||||
|
||||
Reference in new issue
Block a user