Files
cavinid/src/App.tsx
T
2026-07-13 17:59:12 +05:30

1133 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useRef, useEffect } from "react";
import { toPng } from "html-to-image";
import JSZip from "jszip";
import { saveAs } from "file-saver";
import digifoxlogo from "../assets/images/digifox_logo.webp";
import Papa from "papaparse";
import {
Download,
Upload,
CheckCircle2,
ChevronRight,
HelpCircle,
Users,
ImagePlus,
} from "lucide-react";
import { Login } from "./components/Login";
import { AdminDashboard } from "./components/AdminDashboard";
import { Employee, Region } from "./types";
import cavinKareLogo from "../assets/images/CavinKare-logo-z3dMoof1.png";
import defaultBgImage from "../assets/images/id-card-bg-1.png";
const DEFAULT_REGIONS: Region[] = [
{
id: "assam",
name: "Assam",
address:
"INDUSTRIES GROWTH CENTRE, MATIA (MORNAI)\nOPPOSITE TO SAINIK SCHOOL, GOALPARA, ASSAM - 783 101.",
phone: "",
},
{
id: "bhiwandi",
name: "Bhiwandi Plant",
address:
"S.NO. 31/B / 32 A, DHAMANGAON OLD AGRA ROAD,\nTAL. - BHIWANDI, DISTT. - THANE, MUMBAI, MAHARASHTRA - 421 302.",
phone: "",
},
{
id: "erode",
name: "Erode",
address:
"SF NO. 532, BHAVANI TO ANTHIYUR MAIN ROAD, MYLAMPADI,\nKANNADIPALAYAM POST, BHAVANI TK, ERODE DT - 638 314.",
phone: "04256 - 238 202 / 302 / 402",
},
{
id: "haridwar",
name: "Haridwar",
address:
"PLOT NO.16 & 17,SECTOR-4, INTEGRATED INDUSTRIAL ESTATE,\nSIDCUL,RANIPUR, HARIDWAR-249403",
phone: "01334-239246",
},
{
id: "kanchipuram",
name: "Kanchipuram",
address: "NO. 18, ARYAPERUMBAKKAM VILLAGE, KANCHIPURAM - 631 502.",
phone: "2729 4580/81",
},
{
id: "pondy",
name: "Pondy",
address:
"R.S. NO. 81/4, KORKADU VILLAGE, NETTAPAKKAM COMMUNE,\nPONDICHERRY -605 110.",
phone: "0413 - 2665146",
},
{
id: "ro-east",
name: "RO East",
address:
"14TH FLOOR, P.S. SRIJAN TECH PARK, DN - 52, DN BLOCK, SECTOR - 5,\nSALT LAKE CITY, KOLKATA - 700091",
phone: "033 - 40669540",
},
{
id: "ro-north",
name: "RO North",
address: "D-12/ 2ND FLOOR, KAUSHAMBI, GHAZIABAD - 201010\nUTTAR PRADESH",
phone: "0120-4335396",
},
{
id: "ro-south",
name: "RO South",
address: "NO. 12, POONAMALLEE ROAD, EKKATTUTHANGAL,\nCHENNAI - 6OO 032.",
phone: "044 - 2225 1011 / 12",
},
{
id: "ro-west",
name: "RO West",
address:
"504-A, RAHEJA PLAZA PREMISES CO-OP SOCIETY LTD.,\nL.B.S. MARG, GHATKOPAR (WEST), MUMBAI – 400086",
phone: "",
},
];
export default function App() {
const [role, setRole] = useState<"login" | "user" | "admin">(() => {
const savedRole = localStorage.getItem("userRole");
return (savedRole as "login" | "user" | "admin") || "login";
});
useEffect(() => {
localStorage.setItem("userRole", role);
}, [role]);
const [regions, setRegions] = useState<Region[]>(DEFAULT_REGIONS);
const [employees, setEmployees] = useState<Employee[]>([]);
const [currentEmployee, setCurrentEmployee] = useState<Employee>({
id: crypto.randomUUID(),
name: "NAGAMUTHU A",
employeeId: "1016434",
unitId: "erode",
doj: "09/03/2026",
dob: "06/12/1999",
bloodGroup: "AB+",
emergencyNo: "9791238462",
photoUrl: null,
});
const [bgImage, setBgImage] = useState<string | null>(null);
const [isBatchMode, setIsBatchMode] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// 1. Fetch regions from server
fetch("/api/regions")
.then((res) => res.json())
.then((data) => {
if (data && data.length > 0) setRegions(data);
})
.catch((err) => console.error("Error fetching regions:", err));
// 2. Fetch global background setting
const localBg = localStorage.getItem("savedBgImage");
if (localBg) {
setBgImage(localBg);
} else {
fetch("/api/settings/globalBgImage")
.then((res) => res.json())
.then((data) => {
if (data.value) setBgImage(data.value);
})
.catch((err) => console.error("Error fetching global bg setting:", err));
}
}, []);
const currentRegion =
regions.find((r) => r.id === currentEmployee.unitId) || regions[0];
const logCreation = async (unitId: string, count: number) => {
try {
await fetch("/api/stats", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ unitId, count }),
});
} catch (err) {
console.error("Error logging card creation stats:", err);
}
};
const handleInputChange = (field: keyof Employee, value: any) => {
setCurrentEmployee((prev) => ({ ...prev, [field]: value }));
if (isBatchMode) {
setEmployees((emps) =>
emps.map((e) =>
e.id === currentEmployee.id ? { ...e, [field]: value } : e,
),
);
}
};
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = (event) => {
handleInputChange("photoUrl", event.target?.result as string);
};
reader.readAsDataURL(file);
}
};
const handleBgUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = (event) => {
setBgImage(event.target?.result as string);
};
reader.readAsDataURL(file);
}
};
const handlePhotoFolderUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
if (employees.length === 0) {
alert("Please import CSV first before uploading photos.");
return;
}
const files = Array.from(e.target.files) as File[];
const updatedEmployees = [...employees];
let matchedCount = 0;
files.forEach((file) => {
const lastDot = file.name.lastIndexOf(".");
const fileNameWithoutExt = (
lastDot !== -1 ? file.name.substring(0, lastDot) : file.name
).trim();
const normalizedFileName = fileNameWithoutExt
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
const employeeIndex = updatedEmployees.findIndex((emp) => {
const empId = String(emp.employeeId || "").trim();
const empIdNormalized = empId.toLowerCase().replace(/[^a-z0-9]/g, "");
const empNameNormalized = String(emp.name || "")
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
return (
(empId !== "" && empId === fileNameWithoutExt) ||
(empIdNormalized !== "" &&
empIdNormalized === normalizedFileName) ||
(empNameNormalized !== "" &&
empNameNormalized === normalizedFileName)
);
});
if (employeeIndex >= 0) {
updatedEmployees[employeeIndex].photoUrl = URL.createObjectURL(file);
matchedCount++;
}
});
setEmployees(updatedEmployees);
// update current employee if matched
const currIdx = updatedEmployees.findIndex(
(e) => e.id === currentEmployee.id,
);
if (currIdx >= 0) setCurrentEmployee(updatedEmployees[currIdx]);
alert(`Successfully matched ${matchedCount} photos to employees!`);
}
};
const handleCSVUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
Papa.parse(e.target.files[0], {
header: true,
complete: (results) => {
const newEmployees = results.data
.map((row: any) => {
const csvUnit = String(row.unitId || row.unit || row.region || "")
.trim()
.toLowerCase();
let matchedUnit = regions.find(
(r) =>
r.id.toLowerCase() === csvUnit ||
r.name.toLowerCase() === csvUnit,
);
if (!matchedUnit && csvUnit) {
matchedUnit = regions.find(
(r) =>
r.name.toLowerCase().includes(csvUnit) ||
csvUnit.includes(r.name.toLowerCase()),
);
}
const finalUnitId = matchedUnit
? matchedUnit.id
: regions[0]?.id || "erode";
return {
id: crypto.randomUUID(),
name: String(row.name || "").trim(),
employeeId: String(row.employeeId || "").trim(),
unitId: finalUnitId,
doj: String(row.doj || "").trim(),
dob: String(row.dob || "").trim(),
bloodGroup: String(row.bloodGroup || "").trim(),
emergencyNo: String(row.emergencyNo || "").trim(),
photoUrl: null,
};
})
.filter((e) => e.name);
if (newEmployees.length > 0) {
setEmployees(newEmployees);
setCurrentEmployee(newEmployees[0]);
setIsBatchMode(true);
}
},
});
}
};
const handleSave = () => {
localStorage.setItem(
"savedEmployeeDetails",
JSON.stringify(currentEmployee),
);
if (bgImage) {
localStorage.setItem("savedBgImage", bgImage);
}
if (isBatchMode) {
setEmployees((prev) =>
prev.map((e) => (e.id === currentEmployee.id ? currentEmployee : e)),
);
}
alert("Details saved successfully!");
};
const handleDownloadSampleCSV = () => {
const csvContent =
"name,employeeId,unit,doj,dob,bloodGroup,emergencyNo\nJohn Doe,101234,Erode,01/01/2023,15/08/1990,O+,9876543210\nJane Smith,101235,Chennai,15/06/2022,22/11/1992,A-,8765432109";
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
saveAs(blob, "sample_employees.csv");
};
const handleDownloadSingle = async () => {
if (!cardRef.current) return;
setIsGenerating(true);
try {
const dataUrl = await toPng(cardRef.current, { pixelRatio: 3 });
const link = document.createElement("a");
link.download = `${currentEmployee.name || "ID"}_${currentRegion.name}.png`;
link.href = dataUrl;
link.click();
logCreation(currentEmployee.unitId, 1);
} catch (err) {
console.error("Error generating card image:", err);
} finally {
setIsGenerating(false);
}
};
const generateBulkZip = async () => {
if (employees.length === 0 || !cardRef.current) return;
setIsGenerating(true);
try {
const zip = new JSZip();
const folder = zip.folder("ID_Cards");
// Save current employee to restore later
const originalEmployee = currentEmployee;
for (let i = 0; i < employees.length; i++) {
const emp = employees[i];
// 1. Update state to render this employee
setCurrentEmployee(emp);
// 2. Wait for React to render and images to load
await new Promise((resolve) => setTimeout(resolve, 150));
// 3. Generate PNG
const dataUrl = await toPng(cardRef.current, { pixelRatio: 3 });
const base64Data = dataUrl.split(",")[1];
// 4. Add to ZIP
const region = regions.find((r) => r.id === emp.unitId) || regions[0];
const fileName = `${emp.name || "ID"}_${region.name}.png`;
folder?.file(fileName, base64Data, { base64: true });
}
// Restore original employee
setCurrentEmployee(originalEmployee);
// Generate and download ZIP
const content = await zip.generateAsync({ type: "blob" });
saveAs(content, "Bulk_ID_Cards.zip");
// Log bulk creations
const counts: Record<string, number> = {};
employees.forEach((emp) => {
counts[emp.unitId] = (counts[emp.unitId] || 0) + 1;
});
Object.entries(counts).forEach(([unitId, count]) => {
logCreation(unitId, count);
});
} catch (err) {
console.error("Error generating ZIP:", err);
alert("Failed to generate ZIP file.");
} finally {
setIsGenerating(false);
}
};
if (role === "login") {
return <Login onLogin={setRole} />;
}
if (role === "admin") {
return (
<AdminDashboard
onLogout={() => setRole("login")}
regions={regions}
setRegions={setRegions}
/>
);
}
return (
<div className="w-full min-h-screen lg:h-screen bg-slate-50 flex flex-col text-slate-800 font-sans lg:overflow-hidden">
{/* Header Section */}
<header className="h-auto min-h-16 bg-white border-b border-slate-200 px-4 sm:px-8 py-3 sm:py-0 flex flex-col sm:flex-row items-center justify-between gap-4 shadow-sm shrink-0">
<div className="flex items-center gap-3">
<div className="w-12 h-12 flex items-center justify-center">
<img
src={cavinKareLogo}
alt="CavinKare Logo"
className="w-10 h-10 object-contain"
/>
</div>
<span className="font-bold text-xl tracking-tight text-black">
ID CARD <span className="text-blue-600">Maker</span>
</span>
</div>
<div className="hidden md:flex items-center gap-6">
<div
className={`flex items-center gap-2 text-sm font-medium ${true ? "text-blue-700" : "text-slate-500"}`}
>
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs ${true ? "bg-blue-700 text-white" : "bg-slate-200 text-slate-600"}`}
>
1
</span>{" "}
Entry
</div>
<div className="w-8 h-px bg-slate-300"></div>
<div
className={`flex items-center gap-2 text-sm font-medium ${currentEmployee.photoUrl ? "text-blue-700" : "text-slate-400"}`}
>
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs ${currentEmployee.photoUrl ? "bg-blue-700 text-white" : "bg-slate-200 text-slate-600"}`}
>
2
</span>{" "}
Photo
</div>
<div className="w-8 h-px bg-slate-300"></div>
<div
className={`flex items-center gap-2 text-sm font-medium ${currentEmployee.name ? "text-slate-900" : "text-slate-400"}`}
>
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs ${currentEmployee.name ? "bg-slate-900 text-white" : "bg-slate-200 text-slate-600"}`}
>
3
</span>{" "}
Export
</div>
</div>
<div className="flex items-center gap-4">
<button className="text-sm font-medium text-slate-600 hover:text-slate-900 flex items-center gap-1">
<HelpCircle className="w-4 h-4" /> <span className="hidden sm:inline">Help & Support</span>
</button>
<button
onClick={() => setRole("login")}
className="text-sm font-bold text-red-500 hover:text-red-700 bg-red-50 px-3 py-1.5 rounded-md transition-colors"
>
Logout
</button>
</div>
</header>
{/* Main Content Body */}
<main className="flex-1 flex flex-col lg:flex-row lg:overflow-hidden">
{/* Sidebar Configuration */}
<aside className="w-full lg:w-[380px] bg-white border-b lg:border-b-0 lg:border-r border-slate-200 flex flex-col p-6 lg:p-8 shrink-0 overflow-y-visible lg:overflow-y-auto">
<div className="mb-8">
<h2 className="text-lg font-semibold mb-1 text-slate-900">
Personnel Information
</h2>
<p className="text-sm text-slate-500">
Fill out the details for the credential.
</p>
</div>
<div className="space-y-5">
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider">
Background Template
</label>
{bgImage && (
<button
onClick={() => setBgImage(null)}
className="text-[10px] font-bold text-red-500 hover:text-red-700 uppercase tracking-wider"
>
Clear Image
</button>
)}
</div>
<label className="border-2 border-dashed border-slate-200 rounded-lg p-6 flex flex-col items-center justify-center bg-slate-50 cursor-pointer hover:bg-slate-100 transition-colors relative overflow-hidden h-32">
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleBgUpload}
/>
{bgImage ? (
<div className="absolute inset-0 p-2">
<img
src={bgImage}
alt="Background Preview"
className="w-full h-full object-cover rounded shadow-sm opacity-50"
/>
<div className="absolute inset-0 flex items-center justify-center flex-col z-10 text-slate-800 drop-shadow-md font-bold">
<ImagePlus className="w-8 h-8 mb-2" />
<span className="text-xs uppercase tracking-widest">
Replace Template
</span>
</div>
</div>
) : (
<>
<ImagePlus className="w-8 h-8 text-slate-400 mb-2" />
<span className="text-xs font-medium text-slate-600">
Click to upload background template
</span>
<span className="text-[10px] text-slate-400 mt-1">
PNG, JPG up to 5MB
</span>
</>
)}
</label>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">
Unit / Region
</label>
<select
value={currentEmployee.unitId}
onChange={(e) => handleInputChange("unitId", e.target.value)}
className="w-full border border-slate-300 rounded-md p-2.5 text-sm bg-slate-50 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{regions.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">
Full Name
</label>
<input
type="text"
value={currentEmployee.name}
onChange={(e) =>
handleInputChange("name", e.target.value.toUpperCase())
}
className="w-full border border-slate-300 rounded-md p-2 text-sm focus:ring-2 focus:ring-blue-500 uppercase"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">
Employee ID
</label>
<input
type="text"
value={currentEmployee.employeeId}
onChange={(e) =>
handleInputChange(
"employeeId",
e.target.value.toUpperCase(),
)
}
className="w-full border border-slate-300 rounded-md p-2 text-sm focus:ring-2 focus:ring-blue-500 uppercase"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">
Blood Group
</label>
<input
type="text"
value={currentEmployee.bloodGroup}
onChange={(e) =>
handleInputChange(
"bloodGroup",
e.target.value.toUpperCase(),
)
}
className="w-full border border-slate-300 rounded-md p-2 text-sm focus:ring-2 focus:ring-blue-500 uppercase"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">
D.O.J
</label>
<input
type="text"
value={currentEmployee.doj}
onChange={(e) => handleInputChange("doj", e.target.value)}
className="w-full border border-slate-300 rounded-md p-2 text-sm focus:ring-2 focus:ring-blue-500"
placeholder="DD/MM/YYYY"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">
D.O.B
</label>
<input
type="text"
value={currentEmployee.dob}
onChange={(e) => handleInputChange("dob", e.target.value)}
className="w-full border border-slate-300 rounded-md p-2 text-sm focus:ring-2 focus:ring-blue-500"
placeholder="DD/MM/YYYY"
/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">
Emergency No
</label>
<input
type="text"
value={currentEmployee.emergencyNo}
onChange={(e) =>
handleInputChange("emergencyNo", e.target.value)
}
className="w-full border border-slate-300 rounded-md p-2 text-sm focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">
Photo Upload
</label>
<label className="border-2 border-dashed border-slate-200 rounded-lg p-6 flex flex-col items-center justify-center bg-slate-50 cursor-pointer hover:bg-slate-100 transition-colors relative">
<input
type="file"
accept="image/*"
className="hidden"
onChange={handlePhotoUpload}
/>
{currentEmployee.photoUrl ? (
<div className="absolute inset-0 p-2">
<img
src={currentEmployee.photoUrl}
alt="Preview"
className="w-full h-full object-cover rounded shadow-sm opacity-50"
/>
<div className="absolute inset-0 flex items-center justify-center flex-col z-10 text-slate-800 font-bold drop-shadow-md">
<ImagePlus className="w-8 h-8 mb-2" />
<span className="text-xs font-bold uppercase tracking-widest">
Replace Photo
</span>
</div>
</div>
) : (
<>
<ImagePlus className="w-8 h-8 text-slate-400 mb-2" />
<span className="text-xs font-medium text-slate-600">
Click to upload portrait
</span>
<span className="text-[10px] text-slate-400 mt-1">
PNG, JPG up to 5MB
</span>
</>
)}
</label>
{currentEmployee.photoUrl && (
<div className="mt-3 p-3 bg-slate-50 rounded-lg border border-slate-100 space-y-3">
<div className="flex justify-between items-center mb-1">
<span className="text-xs font-bold text-slate-500 uppercase">
Adjust Photo
</span>
</div>
<div>
<label className="flex justify-between text-[10px] text-slate-500 mb-1">
<span>Zoom</span>
<span>
{Math.round((currentEmployee.photoScale || 1) * 100)}%
</span>
</label>
<input
type="range"
min="0.5"
max="3"
step="0.1"
value={currentEmployee.photoScale || 1}
onChange={(e) =>
handleInputChange(
"photoScale",
parseFloat(e.target.value),
)
}
className="w-full accent-blue-600"
/>
</div>
<div className="flex gap-2">
<div className="flex-1">
<label className="flex justify-between text-[10px] text-slate-500 mb-1">
<span>X Offset</span>
<span>{currentEmployee.photoOffsetX || 0}px</span>
</label>
<input
type="range"
min="-100"
max="100"
step="1"
value={currentEmployee.photoOffsetX || 0}
onChange={(e) =>
handleInputChange(
"photoOffsetX",
parseInt(e.target.value),
)
}
className="w-full accent-blue-600"
/>
</div>
<div className="flex-1">
<label className="flex justify-between text-[10px] text-slate-500 mb-1">
<span>Y Offset</span>
<span>{currentEmployee.photoOffsetY || 0}px</span>
</label>
<input
type="range"
min="-100"
max="100"
step="1"
value={currentEmployee.photoOffsetY || 0}
onChange={(e) =>
handleInputChange(
"photoOffsetY",
parseInt(e.target.value),
)
}
className="w-full accent-blue-600"
/>
</div>
</div>
</div>
)}
</div>
{/* Batch Upload Section */}
<div className="pt-4 border-t border-slate-100">
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">
Batch Import (Optional)
</label>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<label className="flex-1 bg-slate-100 text-slate-600 border border-slate-200 hover:bg-slate-200 px-3 py-2 rounded text-sm font-medium cursor-pointer flex items-center justify-center gap-2 transition-colors">
<input
type="file"
accept=".csv"
className="hidden"
onChange={handleCSVUpload}
/>
<Upload className="w-4 h-4 shrink-0" /> Import CSV
</label>
<button
onClick={handleDownloadSampleCSV}
className="flex-1 bg-slate-50 text-slate-600 border border-slate-200 hover:bg-slate-100 px-3 py-2 rounded text-sm font-medium flex items-center justify-center gap-2 transition-colors"
>
<Download className="w-4 h-4 shrink-0" /> Sample CSV
</button>
{employees.length > 0 && (
<div className="bg-green-50 text-green-700 border border-green-200 px-3 py-2 rounded text-sm font-medium flex items-center justify-center gap-1">
<CheckCircle2 className="w-4 h-4 shrink-0" />{" "}
{employees.length} Loaded
</div>
)}
</div>
{employees.length > 0 && (
<div className="flex items-center gap-2">
<label className="flex-1 bg-blue-50 text-blue-700 border border-blue-200 hover:bg-blue-100 px-3 py-2 rounded text-sm font-medium cursor-pointer flex items-center justify-center gap-2 transition-colors">
<input
type="file"
accept="image/*"
className="hidden"
{...({ webkitdirectory: "", directory: "" } as any)}
onChange={handlePhotoFolderUpload}
/>
<Upload className="w-4 h-4 shrink-0" /> Upload Photos
Folder
</label>
</div>
)}
</div>
<p className="text-[10px] text-slate-400 mt-2 leading-tight">
1. Import CSV (Headers: name, employeeId, unitId, doj, dob,
bloodGroup, emergencyNo)
<br />
2. Upload a folder containing photos named by{" "}
<strong>EMP ID</strong> (e.g. 1016434.jpg) or{" "}
<strong>Name</strong>.
</p>
</div>
</div>
<div className="mt-auto pt-8 flex gap-2">
<button
onClick={() => {
handleInputChange("photoScale", 1);
handleInputChange("photoOffsetX", 0);
handleInputChange("photoOffsetY", 0);
}}
className="flex-1 bg-slate-100 text-slate-700 py-2.5 rounded-md font-semibold text-xs border border-slate-200 hover:bg-slate-200"
>
Reset
</button>
<button
onClick={handleSave}
className="flex-[1.5] bg-green-600 text-white py-2.5 rounded-md font-semibold text-xs hover:bg-green-700 shadow-md flex items-center justify-center gap-1"
>
<CheckCircle2 className="w-4 h-4" /> Save Details
</button>
<button
onClick={handleDownloadSingle}
disabled={isGenerating}
className="flex-[1.5] bg-blue-700 text-white py-2.5 rounded-md font-semibold text-xs hover:bg-blue-800 shadow-lg shadow-blue-200 flex items-center justify-center gap-1 disabled:opacity-70 disabled:cursor-wait"
>
{isGenerating ? (
"Wait..."
) : (
<>
<Download className="w-4 h-4" /> Download
</>
)}
</button>
</div>
</aside>
{/* Preview Canvas */}
<section className="flex-1 bg-slate-200 flex flex-col items-center justify-center p-4 sm:p-8 lg:p-12 overflow-y-visible lg:overflow-y-auto min-h-[500px] lg:min-h-0">
<div className="relative origin-center scale-[0.75] min-[450px]:scale-[0.9] sm:scale-100 transition-transform duration-200 my-8 sm:my-0">
{/* Floating Badge Label */}
<div className="absolute -top-12 left-1/2 -translate-x-1/2 bg-white px-4 py-1 rounded-full shadow-sm border border-slate-300 text-[10px] font-bold text-slate-500 uppercase tracking-widest flex items-center gap-2 z-10 whitespace-nowrap">
<span className="w-2 h-2 rounded-full bg-red-500 animate-pulse"></span>{" "}
Live Real-time Preview
</div>
{/* The ID Card */}
<div
ref={cardRef}
className="w-[420px] h-[670px] rounded-md flex flex-col overflow-hidden relative font-sans"
style={{
backgroundImage: bgImage ? `url(${bgImage})` : `url(${defaultBgImage})`,
backgroundSize: "100% 100%",
backgroundPosition: "center",
backgroundColor: "transparent",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)",
fontFamily: "Arial, Helvetica, sans-serif",
}}
>
{/* Top empty space for the logo in the background image */}
<div className="h-[120px] w-full shrink-0"></div>
{/* Card Content - Fields aligned as in the template */}
<div className="flex-1 px-8 flex flex-col pt-4 relative z-10 space-y-[22px]">
<div
className="h-[22px] relative text-[15px] font-black leading-tight w-full"
style={{ color: "#222222" }}
>
<div className="absolute bottom-0 left-0 w-[140px] uppercase">
NAME
</div>
<div className="absolute bottom-0 left-[140px] w-4">:</div>
<div
className="absolute bottom-0 left-[156px] right-0 uppercase break-words"
style={{ wordBreak: "break-word" }}
>
{currentEmployee.name || " "}
</div>
</div>
<div
className="h-[22px] relative text-[15px] font-black leading-tight w-full"
style={{ color: "#222222" }}
>
<div className="absolute bottom-0 left-0 w-[140px] uppercase">
EMP ID
</div>
<div className="absolute bottom-0 left-[140px] w-4">:</div>
<div
className="absolute bottom-0 left-[156px] right-0 uppercase break-words"
style={{ wordBreak: "break-word" }}
>
{currentEmployee.employeeId || " "}
</div>
</div>
<div
className="h-[22px] relative text-[15px] font-black leading-tight w-full"
style={{ color: "#222222" }}
>
<div className="absolute bottom-0 left-0 w-[140px] uppercase">
UNIT
</div>
<div className="absolute bottom-0 left-[140px] w-4">:</div>
<div
className="absolute bottom-0 left-[156px] right-0 uppercase break-words"
style={{ wordBreak: "break-word" }}
>
{currentRegion.name.toUpperCase()}
</div>
</div>
<div
className="h-[22px] relative text-[15px] font-black leading-tight w-full"
style={{ color: "#222222" }}
>
<div className="absolute bottom-0 left-0 w-[140px] uppercase">
D.O.J
</div>
<div className="absolute bottom-0 left-[140px] w-4">:</div>
<div
className="absolute bottom-0 left-[156px] right-0 uppercase break-words"
style={{ wordBreak: "break-word" }}
>
{currentEmployee.doj || " "}
</div>
</div>
<div
className="h-[22px] relative text-[15px] font-black leading-tight w-full"
style={{ color: "#222222" }}
>
<div className="absolute bottom-0 left-0 w-[140px] uppercase">
D.O.B
</div>
<div className="absolute bottom-0 left-[140px] w-4">:</div>
<div
className="absolute bottom-0 left-[156px] right-0 uppercase break-words"
style={{ wordBreak: "break-word" }}
>
{currentEmployee.dob || " "}
</div>
</div>
<div
className="h-[22px] relative text-[15px] font-black leading-tight w-full"
style={{ color: "#222222" }}
>
<div className="absolute bottom-0 left-0 w-[140px] uppercase">
BLOOD GROUP
</div>
<div className="absolute bottom-0 left-[140px] w-4">:</div>
<div
className="absolute bottom-0 left-[156px] right-0 uppercase break-words"
style={{ wordBreak: "break-word" }}
>
{currentEmployee.bloodGroup || " "}
</div>
</div>
<div
className="h-[22px] relative text-[15px] font-black leading-tight w-full"
style={{ color: "#222222" }}
>
<div className="absolute bottom-0 left-0 w-[140px] uppercase">
EMERGENCY NO
</div>
<div className="absolute bottom-0 left-[140px] w-4">:</div>
<div
className="absolute bottom-0 left-[156px] right-0 uppercase break-words"
style={{ wordBreak: "break-word" }}
>
{currentEmployee.emergencyNo || " "}
</div>
</div>
</div>
{/* Bottom Section: Photo, Signature, Company Address */}
<div className="h-[210px] w-full relative shrink-0 mt-2">
{/* Photo Square */}
<div
className="absolute bottom-[90px] left-8 w-[125px] h-[155px] border-2 overflow-hidden z-20 flex items-center justify-center"
style={{
backgroundColor: "#e2e8f0",
borderColor: "#ffffff",
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
}}
>
{currentEmployee.photoUrl ? (
<img
src={currentEmployee.photoUrl}
alt="Employee"
className="w-full h-full absolute"
style={{
objectFit: "cover",
objectPosition: `calc(50% + ${currentEmployee.photoOffsetX || 0}px) calc(50% + ${currentEmployee.photoOffsetY || 0}px)`,
transform: `scale(${currentEmployee.photoScale || 1})`,
}}
/>
) : (
<div
className="w-full h-full flex flex-col items-center justify-center relative z-10"
style={{ backgroundColor: "#f1f5f9" }}
>
<Users
className="w-10 h-10"
style={{ color: "#94a3b8", opacity: 0.5 }}
/>
</div>
)}
</div>
{/* Company Address Section */}
<div className="absolute bottom-2 left-0 w-full h-[75px] text-center px-1 flex flex-col items-center justify-start">
<h2
className="text-[19px] font-black mb-[2px]"
style={{ color: "#111111" }}
>
CAVINKARE PRIVATE LIMITED
</h2>
<p
className="text-[12px] font-bold leading-[1.2] whitespace-pre tracking-tight"
style={{ color: "#111111" }}
>
{currentRegion.address}
</p>
{currentRegion.phone && (
<p
className="text-[12.5px] font-bold mt-[2px] tracking-tight"
style={{ color: "#111111" }}
>
PHONE : {currentRegion.phone}
</p>
)}
</div>
</div>
</div>
{/* Controls */}
{isBatchMode ? (
<div className="mt-8 flex flex-wrap justify-center gap-3 sm:gap-4">
<button
onClick={() => {
const idx = employees.findIndex(
(e) => e.id === currentEmployee.id,
);
if (idx > 0) setCurrentEmployee(employees[idx - 1]);
}}
disabled={
employees.findIndex((e) => e.id === currentEmployee.id) ===
0
}
className="px-4 py-3 bg-white rounded-lg shadow-sm border border-slate-300 text-sm font-bold text-slate-700 disabled:opacity-50"
>
Prev
</button>
<div className="px-4 py-3 bg-white rounded-lg shadow-sm border border-slate-300 text-sm font-bold text-slate-700">
{employees.findIndex((e) => e.id === currentEmployee.id) + 1}{" "}
of {employees.length}
</div>
<button
onClick={() => {
const idx = employees.findIndex(
(e) => e.id === currentEmployee.id,
);
if (idx < employees.length - 1)
setCurrentEmployee(employees[idx + 1]);
}}
disabled={
employees.findIndex((e) => e.id === currentEmployee.id) ===
employees.length - 1
}
className="px-4 py-3 bg-white rounded-lg shadow-sm border border-slate-300 text-sm font-bold text-slate-700 disabled:opacity-50"
>
Next
</button>
<button
onClick={generateBulkZip}
disabled={isGenerating}
className="px-6 py-3 bg-blue-700 rounded-lg shadow-lg border border-blue-800 text-sm font-bold text-white flex items-center gap-2 disabled:opacity-75"
>
<Download className="w-4 h-4" />{" "}
{isGenerating ? "Generating ZIP..." : "Export All (ZIP)"}
</button>
</div>
) : (
<div className="mt-8 flex flex-wrap justify-center gap-3 sm:gap-4">
<button
onClick={handleDownloadSingle}
className="px-6 py-3 bg-white rounded-lg shadow-sm border border-slate-300 text-sm font-bold text-slate-700 flex items-center gap-2 hover:bg-slate-50 transition-colors"
>
<Download className="w-4 h-4" /> Download Digital
</button>
<button className="px-6 py-3 bg-blue-700 rounded-lg shadow-lg border border-blue-800 text-sm font-bold text-white flex items-center gap-2 hover:bg-blue-800 transition-colors">
<Users className="w-4 h-4" /> Print Bulk Orders
</button>
</div>
)}
</div>
</section>
</main>
{/* Footer Instruction Area */}
<footer className="h-auto min-h-12 bg-slate-900 text-slate-400 flex flex-col md:flex-row items-center px-6 py-3 md:py-0 text-[11px] gap-4 md:gap-8 shrink-0 text-center md:text-left">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>{" "}
App Engine Active
</div>
<div className="flex items-center gap-2">
<strong className="text-slate-300">Tip:</strong> <span className="text-center">For batch processing, select the 'Import CSV' option to generate cards for multiple users simultaneously.</span>
</div>
<div className="md:ml-auto flex items-center gap-2 md:gap-4">
<span className="font-semibold text-slate-300">Powered by </span>
<img
src={digifoxlogo}
alt="DigiFox Logo"
className="w-16 h-10 object-contain"
/>
</div>
</footer>
</div>
);
}