ifixkart-admin/app/(admin)/services/intake/page.tsx

640 lines
25 KiB
TypeScript

'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { AlertCircle, ListTodo, RefreshCw, Smartphone, User } from 'lucide-react';
import { toast } from 'sonner';
import { adminService } from '@/services/api/adminService';
import { formatCurrency } from '@/lib/utils';
import { CustomSelect } from '@/components/ui/CustomSelect';
import { PhoneInput } from '@/components/ui/PhoneInput';
import { validatePhone, validateEmail } from '@/lib/validation';
interface ServiceCatalogItem {
service_id: string;
name: string;
base_price: number;
description?: string | null;
estimated_duration_minutes?: number;
workflow_type?: string;
}
const EMPTY_DEVICE = {
brand: '',
custom_brand: '',
model: '',
custom_model: '',
model_number: '',
imei_primary: '',
imei_secondary: '',
color: '',
notes: '',
storage_capacity: '',
};
const EMPTY_INTAKE = {
power_status: 'POWERS_ON',
screen_condition: 'CRACKED',
body_condition: 'GOOD',
back_condition: 'GOOD',
camera_condition: 'WORKING',
accessories: 'NONE',
customer_notes: '',
technician_notes: '',
};
const POWER_OPTIONS = [
{ value: 'POWERS_ON', label: 'Powers on & boots' },
{ value: 'NO_POWER', label: 'No power / dead' },
{ value: 'BOOT_LOOP', label: 'Boot loop' },
];
const SCREEN_OPTIONS = [
{ value: 'EXCELLENT', label: 'Excellent (flawless)' },
{ value: 'MINOR_SCRATCHES', label: 'Minor scratches' },
{ value: 'CRACKED', label: 'Cracked glass' },
{ value: 'SHATTERED', label: 'Shattered display & touch dead' },
];
const BODY_OPTIONS = [
{ value: 'GOOD', label: 'Good (no dents)' },
{ value: 'DENTED', label: 'Minor scuffs / dented corners' },
{ value: 'BENT', label: 'Bent housing' },
];
export default function WalkInIntakePage() {
const router = useRouter();
const [catalog, setCatalog] = useState<ServiceCatalogItem[]>([]);
const [brands, setBrands] = useState<any[]>([]);
const [allModels, setAllModels] = useState<any[]>([]);
const [filteredModels, setFilteredModels] = useState<any[]>([]);
const [existingCustomers, setExistingCustomers] = useState<any[]>([]);
const [customerSearchQuery, setCustomerSearchQuery] = useState('');
const [loadingCatalog, setLoadingCatalog] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [customerType, setCustomerType] = useState<'EXISTING' | 'NEW'>('EXISTING');
const [customerId, setCustomerId] = useState('');
const [customerName, setCustomerName] = useState('');
const [customerPhone, setCustomerPhone] = useState('');
const [customerEmail, setCustomerEmail] = useState('');
const [selectedServiceId, setSelectedServiceId] = useState('');
const [customServiceName, setCustomServiceName] = useState('');
const [customServicePrice, setCustomServicePrice] = useState('');
const [deviceDetails, setDeviceDetails] = useState(EMPTY_DEVICE);
const [intakeDetails, setIntakeDetails] = useState(EMPTY_INTAKE);
const loadCatalog = async () => {
setLoadingCatalog(true);
try {
const [items, brandItems, modelItems, custItems] = await Promise.all([
adminService.fetchServiceCatalog(),
adminService.fetchBrands(),
adminService.fetchDeviceModels(),
adminService.fetchCustomers().catch(() => []),
]);
setCatalog(items);
setBrands(brandItems);
setAllModels(modelItems);
setExistingCustomers(custItems || []);
if (custItems && custItems.length > 0) {
setCustomerId(custItems[0].customer_id);
}
return { items, brandItems };
} catch (err: any) {
toast.error(err.message || 'Failed to load catalog services.');
return { items: [] as ServiceCatalogItem[], brandItems: [] as any[] };
} finally {
setLoadingCatalog(false);
}
};
const resetForm = (nextBrands = brands, nextCatalog = catalog) => {
setCustomerType('EXISTING');
setCustomerId('');
setCustomerName('');
setCustomerPhone('');
setCustomerEmail('');
setCustomServiceName('');
setCustomServicePrice('');
setSelectedServiceId(nextCatalog[0]?.service_id || '');
setDeviceDetails({
...EMPTY_DEVICE,
brand: nextBrands[0]?.brand_id || 'OTHER',
});
setIntakeDetails({ ...EMPTY_INTAKE });
setErrorMsg(null);
};
useEffect(() => {
loadCatalog().then(({ items, brandItems }) => {
resetForm(brandItems, items);
});
}, []);
useEffect(() => {
if (deviceDetails.brand && deviceDetails.brand !== 'OTHER') {
const filtered = allModels.filter((m) => m.brand_id === deviceDetails.brand);
setFilteredModels(filtered);
if (filtered.length > 0) {
setDeviceDetails((prev) => ({ ...prev, model: filtered[0].model_id }));
} else {
setDeviceDetails((prev) => ({ ...prev, model: 'OTHER' }));
}
} else {
setFilteredModels([]);
setDeviceDetails((prev) => ({ ...prev, model: 'OTHER' }));
}
}, [deviceDetails.brand, allModels]);
const handleIntakeSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (customerType === 'EXISTING' && !customerId) {
setErrorMsg('Customer ID is required.');
toast.error('Customer ID is required.');
return;
}
if (customerType === 'NEW') {
if (!customerName) {
setErrorMsg('Customer name is required.');
toast.error('Customer name is required.');
return;
}
if (customerPhone) {
const phoneErr = validatePhone(customerPhone);
if (phoneErr) {
setErrorMsg(phoneErr);
toast.error(phoneErr);
return;
}
}
if (customerEmail) {
const emailErr = validateEmail(customerEmail);
if (emailErr) {
setErrorMsg(emailErr);
toast.error(emailErr);
return;
}
}
}
if (!selectedServiceId) {
setErrorMsg('Service selection is required.');
toast.error('Service selection is required.');
return;
}
if (selectedServiceId === 'OTHER_SERVICE' && !customServiceName) {
setErrorMsg('Custom service name is required.');
toast.error('Custom service name is required.');
return;
}
setIsSubmitting(true);
setErrorMsg(null);
try {
let finalBrand = '';
if (deviceDetails.brand === 'OTHER') {
finalBrand = deviceDetails.custom_brand || 'Other';
} else {
const found = brands.find((b) => b.brand_id === deviceDetails.brand);
finalBrand = found ? found.name : 'Unknown';
}
let finalModel = '';
if (deviceDetails.model === 'OTHER') {
finalModel = deviceDetails.custom_model || 'Other';
} else {
const found = allModels.find((m) => m.model_id === deviceDetails.model);
finalModel = found ? found.name : 'Unknown';
}
const payload = {
device_id: undefined,
new_device: {
brand: finalBrand,
model: finalModel,
model_number: deviceDetails.model_number,
imei_primary: deviceDetails.imei_primary,
imei_secondary: deviceDetails.imei_secondary,
color: deviceDetails.color,
notes: deviceDetails.notes,
device_type: 'Smartphone',
storage_capacity: deviceDetails.storage_capacity,
},
service_id: selectedServiceId,
source: 'WALK_IN',
appointment: undefined,
customer_id: customerType === 'EXISTING' ? customerId : undefined,
customer_name: customerType === 'NEW' ? customerName : undefined,
customer_phone: customerType === 'NEW' ? customerPhone : undefined,
customer_email: customerType === 'NEW' ? customerEmail : undefined,
custom_service_name: selectedServiceId === 'OTHER_SERVICE' ? customServiceName : undefined,
};
const booking = await adminService.createServiceBooking(payload);
await adminService.submitDeviceIntake(booking.job_id, {
power_status: intakeDetails.power_status,
screen_condition: intakeDetails.screen_condition,
body_condition: intakeDetails.body_condition,
back_condition: intakeDetails.back_condition,
camera_condition: intakeDetails.camera_condition,
accessories: intakeDetails.accessories,
customer_notes: intakeDetails.customer_notes,
technician_notes: intakeDetails.technician_notes,
});
let baseTotal = 0;
if (selectedServiceId === 'OTHER_SERVICE') {
baseTotal = parseFloat(customServicePrice) || 0;
} else {
const serviceItem = catalog.find((s) => s.service_id === selectedServiceId);
baseTotal = serviceItem ? serviceItem.base_price : 0;
}
await adminService.createOrReviseQuote(booking.job_id, {
subtotal: baseTotal,
tax: baseTotal * 0.18,
additional_damage_amount: 0,
total: baseTotal * 1.18,
reason: 'Initial estimate quote generated for walk-in counter order.',
});
const displayJobNo = booking.job_no || booking.job_id;
toast.success(`Walk-in job ${displayJobNo} registered! Redirecting to technician workspace...`);
router.push(`/technician?job_id=${booking.job_id}`);
} catch (err: any) {
const message = err.message || 'Failed to submit intake. Verify inputs.';
setErrorMsg(message);
toast.error(message);
} finally {
setIsSubmitting(false);
}
};
const brandOptions = [
...brands.map((b) => ({ value: b.brand_id as string, label: b.name as string })),
{ value: 'OTHER', label: 'Other brand' },
];
const modelOptions = [
...filteredModels.map((m) => ({ value: m.model_id as string, label: m.name as string })),
{ value: 'OTHER', label: 'Other model' },
];
const serviceOptions = [
...catalog.map((s) => ({
value: s.service_id,
label: `${s.name} (${formatCurrency(s.base_price)})`,
})),
{ value: 'OTHER_SERVICE', label: 'Other / custom service' },
];
const dataCardShell =
'crm-radius-section border border-border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] overflow-visible min-w-0';
const inputClass =
'w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary disabled:opacity-50';
const labelClass = 'block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5';
const primaryButton =
'inline-flex items-center justify-center gap-2 h-9 min-h-9 px-4 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[13px] font-semibold cursor-pointer disabled:opacity-50 whitespace-nowrap';
const secondaryButton =
'inline-flex items-center justify-center gap-2 h-9 min-h-9 px-4 crm-radius-control border border-border bg-card text-foreground text-[13px] font-medium hover:bg-muted cursor-pointer disabled:opacity-50 whitespace-nowrap';
const sectionTitle = 'flex items-center gap-2 text-[14px] font-semibold text-foreground';
return (
<div className="flex flex-col gap-5 min-w-0">
<div className="flex flex-wrap items-start justify-between gap-3 min-w-0">
<div className="min-w-0">
<h1 className="text-lg font-semibold text-foreground">Walk-In Device Intake</h1>
</div>
<button
type="button"
onClick={async () => {
const { items, brandItems } = await loadCatalog();
resetForm(brandItems, items);
}}
className="w-8 h-8 crm-radius-control border border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center"
title="Reload catalog"
>
<RefreshCw className={`w-3.5 h-3.5 ${loadingCatalog ? 'animate-spin' : ''}`} />
</button>
</div>
<div className="w-full max-w-[760px] mx-auto min-w-0">
<form onSubmit={handleIntakeSubmit} className={`${dataCardShell} flex flex-col`}>
<div className="px-5 py-4 border-b border-border">
<h2 className="text-[16px] font-semibold text-foreground">Register intake</h2>
<p className="text-[13px] text-muted-foreground mt-1">
Capture the customer, device and service details to open a walk-in job.
</p>
</div>
<div className="px-5 py-5 space-y-6">
{errorMsg && (
<div className="flex items-start gap-2 px-3 py-2.5 border border-destructive/30 bg-destructive/5 crm-radius-section text-[13px] text-destructive">
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
<span>{errorMsg}</span>
</div>
)}
<section className="space-y-3">
<h3 className={sectionTitle}>
<User className="w-4 h-4 text-primary" />
Customer
</h3>
<div className="crm-radius-toggle inline-flex items-center gap-1 border border-border bg-card p-1">
<button
type="button"
onClick={() => setCustomerType('EXISTING')}
className={`h-8 px-3 crm-radius-toggle text-[13px] font-medium cursor-pointer transition-colors ${
customerType === 'EXISTING' ? 'bg-primary text-white' : 'text-foreground hover:bg-muted'
}`}
>
Existing customer
</button>
<button
type="button"
onClick={() => setCustomerType('NEW')}
className={`h-8 px-3 crm-radius-toggle text-[13px] font-medium cursor-pointer transition-colors ${
customerType === 'NEW' ? 'bg-primary text-white' : 'text-foreground hover:bg-muted'
}`}
>
New walk-in
</button>
</div>
{customerType === 'EXISTING' ? (
<div className="space-y-2 font-medium">
<div className="flex items-center justify-between">
<label className={labelClass}>
Select Existing CRM Customer <span className="text-primary">*</span>
</label>
<span className="text-[11px] text-muted-foreground">Filter by name, phone, or email</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<input
type="text"
placeholder="Type to filter customer list..."
value={customerSearchQuery}
onChange={(e) => setCustomerSearchQuery(e.target.value)}
className={inputClass}
/>
<select
required
value={customerId}
onChange={(e) => setCustomerId(e.target.value)}
className={`${inputClass} bg-card font-semibold`}
>
<option value="">-- Select Customer --</option>
{existingCustomers
.filter((c) => {
if (!customerSearchQuery) return true;
const q = customerSearchQuery.toLowerCase();
const name = `${c.first_name || ''} ${c.last_name || ''}`.toLowerCase();
const phone = (c.phone || '').toLowerCase();
const email = (c.email || '').toLowerCase();
return name.includes(q) || phone.includes(q) || email.includes(q);
})
.map((c) => (
<option key={c.customer_id} value={c.customer_id}>
{c.first_name} {c.last_name} ({c.phone || c.email || c.customer_id})
</option>
))}
</select>
</div>
{customerId && (
<div className="text-[11px] text-muted-foreground bg-muted/30 p-2 crm-radius-control border border-border">
Selected Customer ID: <span className="font-mono text-foreground font-semibold">{customerId}</span>
</div>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="md:col-span-2">
<label className={labelClass}>
Name <span className="text-primary">*</span>
</label>
<input
required
placeholder="e.g. John Doe"
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
className={inputClass}
/>
</div>
<PhoneInput
label="Phone"
value={customerPhone}
onChange={setCustomerPhone}
/>
<div>
<label className={labelClass}>Email</label>
<input
type="email"
placeholder="e.g. john@example.com"
value={customerEmail}
onChange={(e) => setCustomerEmail(e.target.value)}
className={inputClass}
/>
</div>
</div>
)}
</section>
<section className="space-y-3">
<h3 className={sectionTitle}>
<Smartphone className="w-4 h-4 text-primary" />
Device
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className={labelClass}>
Brand <span className="text-primary">*</span>
</label>
<CustomSelect
value={deviceDetails.brand}
onChange={(value) => setDeviceDetails({ ...deviceDetails, brand: value })}
options={brandOptions}
placeholder="Select brand"
aria-label="Brand"
/>
{deviceDetails.brand === 'OTHER' && (
<input
required
placeholder="Type brand name"
value={deviceDetails.custom_brand}
onChange={(e) => setDeviceDetails({ ...deviceDetails, custom_brand: e.target.value })}
className={`${inputClass} mt-2`}
/>
)}
</div>
<div>
<label className={labelClass}>
Model <span className="text-primary">*</span>
</label>
<CustomSelect
value={deviceDetails.model}
onChange={(value) => setDeviceDetails({ ...deviceDetails, model: value })}
options={modelOptions}
placeholder="Select model"
aria-label="Model"
/>
{deviceDetails.model === 'OTHER' && (
<input
required
placeholder="Type model name"
value={deviceDetails.custom_model}
onChange={(e) => setDeviceDetails({ ...deviceDetails, custom_model: e.target.value })}
className={`${inputClass} mt-2`}
/>
)}
</div>
<div>
<label className={labelClass}>Primary IMEI / serial</label>
<input
placeholder="15-digit IMEI or serial"
value={deviceDetails.imei_primary}
onChange={(e) => setDeviceDetails({ ...deviceDetails, imei_primary: e.target.value })}
className={`${inputClass} font-mono`}
/>
</div>
<div>
<label className={labelClass}>Color</label>
<input
placeholder="e.g. Natural Titanium"
value={deviceDetails.color}
onChange={(e) => setDeviceDetails({ ...deviceDetails, color: e.target.value })}
className={inputClass}
/>
</div>
<div className="md:col-span-2">
<label className={labelClass}>
Storage <span className="text-primary">*</span>
</label>
<input
required
placeholder="e.g. 128GB, 256GB"
value={deviceDetails.storage_capacity}
onChange={(e) => setDeviceDetails({ ...deviceDetails, storage_capacity: e.target.value })}
className={inputClass}
/>
</div>
</div>
</section>
<section className="space-y-3">
<h3 className={sectionTitle}>Service</h3>
<div>
<label className={labelClass}>
Diagnostics / repair service <span className="text-primary">*</span>
</label>
<CustomSelect
value={selectedServiceId}
onChange={setSelectedServiceId}
options={serviceOptions}
placeholder="Select service"
aria-label="Service"
/>
</div>
{selectedServiceId === 'OTHER_SERVICE' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 p-3 border border-border crm-radius-section bg-muted/10">
<div>
<label className={labelClass}>
Custom service name <span className="text-primary">*</span>
</label>
<input
required
placeholder="e.g. Motherboard IC Repair"
value={customServiceName}
onChange={(e) => setCustomServiceName(e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>
Estimate price () <span className="text-primary">*</span>
</label>
<input
type="number"
required
placeholder="e.g. 4500"
value={customServicePrice}
onChange={(e) => setCustomServicePrice(e.target.value)}
className={inputClass}
/>
</div>
</div>
)}
</section>
<section className="space-y-3">
<h3 className={sectionTitle}>
<ListTodo className="w-4 h-4 text-primary" />
Physical condition
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className={labelClass}>Power status</label>
<CustomSelect
value={intakeDetails.power_status}
onChange={(value) => setIntakeDetails({ ...intakeDetails, power_status: value })}
options={POWER_OPTIONS}
aria-label="Power status"
/>
</div>
<div>
<label className={labelClass}>Screen condition</label>
<CustomSelect
value={intakeDetails.screen_condition}
onChange={(value) => setIntakeDetails({ ...intakeDetails, screen_condition: value })}
options={SCREEN_OPTIONS}
aria-label="Screen condition"
/>
</div>
<div>
<label className={labelClass}>Body / frame</label>
<CustomSelect
value={intakeDetails.body_condition}
onChange={(value) => setIntakeDetails({ ...intakeDetails, body_condition: value })}
options={BODY_OPTIONS}
aria-label="Body condition"
/>
</div>
<div>
<label className={labelClass}>Accessories</label>
<input
placeholder="e.g. Case, SIM tray, box, charger"
value={intakeDetails.accessories}
onChange={(e) => setIntakeDetails({ ...intakeDetails, accessories: e.target.value })}
className={inputClass}
/>
</div>
</div>
</section>
</div>
<div className="shrink-0 border-t border-border px-5 py-3 flex items-center justify-end gap-2 bg-card">
<button
type="button"
onClick={() => resetForm()}
disabled={isSubmitting}
className={secondaryButton}
>
Clear
</button>
<button type="submit" disabled={isSubmitting || loadingCatalog} className={primaryButton}>
{isSubmitting ? (
<>
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
Registering...
</>
) : (
'Register intake'
)}
</button>
</div>
</form>
</div>
</div>
);
}