346 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|