Files
doormilxpress_astryx/src/pages/doormile/clients/ClientOnboarding.jsx

487 lines
18 KiB
JavaScript

import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Building2, Copy, Eye, EyeOff, KeyRound, MapPin, Pencil, ShieldCheck, Trash2, UserPlus } from 'lucide-react';
import {
Alert, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
BreadcrumbSeparator, Button, DataTable, EmptyState, Field, Input, PageHeader, Stack,
StatusBadge, Surface, Switch,
} from '@/components/ds';
import { inputVariants } from '@/components/ui/input';
import { useAuth } from '@/lib/AuthContext';
import { useOnboardClient, useOnboardedClients, useOnboardingCities } from '@/lib/doormileHooks';
import AddressAutocomplete from '@/components/doormile/AddressAutocomplete';
import { EditClientModal, RemoveClientModal } from './ClientLoginDialogs';
import {
addressFromPlace, addressPayload, canOnboardClients, generateClientPassword, hasMapLocation,
onboardingUnavailableMessage, validateOnboarding,
} from '@/lib/clientOnboarding';
/**
* Client onboarding — register a new client and give them a console login.
*
* One submit creates, in a single server transaction, the tenant (the client
* company), its console login (doormile_auth, scoped to that tenant) and its
* appusers row. The client then signs in at the normal console login and sees
* only their own data.
*
* Restricted to the onboarding owner login (admin@doormile.com). The server
* enforces that; this page only hides itself from everyone else.
*/
const EMPTY = {
companyname: '',
contactname: '',
email: '',
phone: '',
applocationid: '',
password: '',
confirm: '',
requiredeliveryotp: false,
// Main address, saved as the client's primary location. Coordinates come
// only from picking a suggestion.
address: '',
city: '',
state: '',
pincode: '',
latitude: '',
longitude: '',
};
const SERVER_FIELD = [
['company name', 'companyname'],
['contact person', 'contactname'],
['email', 'email'],
['mobile', 'phone'],
['password', 'password'],
['operating city', 'applocationid'],
['pincode', 'pincode'],
['address', 'address'],
['map location', 'address'],
];
/** Put a server message under the field it is about, when it names one. */
function serverFieldOf(message) {
const m = String(message || '').toLowerCase();
const hit = SERVER_FIELD.find(([needle]) => m.includes(needle));
return hit ? hit[1] : null;
}
function OnboardedList() {
const { data = [], isLoading } = useOnboardedClients();
const [editing, setEditing] = useState(null);
const [removing, setRemoving] = useState(null);
// Rows without an authid come from a backend older than Edit/Delete (it has
// neither the field nor the PUT/DELETE routes). Never send /clients/undefined:
// disable the actions and say what to do instead.
const backendTooOld = data.length > 0 && data.some((r) => r.authid == null);
const columns = [
{ key: 'tenantname', header: 'Client', accessor: (r) => r.tenantname },
{ key: 'loginemail', header: 'Console login', accessor: (r) => r.loginemail },
{ key: 'primarycontact', header: 'Contact', accessor: (r) => r.primarycontact || '—' },
{
key: 'address',
header: 'Address',
accessor: (r) => (r.address ? [r.address, r.pincode].filter(Boolean).join(' · ') : '—'),
},
{ key: 'status', header: 'Status', cell: (r) => <StatusBadge status={r.status} size="sm" /> },
{
key: 'logincreatedat',
header: 'Login created',
accessor: (r) => (r.logincreatedat ? new Date(r.logincreatedat).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'),
},
{
key: 'actions',
header: 'Actions',
align: 'right',
cell: (r) => (
<div className="flex justify-end gap-1.5">
<Button
size="sm"
variant="outline"
disabled={r.authid == null}
onClick={() => setEditing(r)}
aria-label={`Edit ${r.tenantname}`}
>
<Pencil className="mr-1 h-3.5 w-3.5" /> Edit
</Button>
<Button
size="sm"
variant="outline"
className="text-destructive hover:text-destructive"
disabled={r.authid == null}
onClick={() => setRemoving(r)}
aria-label={`Delete ${r.tenantname}`}
>
<Trash2 className="mr-1 h-3.5 w-3.5" /> Delete
</Button>
</div>
),
},
];
return (
<Surface radius="xl" padding="lg">
<h2 className="text-body font-semibold text-ink-1 mb-3">Clients with a console login</h2>
{backendTooOld && (
<Alert tone="warning" className="mb-3" role="alert" title="Edit and Delete are not available on this server yet">
The API server this console talks to is running an older backend build without the edit and delete
endpoints. Rebuild and redeploy doormile_backend from the current code, then reload this page.
</Alert>
)}
<DataTable
columns={columns}
rows={data}
getRowId={(row, i) => row.authid ?? `${row.tenantid}-${row.loginemail}-${i}`}
loading={isLoading}
emptyState={<EmptyState icon={Building2} size="sm" title="No client logins yet" />}
/>
<EditClientModal row={editing} onClose={() => setEditing(null)} />
<RemoveClientModal row={removing} onClose={() => setRemoving(null)} />
</Surface>
);
}
export default function ClientOnboarding() {
const navigate = useNavigate();
const { user, isClient } = useAuth();
const allowed = canOnboardClients(user, isClient);
const onboard = useOnboardClient();
const citiesQ = useOnboardingCities({ enabled: allowed, retry: false });
const locations = citiesQ.data || [];
// When the city list cannot be read, onboarding cannot work on this server
// either (same backend, same gate) — say why instead of an empty dropdown.
const unavailable = citiesQ.isError ? onboardingUnavailableMessage(citiesQ.error) : '';
const cities = useMemo(() => locations.filter((l) => Number(l.applocationid) > 0), [locations]);
const [form, setForm] = useState(EMPTY);
const [errors, setErrors] = useState({});
const [formError, setFormError] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [created, setCreated] = useState(null); // { tenant, login, password }
const [copied, setCopied] = useState(false);
const set = (key) => (value) => {
setForm((f) => ({ ...f, [key]: value }));
setErrors((e) => ({ ...e, [key]: undefined }));
};
if (!allowed) {
return (
<Stack space="lg">
<EmptyState
icon={ShieldCheck}
title="Client onboarding is restricted"
description="Only the designated onboarding account can register new clients."
action={
isClient
? { label: 'Go to home', onClick: () => navigate('/doormile/home') }
: { label: 'Back to clients', onClick: () => navigate('/doormile/tenants') }
}
/>
</Stack>
);
}
const submit = (event) => {
event.preventDefault();
setFormError('');
const nextErrors = validateOnboarding(form);
setErrors(nextErrors);
if (Object.keys(nextErrors).length) return;
const password = form.password;
onboard.mutate(
{
companyname: form.companyname.trim(),
contactname: form.contactname.trim(),
email: form.email.trim().toLowerCase(),
phone: form.phone.replace(/\D/g, ''),
password,
applocationid: Number(form.applocationid),
requiredeliveryotp: form.requiredeliveryotp,
...addressPayload(form),
},
{
onSuccess: (res) => {
if (res?.success === false) {
setFormError(res.message || 'The client was not created.');
return;
}
setCreated({ ...res.data, password });
setForm(EMPTY);
setCopied(false);
},
onError: (err) => {
const status = err?.response?.status;
const message = err?.response?.data?.message;
if (status === 403) {
setFormError('Your login is not allowed to onboard clients.');
return;
}
const field = serverFieldOf(message);
if (field && message) setErrors((e) => ({ ...e, [field]: message }));
else setFormError(message || 'The client could not be created. Nothing was saved.');
},
}
);
};
const copyLogin = async () => {
if (!created) return;
const text = [
`Doormile console: ${window.location.origin}/login`,
`Email: ${created.login.email}`,
`Password: ${created.password}`,
].join('\n');
try {
await navigator.clipboard.writeText(text);
setCopied(true);
} catch {
setCopied(false);
}
};
return (
<Stack space="lg">
<PageHeader
breadcrumb={
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/doormile/tenants">Clients</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Onboard client</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
}
actions={
<Button variant="outline" onClick={() => navigate('/doormile/tenants')}>
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
</Button>
}
/>
{created && (
<Alert tone="success" title={`${created.tenant.tenantname} is onboarded`} role="status">
<p>
Console login <span className="font-semibold">{created.login.email}</span> is ready. They sign in at the
usual console login and see only their own orders.
</p>
<p className="mt-1">
This is the only time the password is shown — share it with the client securely.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={copyLogin}>
<Copy className="mr-1.5 h-4 w-4" /> {copied ? 'Copied' : 'Copy login details'}
</Button>
<Button size="sm" variant="ghost" onClick={() => setCreated(null)}>
Onboard another
</Button>
</div>
</Alert>
)}
<form onSubmit={submit} noValidate>
<Surface radius="xl" padding="lg">
<div className="mb-5 flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-tint text-brand">
<UserPlus className="h-5 w-5" />
</div>
<div>
<h1 className="text-h4 font-semibold text-ink-1">Onboard a new client</h1>
<p className="text-body-sm text-ink-3">
Creates the client and their console login together. If any part fails, nothing is saved.
</p>
</div>
</div>
{unavailable && (
<Alert
tone="warning"
className="mb-4"
role="alert"
title="Client onboarding is not available right now"
action={
// type="button": this sits inside the form, and a plain button
// would also submit it, flashing every field's error.
<Button type="button" size="sm" variant="outline" onClick={() => citiesQ.refetch()}>
Try again
</Button>
}
>
{unavailable}
</Alert>
)}
{formError && (
<Alert tone="destructive" className="mb-4" role="alert">
{formError}
</Alert>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Company name" required error={errors.companyname} className="sm:col-span-2">
<Input
value={form.companyname}
onChange={(e) => set('companyname')(e.target.value)}
placeholder="Peelamedu Provisions Pvt Ltd"
maxLength={120}
/>
</Field>
<Field label="Contact person" required error={errors.contactname}>
<Input
value={form.contactname}
onChange={(e) => set('contactname')(e.target.value)}
placeholder="Priya Raman"
maxLength={80}
/>
</Field>
<Field label="Mobile number" required error={errors.phone}>
<Input
value={form.phone}
onChange={(e) => set('phone')(e.target.value.replace(/\D/g, '').slice(0, 10))}
placeholder="9876543210"
inputMode="numeric"
/>
</Field>
<Field label="Login email" required error={errors.email} hint="The client signs in to the console with this">
<Input
type="email"
value={form.email}
onChange={(e) => set('email')(e.target.value)}
placeholder="ops@client.com"
autoComplete="off"
/>
</Field>
<Field label="Operating city" required error={errors.applocationid}>
<select
value={form.applocationid}
onChange={(e) => set('applocationid')(e.target.value)}
className={inputVariants()}
>
<option value="">
{citiesQ.isLoading ? 'Loading cities…' : unavailable ? 'Cities could not be loaded' : cities.length ? 'Choose a city' : 'No operating cities found'}
</option>
{cities.map((c) => (
<option key={c.applocationid} value={c.applocationid}>
{c.applocationname}
</option>
))}
</select>
</Field>
<div className="sm:col-span-2">
<AddressAutocomplete
id="onboard-address"
label="Client address"
required
placeholder="Search the client's building, street or area…"
value={form.address}
onChange={(text) => set('address')(text)}
onPlaceSelected={(place) => {
setForm((f) => ({ ...f, ...addressFromPlace(place) }));
setErrors((e) => ({ ...e, address: undefined, pincode: undefined }));
}}
onPlaceCleared={() => setForm((f) => ({ ...f, latitude: '', longitude: '' }))}
/>
{errors.address ? (
<p className="mt-1 text-caption text-destructive" role="alert">
{errors.address}
</p>
) : (
<p className="mt-1 flex items-center gap-1 text-caption text-ink-3">
<MapPin className="h-3.5 w-3.5" />
{hasMapLocation(form)
? "Location pinned. This becomes the client's pickup point and zone."
: 'Pick a suggestion so the address gets a map location.'}
</p>
)}
</div>
<Field label="Pincode" required error={errors.pincode}>
<Input
value={form.pincode}
onChange={(e) => set('pincode')(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="641002"
inputMode="numeric"
/>
</Field>
<Field label="City">
<Input value={form.city} onChange={(e) => set('city')(e.target.value)} placeholder="Coimbatore" maxLength={80} />
</Field>
<Field label="Password" required error={errors.password} hint="At least 8 characters" htmlFor="onboard-password">
<Input
id="onboard-password"
type={showPassword ? 'text' : 'password'}
value={form.password}
onChange={(e) => set('password')(e.target.value)}
autoComplete="new-password"
maxLength={72}
/>
</Field>
<Field label="Confirm password" required error={errors.confirm}>
<Input
type={showPassword ? 'text' : 'password'}
value={form.confirm}
onChange={(e) => set('confirm')(e.target.value)}
autoComplete="new-password"
maxLength={72}
/>
</Field>
<div className="flex flex-wrap items-center gap-2 sm:col-span-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
const pw = generateClientPassword();
setForm((f) => ({ ...f, password: pw, confirm: pw }));
setErrors((e) => ({ ...e, password: undefined, confirm: undefined }));
setShowPassword(true);
}}
>
<KeyRound className="mr-1.5 h-4 w-4" /> Generate password
</Button>
<Button type="button" size="sm" variant="ghost" onClick={() => setShowPassword((s) => !s)}>
{showPassword ? <EyeOff className="mr-1.5 h-4 w-4" /> : <Eye className="mr-1.5 h-4 w-4" />}
{showPassword ? 'Hide' : 'Show'} password
</Button>
</div>
<Field
label="Require delivery OTP"
inline
hint="The rider must collect a one-time code from the receiver"
className="sm:col-span-2"
>
<Switch checked={form.requiredeliveryotp} onCheckedChange={set('requiredeliveryotp')} />
</Field>
</div>
<div className="mt-6 flex justify-end gap-2 border-t border-border pt-5">
<Button type="button" variant="ghost" onClick={() => navigate('/doormile/tenants')}>
Cancel
</Button>
<Button type="submit" loading={onboard.isPending} disabled={Boolean(unavailable)}>
Onboard client
</Button>
</div>
</Surface>
</form>
<OnboardedList />
</Stack>
);
}