Files
GSB-Construction/Modules/UserManagement/resources/js/Pages/Users/BulkImport.tsx

346 lines
20 KiB
TypeScript

import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, router, usePage } from '@inertiajs/react';
import { Badge } from '@/Components/ui/badge';
import { Button } from '@/Components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/Components/ui/select';
import { Separator } from '@/Components/ui/separator';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/Components/ui/table';
import { Contractor, PageProps } from '@/types';
import {
Upload, CheckCircle2, XCircle, AlertTriangle, Copy, FileSpreadsheet, ArrowLeft, Send, UserPlus, Building2,
} from 'lucide-react';
import { FormEvent, useRef, useState } from 'react';
interface CreatedUser {
name: string;
email: string;
role: string;
password: string;
}
interface Results {
created: CreatedUser[];
errors: string[];
}
interface Props extends PageProps {
results?: Results;
contractors: Contractor[];
}
export default function BulkImport({ results, contractors }: Props) {
const { auth } = usePage<PageProps>().props;
const isPlatformAdmin = auth.user.contractor_id === null;
const fileRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [inviteMethod, setInviteMethod] = useState<'direct' | 'email'>('direct');
const [selectedContractor, setSelectedContractor] = useState<string>('none');
const [dragging, setDragging] = useState(false);
const [processing, setProcessing] = useState(false);
const [copied, setCopied] = useState<string | null>(null);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
const dropped = e.dataTransfer.files[0];
if (dropped && (dropped.name.endsWith('.csv') || dropped.type === 'text/plain')) {
setFile(dropped);
}
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!file) return;
setProcessing(true);
const formData = new FormData();
formData.append('file', file);
formData.append('invite_method', inviteMethod);
if (isPlatformAdmin && selectedContractor !== 'none') {
formData.append('contractor_id', selectedContractor);
}
router.post(route('users.bulk-import'), formData, {
forceFormData: true,
onFinish: () => setProcessing(false),
});
};
const copyToClipboard = (text: string, key: string) => {
navigator.clipboard.writeText(text);
setCopied(key);
setTimeout(() => setCopied(null), 2000);
};
const copyAllCredentials = () => {
if (!results) return;
const text = results.created
.map((u) => `${u.name} | ${u.email} | ${u.role} | ${u.password}`)
.join('\n');
navigator.clipboard.writeText(text);
setCopied('all');
setTimeout(() => setCopied(null), 2000);
};
return (
<AuthenticatedLayout
header={
<div className="flex items-center gap-3">
<Link href={route('users.index')}>
<Button variant="ghost" size="icon-sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<FileSpreadsheet className="h-5 w-5 text-blue-500" />
<h2 className="text-xl font-semibold leading-tight text-gray-800">
Bulk Import Team Members
</h2>
</div>
}
>
<Head title="Bulk Import Users" />
<div className="py-6">
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 space-y-6">
{/* Upload Card */}
{!results && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" /> Upload CSV File
</CardTitle>
<p className="text-sm text-gray-500 mt-1">
CSV must have columns: <code className="rounded bg-gray-100 px-1 text-xs">Name, Email, Role, Department, Position</code>
</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-5">
{/* Contractor picker for platform admins */}
{isPlatformAdmin && contractors.length > 0 && (
<div className="rounded-lg border border-blue-200 bg-blue-50/60 p-4">
<Label className="mb-1.5 flex items-center gap-2 text-sm font-medium text-blue-800">
<Building2 className="h-4 w-4" />
Assign to Contractor
</Label>
<Select
value={selectedContractor}
onValueChange={(v) => setSelectedContractor(v || 'none')}
items={[
{ value: 'none', label: '— No contractor (platform users) —' },
...contractors.map((c) => ({ value: String(c.id), label: c.company_name })),
]}
>
<SelectTrigger id="bulk_contractor_id">
<SelectValue placeholder="— No contractor (platform users) —" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> No contractor (platform users) </SelectItem>
{contractors.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.company_name}
<span className="ml-2 text-xs text-gray-400 capitalize">({c.type})</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-blue-600">All imported users will be assigned to this contractor.</p>
</div>
)}
{/* Drop Zone */}
<div
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => fileRef.current?.click()}
className={`cursor-pointer rounded-xl border-2 border-dashed px-6 py-12 text-center transition-all ${
dragging ? 'border-blue-400 bg-blue-50' : 'border-gray-200 bg-gray-50 hover:border-gray-300 hover:bg-gray-100'
}`}
>
<input
ref={fileRef}
type="file"
accept=".csv,text/plain"
className="hidden"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
{file ? (
<div className="space-y-1">
<CheckCircle2 className="mx-auto h-8 w-8 text-emerald-500" />
<p className="font-medium text-gray-800">{file.name}</p>
<p className="text-xs text-gray-500">{(file.size / 1024).toFixed(1)} KB Click to change</p>
</div>
) : (
<div className="space-y-2">
<FileSpreadsheet className="mx-auto h-10 w-10 text-gray-300" />
<p className="text-sm font-medium text-gray-600">Drop your CSV here or click to browse</p>
<p className="text-xs text-gray-400">Supports .csv files up to 2MB</p>
</div>
)}
</div>
{/* Invite Method */}
<div>
<Label className="mb-2 block text-sm font-medium">Delivery Method</Label>
<div className="grid grid-cols-2 gap-3">
{(['direct', 'email'] as const).map((method) => (
<button
key={method}
type="button"
onClick={() => setInviteMethod(method)}
className={`flex flex-col items-center gap-2 rounded-lg border-2 p-4 text-sm transition-all ${
inviteMethod === method
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-200 text-gray-600 hover:border-gray-300'
}`}
>
{method === 'direct'
? <><UserPlus className="h-5 w-5" /><span className="font-medium">Direct Creation</span><span className="text-xs opacity-70">Show passwords in summary</span></>
: <><Send className="h-5 w-5" /><span className="font-medium">Email Invite</span><span className="text-xs opacity-70">Send credentials via email</span></>
}
</button>
))}
</div>
</div>
{/* CSV format hint */}
<div className="rounded-lg border border-blue-100 bg-blue-50 p-3">
<p className="mb-1 text-xs font-semibold text-blue-700">Expected CSV Format</p>
<code className="text-xs text-blue-600">
Name,Email,Role,Department,Position<br />
John Doe,john@example.com,employee,Engineering,Engineer<br />
Jane Smith,jane@example.com,employee,Operations,Manager
</code>
</div>
<Button type="submit" disabled={!file || processing} className="w-full">
<Upload className="mr-2 h-4 w-4" />
{processing ? 'Importing...' : 'Import Team Members'}
</Button>
</form>
</CardContent>
</Card>
)}
{/* Results */}
{results && (
<div className="space-y-4">
{/* Summary Stats */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-center">
<p className="text-2xl font-bold text-emerald-700">{results.created.length}</p>
<p className="text-xs text-emerald-600">Users Created</p>
</div>
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-center">
<p className="text-2xl font-bold text-red-600">{results.errors.length}</p>
<p className="text-xs text-red-500">Errors / Skipped</p>
</div>
<div className="col-span-2 sm:col-span-1 flex items-center justify-center">
<Link href={route('users.bulk-import.form')}>
<Button variant="outline" size="sm">
<Upload className="mr-2 h-4 w-4" /> Import Another File
</Button>
</Link>
</div>
</div>
{/* Created Users */}
{results.created.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
Successfully Created ({results.created.length})
</CardTitle>
<Button variant="outline" size="sm" onClick={copyAllCredentials}>
<Copy className="mr-1.5 h-3.5 w-3.5" />
{copied === 'all' ? 'Copied!' : 'Copy All'}
</Button>
</div>
<p className="text-xs text-gray-500">
All users will be required to change their password on first login.
</p>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Temp Password</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{results.created.map((u, i) => (
<TableRow key={i}>
<TableCell className="font-medium">{u.name}</TableCell>
<TableCell className="text-sm text-gray-600">{u.email}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">{u.role}</Badge>
</TableCell>
<TableCell>
<code className="rounded bg-gray-100 px-2 py-0.5 text-xs">
{u.password}
</code>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon-sm"
title="Copy credentials"
onClick={() => copyToClipboard(`${u.email} / ${u.password}`, u.email)}
>
<Copy className={`h-3.5 w-3.5 ${copied === u.email ? 'text-emerald-500' : ''}`} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Errors */}
{results.errors.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-5 w-5 text-amber-500" />
Skipped Rows ({results.errors.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{results.errors.map((err, i) => (
<div key={i} className="flex items-start gap-2 rounded-lg bg-red-50 p-3">
<XCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
<p className="text-sm text-red-700">{err}</p>
</div>
))}
</CardContent>
</Card>
)}
<div className="flex justify-end">
<Link href={route('users.index')}>
<Button>View All Users</Button>
</Link>
</div>
</div>
)}
</div>
</div>
</AuthenticatedLayout>
);
}