306 lines
12 KiB
JavaScript
306 lines
12 KiB
JavaScript
import React, { useEffect, useState } from 'react';
|
|
import { KeyRound, Pencil, Trash2 } from 'lucide-react';
|
|
import { Alert, Button, Field, Input, Modal, Switch } from '@/components/ds';
|
|
import { inputVariants } from '@/components/ui/input';
|
|
import { useDeleteOnboardedClient, useUpdateOnboardedClient } from '@/lib/doormileHooks';
|
|
import AddressAutocomplete from '@/components/doormile/AddressAutocomplete';
|
|
import { addressFromPlace, addressPayload, generateClientPassword, validateAddress } from '@/lib/clientOnboarding';
|
|
|
|
/**
|
|
* Edit and Remove for one onboarded client login (a row of "Clients with a
|
|
* console login"). Both call owner-only endpoints: PUT and DELETE
|
|
* /admin/clients/:authid.
|
|
*/
|
|
|
|
const STATUSES = ['Active', 'Pending', 'Inactive'];
|
|
|
|
/** Only the fields that actually changed, so an edit never rewrites the rest. */
|
|
export function changedFields(row, form) {
|
|
const out = {};
|
|
const trim = (v) => String(v ?? '').trim();
|
|
if (trim(form.companyname) !== trim(row.tenantname)) out.companyname = trim(form.companyname);
|
|
if (trim(form.contactname) !== trim(row.contactname)) out.contactname = trim(form.contactname);
|
|
if (trim(form.email).toLowerCase() !== trim(row.loginemail).toLowerCase()) out.email = trim(form.email).toLowerCase();
|
|
if (form.phone.replace(/\D/g, '') !== String(row.primarycontact || '').replace(/\D/g, '')) out.phone = form.phone.replace(/\D/g, '');
|
|
if (form.status !== row.status) out.status = form.status;
|
|
if (Boolean(form.requiredeliveryotp) !== Boolean(row.requiredeliveryotp)) out.requiredeliveryotp = Boolean(form.requiredeliveryotp);
|
|
if (form.password) out.password = form.password;
|
|
// The address goes as one `location` object, and only when any part of it
|
|
// changed (a coordinate change alone counts: re-picking the same text moves
|
|
// the pin).
|
|
const addr = addressPayload(form);
|
|
const before = {
|
|
address: trim(row.address),
|
|
city: trim(row.city),
|
|
state: trim(row.state),
|
|
pincode: String(row.pincode || ''),
|
|
latitude: Number(row.latitude) || 0,
|
|
longitude: Number(row.longitude) || 0,
|
|
};
|
|
if (
|
|
addr.address !== before.address ||
|
|
addr.pincode !== before.pincode ||
|
|
addr.city !== before.city ||
|
|
addr.state !== before.state ||
|
|
(Number(addr.latitude) || 0) !== before.latitude ||
|
|
(Number(addr.longitude) || 0) !== before.longitude
|
|
) {
|
|
out.location = addr;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function validateEdit(form, changes) {
|
|
const errors = {};
|
|
if ('companyname' in changes && changes.companyname.length < 2) errors.companyname = 'Enter the company name';
|
|
if ('contactname' in changes && changes.contactname.length < 2) errors.contactname = "Enter the contact person's name";
|
|
if ('email' in changes && !/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(changes.email)) errors.email = 'Enter a valid email address';
|
|
if ('phone' in changes && !/^[6-9]\d{9}$/.test(changes.phone)) errors.phone = 'Enter a 10-digit mobile number';
|
|
if (form.password && form.password.length < 8) errors.password = 'At least 8 characters';
|
|
if (form.password && form.password.length > 72) errors.password = 'At most 72 characters';
|
|
if ('location' in changes) Object.assign(errors, validateAddress(form));
|
|
return errors;
|
|
}
|
|
|
|
function formFrom(row) {
|
|
return {
|
|
companyname: row?.tenantname || '',
|
|
contactname: row?.contactname || '',
|
|
email: row?.loginemail || '',
|
|
phone: String(row?.primarycontact || '').replace(/\D/g, ''),
|
|
status: STATUSES.includes(row?.status) ? row.status : 'Active',
|
|
requiredeliveryotp: Boolean(row?.requiredeliveryotp),
|
|
password: '',
|
|
address: row?.address || '',
|
|
city: row?.city || '',
|
|
state: row?.state || '',
|
|
pincode: row?.pincode || '',
|
|
latitude: row?.latitude || '',
|
|
longitude: row?.longitude || '',
|
|
};
|
|
}
|
|
|
|
export function EditClientModal({ row, onClose }) {
|
|
const update = useUpdateOnboardedClient();
|
|
const [form, setForm] = useState(formFrom(row));
|
|
const [errors, setErrors] = useState({});
|
|
const [serverError, setServerError] = useState('');
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setForm(formFrom(row));
|
|
setErrors({});
|
|
setServerError('');
|
|
setShowPassword(false);
|
|
}, [row]);
|
|
|
|
const set = (key) => (value) => {
|
|
setForm((f) => ({ ...f, [key]: value }));
|
|
setErrors((e) => ({ ...e, [key]: undefined }));
|
|
};
|
|
|
|
const changes = row ? changedFields(row, form) : {};
|
|
const nothingChanged = Object.keys(changes).length === 0;
|
|
|
|
const save = () => {
|
|
const nextErrors = validateEdit(form, changes);
|
|
setErrors(nextErrors);
|
|
setServerError('');
|
|
if (Object.keys(nextErrors).length || nothingChanged) return;
|
|
update.mutate(
|
|
{ authid: row.authid, data: changes },
|
|
{
|
|
onSuccess: (res) => {
|
|
if (res?.success !== false) onClose();
|
|
},
|
|
onError: (err) => setServerError(err?.response?.data?.message || 'The change was not saved.'),
|
|
}
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
open={Boolean(row)}
|
|
onOpenChange={(open) => !open && onClose()}
|
|
title={`Edit ${row?.tenantname || 'client'}`}
|
|
description="Changes the client and its console login together."
|
|
icon={Pencil}
|
|
busy={update.isPending}
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={onClose} disabled={update.isPending}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={save} loading={update.isPending} disabled={nothingChanged}>
|
|
Save changes
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
{serverError && (
|
|
<Alert tone="destructive" className="mb-4" role="alert">
|
|
{serverError}
|
|
</Alert>
|
|
)}
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<Field label="Company name" error={errors.companyname} className="sm:col-span-2">
|
|
<Input value={form.companyname} maxLength={120} onChange={(e) => set('companyname')(e.target.value)} />
|
|
</Field>
|
|
<Field label="Contact person" error={errors.contactname}>
|
|
<Input value={form.contactname} maxLength={80} onChange={(e) => set('contactname')(e.target.value)} />
|
|
</Field>
|
|
<Field label="Mobile number" error={errors.phone}>
|
|
<Input
|
|
value={form.phone}
|
|
inputMode="numeric"
|
|
onChange={(e) => set('phone')(e.target.value.replace(/\D/g, '').slice(0, 10))}
|
|
/>
|
|
</Field>
|
|
<Field label="Login email" error={errors.email} hint="Changing it changes what they sign in with">
|
|
<Input type="email" value={form.email} autoComplete="off" onChange={(e) => set('email')(e.target.value)} />
|
|
</Field>
|
|
<Field label="Status">
|
|
<select value={form.status} onChange={(e) => set('status')(e.target.value)} className={inputVariants()}>
|
|
{STATUSES.map((s) => (
|
|
<option key={s} value={s}>
|
|
{s}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
<Field
|
|
label="New password"
|
|
error={errors.password}
|
|
hint="Leave empty to keep the current password"
|
|
htmlFor="edit-client-password"
|
|
className="sm:col-span-2"
|
|
>
|
|
<Input
|
|
id="edit-client-password"
|
|
type={showPassword ? 'text' : 'password'}
|
|
value={form.password}
|
|
maxLength={72}
|
|
autoComplete="new-password"
|
|
onChange={(e) => set('password')(e.target.value)}
|
|
/>
|
|
</Field>
|
|
<div className="flex flex-wrap gap-2 sm:col-span-2">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
set('password')(generateClientPassword());
|
|
setShowPassword(true);
|
|
}}
|
|
>
|
|
<KeyRound className="mr-1.5 h-4 w-4" /> Generate new password
|
|
</Button>
|
|
{form.password && (
|
|
<Button type="button" size="sm" variant="ghost" onClick={() => setShowPassword((s) => !s)}>
|
|
{showPassword ? 'Hide' : 'Show'} password
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="sm:col-span-2">
|
|
<AddressAutocomplete
|
|
id="edit-client-address"
|
|
label="Client address"
|
|
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>
|
|
) : (
|
|
!row?.address && (
|
|
<p className="mt-1 text-caption text-ink-3">No address on file yet. Add one so this client has a pickup point and zone.</p>
|
|
)
|
|
)}
|
|
</div>
|
|
<Field label="Pincode" error={errors.pincode}>
|
|
<Input
|
|
value={form.pincode}
|
|
inputMode="numeric"
|
|
onChange={(e) => set('pincode')(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
/>
|
|
</Field>
|
|
<Field label="City">
|
|
<Input value={form.city} maxLength={80} onChange={(e) => set('city')(e.target.value)} />
|
|
</Field>
|
|
<Field label="Require delivery OTP" inline className="sm:col-span-2">
|
|
<Switch checked={form.requiredeliveryotp} onCheckedChange={set('requiredeliveryotp')} />
|
|
</Field>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
export function RemoveClientModal({ row, onClose }) {
|
|
const remove = useDeleteOnboardedClient();
|
|
const [typed, setTyped] = useState('');
|
|
const [serverError, setServerError] = useState('');
|
|
|
|
useEffect(() => {
|
|
setTyped('');
|
|
setServerError('');
|
|
}, [row]);
|
|
|
|
const confirmed = row && typed.trim().toLowerCase() === String(row.tenantname).trim().toLowerCase();
|
|
|
|
const submit = () => {
|
|
if (!confirmed) return;
|
|
remove.mutate(row.authid, {
|
|
onSuccess: (res) => {
|
|
if (res?.success !== false) onClose();
|
|
},
|
|
onError: (err) => setServerError(err?.response?.data?.message || 'The login was not removed.'),
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
open={Boolean(row)}
|
|
onOpenChange={(open) => !open && onClose()}
|
|
title={`Remove ${row?.tenantname || 'client'}'s login`}
|
|
icon={Trash2}
|
|
busy={remove.isPending}
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={onClose} disabled={remove.isPending}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="destructive" onClick={submit} loading={remove.isPending} disabled={!confirmed}>
|
|
Remove login
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
{serverError && (
|
|
<Alert tone="destructive" className="mb-4" role="alert">
|
|
{serverError}
|
|
</Alert>
|
|
)}
|
|
<div className="space-y-3 text-body-sm text-ink-2">
|
|
<p>
|
|
<span className="font-semibold">{row?.loginemail}</span> will no longer be able to sign in to the console.
|
|
</p>
|
|
<p>
|
|
The client's orders, customers and pricing are kept. If this is its last login, the client is marked{' '}
|
|
<span className="font-semibold">Inactive</span>. A session already signed in ends when its token expires.
|
|
</p>
|
|
<Field label={`Type "${row?.tenantname || ''}" to confirm`}>
|
|
<Input value={typed} onChange={(e) => setTyped(e.target.value)} autoComplete="off" />
|
|
</Field>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|