Initial commit: tinovisas (visa management app)

This commit is contained in:
devyouz
2026-05-18 23:25:10 +00:00
commit ab0d059a63
101 changed files with 15822 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
.next
.env
*.log
+16
View File
@@ -0,0 +1,16 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/package*.json ./
RUN npm install --production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+14
View File
@@ -0,0 +1,14 @@
/** @type {import(next).NextConfig} */
const nextConfig = {
output: "standalone",
images: { unoptimized: true },
async rewrites() {
return [
{
source: "/api/:path*",
destination: "http://backend:4000/api/:path*"
}
];
}
};
module.exports = nextConfig;
+6643
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
{"name":"tinovisas-frontend","version":"1.0.0","private":true,"scripts":{"dev":"next dev -p 3000","build":"next build","start":"next start -p 3000","lint":"next lint"},"dependencies":{"next":"^14.0.4","react":"^18.2.0","react-dom":"^18.2.0","axios":"^1.6.2","tailwindcss":"^3.3.6","postcss":"^8.4.32","autoprefixer":"^10.4.16","@headlessui/react":"^1.7.17","@heroicons/react":"^2.0.18","clsx":"^2.0.0","date-fns":"^2.30.0","react-toastify":"^9.1.3","recharts":"^2.10.3","lucide-react":"^0.294.0"},"devDependencies":{"@types/node":"^20.10.4","@types/react":"^18.2.43","@types/react-dom":"^18.2.17","typescript":"^5.3.3","eslint":"^8.55.0","eslint-config-next":"^14.0.4"}}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
+311
View File
@@ -0,0 +1,311 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "../../hooks/useAuth";
import Sidebar from "../../components/Sidebar";
import Header from "../../components/Header";
import Pagination from "../../components/Pagination";
import { adminAPI, auditAPI } from "../../lib/api";
import { toast } from "react-toastify";
import {
Shield, Users, Activity, FileText, BarChart3,
UserCheck, UserX, Trash2, Settings as SettingsIcon
} from "lucide-react";
export default function AdminPage() {
const router = useRouter();
const { user, loading, isAdmin } = useAuth();
const [stats, setStats] = useState<any>(null);
const [users, setUsers] = useState<any[]>([]);
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [auditMeta, setAuditMeta] = useState<any>(null);
const [auditPage, setAuditPage] = useState(1);
const [activeTab, setActiveTab] = useState("overview");
useEffect(() => { if (!loading && !user) router.push("/login"); }, [user, loading, router]);
useEffect(() => { if (user && !isAdmin) router.push("/dashboard"); }, [user, isAdmin, router]);
useEffect(() => {
if (!isAdmin) return;
fetchDashboard();
fetchUsers();
fetchAuditLogs();
}, [isAdmin]);
const fetchDashboard = async () => {
try {
const { data } = await adminAPI.getDashboard();
setStats(data.data);
} catch {
toast.error("Failed to load dashboard stats");
}
};
const fetchUsers = async () => {
try {
const { data } = await adminAPI.getUsers();
setUsers(data.data || []);
} catch {
toast.error("Failed to load users");
}
};
const fetchAuditLogs = async () => {
try {
const { data } = await auditAPI.getAll({ page: auditPage, limit: 20 });
setAuditLogs(data.data || []);
setAuditMeta(data.meta);
} catch {
toast.error("Failed to load audit logs");
}
};
const handleToggleUser = async (id: string) => {
try {
await adminAPI.toggleActive(id);
toast.success("User status updated");
fetchUsers();
} catch {
toast.error("Failed to update user");
}
};
const handleDeleteUser = async (id: string) => {
if (!confirm("Delete this user?")) return;
try {
await adminAPI.deleteUser(id);
toast.success("User deleted");
fetchUsers();
} catch {
toast.error("Failed to delete user");
}
};
if (loading || !user || !isAdmin) return null;
const tabs = [
{ id: "overview", label: "Overview", icon: BarChart3 },
{ id: "users", label: "Users", icon: Users },
{ id: "audit", label: "Audit Logs", icon: FileText },
{ id: "system", label: "System", icon: SettingsIcon }
];
return (
<div className="flex min-h-screen bg-dark-900">
<Sidebar />
<div className="flex-1 flex flex-col">
<Header />
<main className="flex-1 p-6 overflow-y-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<Shield className="w-6 h-6 text-accent-purple" />
Admin Panel
</h1>
<p className="text-gray-500">System management and monitoring</p>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-6 bg-dark-800 p-1 rounded-lg border border-dark-600">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === tab.id
? "bg-accent-purple text-white"
: "text-gray-400 hover:text-white"
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
);
})}
</div>
{/* Overview Tab */}
{activeTab === "overview" && stats && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Total Users", value: stats.total_users, icon: Users, color: "blue" },
{ label: "Total Clients", value: stats.total_clients, icon: Activity, color: "green" },
{ label: "Total Sessions", value: stats.total_sessions, icon: Activity, color: "cyan" },
{ label: "Active Countries", value: stats.total_countries, icon: Shield, color: "purple" }
].map((stat) => {
const Icon = stat.icon;
return (
<div key={stat.label} className="card">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-400">{stat.label}</span>
<Icon className={`w-5 h-5 text-${stat.color}-400`} />
</div>
<p className="text-2xl font-bold text-white">{stat.value}</p>
</div>
);
})}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="card">
<h3 className="font-semibold text-white mb-4">Clients by Status</h3>
<div className="space-y-3">
{Object.entries(stats.clients_by_status || {}).map(([status, count]: [string, any]) => (
<div key={status} className="flex items-center justify-between">
<span className="text-sm text-gray-400 capitalize">{status}</span>
<div className="flex items-center gap-3">
<div className="w-32 h-2 bg-dark-700 rounded-full overflow-hidden">
<div className="h-full bg-accent-blue rounded-full" style={{ width: `${(count / (stats.total_clients || 1)) * 100}%` }} />
</div>
<span className="text-sm font-medium text-white w-8">{count}</span>
</div>
</div>
))}
</div>
</div>
<div className="card">
<h3 className="font-semibold text-white mb-4">Sessions by Type</h3>
<div className="space-y-3">
{Object.entries(stats.sessions_by_type || {}).map(([type, count]: [string, any]) => (
<div key={type} className="flex items-center justify-between">
<span className="text-sm text-gray-400 capitalize">{type}</span>
<div className="flex items-center gap-3">
<div className="w-32 h-2 bg-dark-700 rounded-full overflow-hidden">
<div className="h-full bg-accent-purple rounded-full" style={{ width: `${(count / (stats.total_sessions || 1)) * 100}%` }} />
</div>
<span className="text-sm font-medium text-white w-8">{count}</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* Users Tab */}
{activeTab === "users" && (
<div className="card">
<h3 className="font-semibold text-white mb-4">User Management</h3>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left text-xs font-semibold text-gray-500 uppercase px-4 py-3">User</th>
<th className="text-left text-xs font-semibold text-gray-500 uppercase px-4 py-3">Role</th>
<th className="text-left text-xs font-semibold text-gray-500 uppercase px-4 py-3">Status</th>
<th className="text-left text-xs font-semibold text-gray-500 uppercase px-4 py-3">Last Login</th>
<th className="text-left text-xs font-semibold text-gray-500 uppercase px-4 py-3">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-600">
{users.map((u) => (
<tr key={u.id}>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-accent-blue/20 flex items-center justify-center">
<span className="text-sm font-medium text-accent-blue">{u.first_name?.[0] || "U"}</span>
</div>
<div>
<p className="text-sm text-white">{u.first_name} {u.last_name}</p>
<p className="text-xs text-gray-500">{u.email}</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<span className={`badge ${u.role === "admin" ? "bg-accent-purple/20 text-purple-400" : u.role === "operator" ? "bg-accent-blue/20 text-blue-400" : "bg-gray-500/20 text-gray-400"}`}>
{u.role}
</span>
</td>
<td className="px-4 py-3">
<span className={`badge ${u.is_active ? "bg-green-500/20 text-green-400" : "bg-red-500/20 text-red-400"}`}>
{u.is_active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-400">{u.last_login ? new Date(u.last_login).toLocaleDateString() : "Never"}</td>
<td className="px-4 py-3">
<div className="flex gap-1">
<button onClick={() => handleToggleUser(u.id)} className="p-1.5 text-gray-400 hover:text-accent-blue transition-colors">
{u.is_active ? <UserX className="w-4 h-4" /> : <UserCheck className="w-4 h-4" />}
</button>
<button onClick={() => handleDeleteUser(u.id)} className="p-1.5 text-gray-400 hover:text-accent-red transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Audit Logs Tab */}
{activeTab === "audit" && (
<div className="card">
<h3 className="font-semibold text-white mb-4">Audit Logs</h3>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left text-xs font-semibold text-gray-500 uppercase px-4 py-3">Time</th>
<th className="text-left text-xs font-semibold text-gray-500 uppercase px-4 py-3">User</th>
<th className="text-left text-xs font-semibold text-gray-500 uppercase px-4 py-3">Action</th>
<th className="text-left text-xs font-semibold text-gray-500 uppercase px-4 py-3">Entity</th>
<th className="text-left text-xs font-semibold text-gray-500 uppercase px-4 py-3">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-600">
{auditLogs.map((log) => (
<tr key={log.id}>
<td className="px-4 py-3 text-sm text-gray-400">{new Date(log.created_at).toLocaleString()}</td>
<td className="px-4 py-3 text-sm text-white">{log.user_email || "System"}</td>
<td className="px-4 py-3">
<span className="badge bg-accent-blue/20 text-blue-400">{log.action}</span>
</td>
<td className="px-4 py-3 text-sm text-gray-400">{log.entity_type} {log.entity_id?.slice(0, 8)}</td>
<td className="px-4 py-3 text-sm text-gray-500 max-w-xs truncate">{JSON.stringify(log.details)}</td>
</tr>
))}
</tbody>
</table>
</div>
{auditMeta && <Pagination page={auditPage} totalPages={auditMeta.totalPages || 1} onPageChange={(p) => { setAuditPage(p); fetchAuditLogs(); }} />}
</div>
)}
{/* System Tab */}
{activeTab === "system" && (
<div className="card">
<h3 className="font-semibold text-white mb-4">System Information</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-dark-700 rounded-lg">
<p className="text-xs text-gray-500 mb-1">App Name</p>
<p className="text-sm font-medium text-white">Tinovisas</p>
</div>
<div className="p-4 bg-dark-700 rounded-lg">
<p className="text-xs text-gray-500 mb-1">Version</p>
<p className="text-sm font-medium text-white">1.0.0</p>
</div>
</div>
<div className="p-4 bg-dark-700 rounded-lg">
<p className="text-xs text-gray-500 mb-2">Features</p>
<div className="flex flex-wrap gap-2">
{["Checkup", "Booking", "Workflows", "Documents", "Notifications", "Audit Logs"].map((f) => (
<span key={f} className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-xs">{f}</span>
))}
</div>
</div>
</div>
</div>
)}
</main>
</div>
</div>
);
}
+173
View File
@@ -0,0 +1,173 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "../../hooks/useAuth";
import { useClients } from "../../hooks/useClients";
import { useCountries } from "../../hooks/useCountries";
import Sidebar from "../../components/Sidebar";
import Header from "../../components/Header";
import { bookingAPI } from "../../lib/api";
import { toast } from "react-toastify";
import { CalendarCheck, Play, Loader2, Calendar, Flag, User } from "lucide-react";
export default function BookingPage() {
const router = useRouter();
const { user, loading } = useAuth();
const { clients } = useClients({ status: "active", limit: 100 });
const { countries } = useCountries(true);
const [running, setRunning] = useState(false);
const [form, setForm] = useState({ client_id: "", country_id: "", preferred_date: "", visa_type: "Tourist" });
const [logs, setLogs] = useState<string[]>([]);
useEffect(() => { if (!loading && !user) router.push("/login"); }, [user, loading, router]);
const handleRunBooking = async () => {
if (!form.client_id || !form.country_id) {
toast.error("Select a client and country");
return;
}
setRunning(true);
setLogs([]);
try {
setLogs((prev) => [...prev, "Starting booking session..."]);
const { data } = await bookingAPI.run(form);
setLogs((prev) => [...prev, "Booking process completed!"]);
toast.success("Booking process completed");
} catch (error: any) {
const msg = error.response?.data?.message || "Booking failed";
setLogs((prev) => [...prev, `Error: ${msg}`]);
toast.error(msg);
} finally {
setRunning(false);
}
};
if (loading || !user) return null;
const selectedClient = clients.find((c: any) => c.id === form.client_id);
const selectedCountry = countries.find((c: any) => c.id === form.country_id);
return (
<div className="flex min-h-screen bg-dark-900">
<Sidebar />
<div className="flex-1 flex flex-col">
<Header />
<main className="flex-1 p-6 overflow-y-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<CalendarCheck className="w-6 h-6 text-accent-purple" />
Booking Mode
</h1>
<p className="text-gray-500">Automate visa appointment booking</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Client Selection */}
<div className="card">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<User className="w-5 h-5 text-accent-blue" />
Select Client
</h2>
<div className="space-y-2 max-h-64 overflow-y-auto">
{clients.map((client: any) => (
<button
key={client.id}
onClick={() => setForm({ ...form, client_id: client.id })}
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-all text-left ${
form.client_id === client.id
? "border-accent-blue bg-accent-blue/10"
: "border-dark-600 hover:border-dark-500"
}`}
>
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-accent-blue to-accent-purple flex items-center justify-center">
<span className="text-sm font-bold text-white">{client.first_name[0]}{client.last_name[0]}</span>
</div>
<div>
<p className="text-sm font-medium text-white">{client.first_name} {client.last_name}</p>
<p className="text-xs text-gray-500">{client.nationality || "No nationality"}</p>
</div>
</button>
))}
</div>
</div>
{/* Country Selection */}
<div className="card">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Flag className="w-5 h-5 text-accent-green" />
Select Country
</h2>
<div className="grid grid-cols-2 gap-2">
{countries.map((country: any) => (
<button
key={country.id}
onClick={() => setForm({ ...form, country_id: country.id })}
className={`flex items-center gap-2 p-3 rounded-lg border transition-all ${
form.country_id === country.id
? "border-accent-green bg-accent-green/10"
: "border-dark-600 hover:border-dark-500"
}`}
>
<span className="text-2xl">{country.flag_emoji}</span>
<span className="text-sm font-medium text-white">{country.name}</span>
</button>
))}
</div>
</div>
{/* Booking Options */}
<div className="card">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Calendar className="w-5 h-5 text-accent-orange" />
Booking Options
</h2>
<div className="space-y-4">
<div>
<label className="label">Preferred Date</label>
<input type="date" value={form.preferred_date} onChange={(e) => setForm({ ...form, preferred_date: e.target.value })} className="input w-full" />
</div>
<div>
<label className="label">Visa Type</label>
<select value={form.visa_type} onChange={(e) => setForm({ ...form, visa_type: e.target.value })} className="select w-full">
<option>Tourist</option>
<option>Business</option>
<option>Student</option>
<option>Work</option>
<option>Transit</option>
</select>
</div>
<div className="p-3 bg-dark-700 rounded-lg">
<p className="text-xs text-gray-500 mb-1">Selected:</p>
<p className="text-sm text-white">{selectedClient ? `${selectedClient.first_name} ${selectedClient.last_name}` : "No client selected"}</p>
<p className="text-sm text-gray-400">{selectedCountry ? `${selectedCountry.name}` : "No country selected"}</p>
</div>
<button
onClick={handleRunBooking}
disabled={running || !form.client_id || !form.country_id}
className="btn-primary w-full flex items-center justify-center gap-2 disabled:opacity-50"
>
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{running ? "Processing..." : "Start Booking"}
</button>
</div>
</div>
</div>
{/* Logs */}
{logs.length > 0 && (
<div className="card mt-6">
<h2 className="text-lg font-semibold text-white mb-4">Session Logs</h2>
<div className="bg-dark-900 rounded-lg p-4 max-h-64 overflow-y-auto font-mono text-sm space-y-1">
{logs.map((log, i) => (
<div key={i} className={`${log.includes("Error") ? "text-red-400" : "text-gray-400"}`}>
[{new Date().toLocaleTimeString()}] {log}
</div>
))}
</div>
</div>
)}
</main>
</div>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "../../hooks/useAuth";
import { useClients } from "../../hooks/useClients";
import { useCountries } from "../../hooks/useCountries";
import Sidebar from "../../components/Sidebar";
import Header from "../../components/Header";
import { checkupAPI, clientAPI } from "../../lib/api";
import { toast } from "react-toastify";
import { Activity, Play, Loader2, Monitor, AlertCircle } from "lucide-react";
export default function CheckupPage() {
const router = useRouter();
const { user, loading } = useAuth();
const { clients } = useClients({ status: "active", limit: 100 });
const { countries } = useCountries(true);
const [running, setRunning] = useState(false);
const [selectedClient, setSelectedClient] = useState("");
const [selectedCountry, setSelectedCountry] = useState("");
const [logs, setLogs] = useState<string[]>([]);
useEffect(() => { if (!loading && !user) router.push("/login"); }, [user, loading, router]);
const handleRunCheckup = async () => {
if (!selectedClient || !selectedCountry) {
toast.error("Select a client and country");
return;
}
setRunning(true);
setLogs([]);
try {
setLogs((prev) => [...prev, "Starting checkup session..."]);
const { data } = await checkupAPI.run({ client_id: selectedClient, country_id: selectedCountry });
setLogs((prev) => [...prev, "Checkup completed!", `Available dates: ${data.data?.available_dates?.length || 0} found`]);
toast.success("Checkup completed successfully");
} catch (error: any) {
const msg = error.response?.data?.message || "Checkup failed";
setLogs((prev) => [...prev, `Error: ${msg}`]);
toast.error(msg);
} finally {
setRunning(false);
}
};
if (loading || !user) return null;
return (
<div className="flex min-h-screen bg-dark-900">
<Sidebar />
<div className="flex-1 flex flex-col">
<Header />
<main className="flex-1 p-6 overflow-y-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<Activity className="w-6 h-6 text-accent-cyan" />
Checkup Mode
</h1>
<p className="text-gray-500">Check visa appointment availability</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Control Panel */}
<div className="card">
<h2 className="text-lg font-semibold text-white mb-4">Run Checkup</h2>
<div className="space-y-4">
<div>
<label className="label">Select Client</label>
<select value={selectedClient} onChange={(e) => setSelectedClient(e.target.value)} className="select w-full">
<option value="">Choose a client...</option>
{clients.map((c: any) => (
<option key={c.id} value={c.id}>{c.first_name} {c.last_name} ({c.nationality || "N/A"})</option>
))}
</select>
</div>
<div>
<label className="label">Select Country</label>
<div className="grid grid-cols-2 gap-2">
{countries.map((c: any) => (
<button
key={c.id}
onClick={() => setSelectedCountry(c.id)}
className={`flex items-center gap-2 p-3 rounded-lg border transition-all ${
selectedCountry === c.id
? "border-accent-blue bg-accent-blue/10 text-white"
: "border-dark-600 hover:border-dark-500 text-gray-400"
}`}
>
<span className="text-xl">{c.flag_emoji}</span>
<span className="text-sm font-medium">{c.name}</span>
</button>
))}
</div>
</div>
<button
onClick={handleRunCheckup}
disabled={running || !selectedClient || !selectedCountry}
className="btn-primary w-full flex items-center justify-center gap-2 disabled:opacity-50"
>
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{running ? "Running Checkup..." : "Start Checkup"}
</button>
</div>
</div>
{/* Logs Panel */}
<div className="card">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Monitor className="w-5 h-5 text-accent-blue" />
Session Logs
</h2>
<div className="bg-dark-900 rounded-lg p-4 h-80 overflow-y-auto font-mono text-sm space-y-1">
{logs.length === 0 ? (
<p className="text-gray-600 italic">No logs yet. Start a checkup to see logs here.</p>
) : (
logs.map((log, i) => (
<div key={i} className={`${log.includes("Error") ? "text-red-400" : log.includes("completed") ? "text-green-400" : "text-gray-400"}`}>
<span className="text-gray-600">[{new Date().toLocaleTimeString()}]</span> {log}
</div>
))
)}
</div>
</div>
</div>
{/* Info Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
<div className="card">
<AlertCircle className="w-8 h-8 text-accent-orange mb-2" />
<h3 className="font-medium text-white mb-1">Automated Checkup</h3>
<p className="text-sm text-gray-500">Uses Playwright to navigate VFS websites and check for available appointment slots.</p>
</div>
<div className="card">
<Activity className="w-8 h-8 text-accent-cyan mb-2" />
<h3 className="font-medium text-white mb-1">Real-time Monitoring</h3>
<p className="text-sm text-gray-500">Screenshots are captured during the checkup process for verification.</p>
</div>
<div className="card">
<Monitor className="w-8 h-8 text-accent-purple mb-2" />
<h3 className="font-medium text-white mb-1">Session Logging</h3>
<p className="text-sm text-gray-500">All actions are logged and stored for audit and debugging purposes.</p>
</div>
</div>
</main>
</div>
</div>
);
}
+794
View File
@@ -0,0 +1,794 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import Sidebar from "@/components/Sidebar";
import Header from "@/components/Header";
import { clientAPI } from "@/lib/api";
import { toast } from "react-toastify";
import {
ChevronLeft, User, Mail, Phone, Calendar, MapPin, FileText,
Key, Shield, AlertCircle, Play, Edit, Trash2, Clock, Activity,
CheckCircle, XCircle, Loader2, Copy, Check, Globe, BookOpen,
Sparkles, ArrowRight, Flag
} from "lucide-react";
// Country flag mapping
const flagMap: Record<string, string> = {
"Malte - Alger": "🇩🇿", "Malte - Oran": "🇩🇿", "Alger": "🇩🇿", "Oran": "🇩🇿",
"Algeria": "🇩🇿", "Allemagne": "🇩🇪", "Germany": "🇩🇪",
"Hollande": "🇳🇱", "Pays-Bas": "🇳🇱", "Netherlands": "🇳🇱",
"Danemark": "🇩🇰", "Denmark": "🇩🇰", "Autriche": "🇦🇹", "Austria": "🇦🇹",
"Suisse": "🇨🇭", "Switzerland": "🇨🇭", "Grèce": "🇬🇷", "Greece": "🇬🇷",
"France": "🇫🇷", "Italie": "🇮🇹", "Italy": "🇮🇹",
"Espagne": "🇪🇸", "Spain": "🇪🇸", "Portugal": "🇵🇹",
"Belgique": "🇧🇪", "Belgium": "🇧🇪", "Suède": "🇸🇪", "Sweden": "🇸🇪",
"Norvège": "🇳🇴", "Norway": "🇳🇴", "Finlande": "🇫🇮", "Finland": "🇫🇮",
"Luxembourg": "🇱🇺", "Islande": "🇮🇸", "Iceland": "🇮🇸",
"Tunisia": "🇹🇳", "Tunisie": "🇹🇳", "Morocco": "🇲🇦", "Maroc": "🇲🇦",
"Egypt": "🇪🇬", "Égypte": "🇪🇬", "Turkey": "🇹🇷", "Turquie": "🇹🇷",
"United Kingdom": "🇬🇧", "Royaume-Uni": "🇬🇧", "UK": "🇬🇧",
"USA": "🇺🇸", "United States": "🇺🇸", "États-Unis": "🇺🇸",
"Canada": "🇨🇦", "Australia": "🇦🇺", "Australie": "🇦🇺",
};
function formatDate(date: string | null) {
if (!date) return "N/A";
const d = new Date(date);
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}
function formatDateTime(date: string | null) {
if (!date) return "N/A";
const d = new Date(date);
return d.toLocaleString("en-US", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
}
export default function ClientDetailPage({ params }: { params: { id: string } }) {
const router = useRouter();
const { user, loading: authLoading } = useAuth();
const [client, setClient] = useState<any>(null);
const [sessions, setSessions] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [copiedField, setCopiedField] = useState<string | null>(null);
const [startingSession, setStartingSession] = useState(false);
useEffect(() => { if (!authLoading && !user) router.push("/login"); }, [user, authLoading, router]);
useEffect(() => {
if (!user) return;
fetchClient();
fetchSessions();
}, [user, params.id]);
const fetchClient = async () => {
try {
setLoading(true);
const { data } = await clientAPI.getById(params.id);
setClient(data);
} catch (error: any) {
toast.error(error.response?.data?.message || "Failed to load client");
} finally {
setLoading(false);
}
};
const fetchSessions = async () => {
try {
// Try to fetch sessions for this client
const { data } = await clientAPI.getAll({ client_id: params.id, limit: 10 });
// If API returns sessions differently, adjust here
setSessions(data?.data || []);
} catch (error) {
console.error("Failed to fetch sessions:", error);
}
};
const handleDelete = async () => {
try {
await clientAPI.delete(params.id);
toast.success("Client deleted");
router.push("/clients");
} catch (error: any) {
toast.error(error.response?.data?.message || "Failed to delete client");
}
};
const handleStartSession = async () => {
if (!client?.is_active) {
toast.warning("Client is inactive");
return;
}
setStartingSession(true);
try {
// Try to start a session via API
await clientAPI.create({ client_id: params.id, mode: "booking" });
toast.success("Session started");
router.push("/sessions");
} catch (error: any) {
if (error.response?.status === 409) {
toast.info("Client already has an active session");
router.push("/sessions");
} else {
toast.error(error.response?.data?.message || "Failed to start session");
}
} finally {
setStartingSession(false);
}
};
const copyToClipboard = (text: string, field: string) => {
navigator.clipboard.writeText(text);
setCopiedField(field);
setTimeout(() => setCopiedField(null), 2000);
toast.success("Copied to clipboard");
};
const getFlag = (country: string) => flagMap[country] || "🏳️";
const getPriorityConfig = (priority: number) => {
if (priority >= 10) return {
label: "Urgent",
badge: "bg-red-500/20 text-red-400 border-red-500/30",
icon: "🔴",
bar: "bg-red-500",
dot: "bg-red-500"
};
if (priority >= 5) return {
label: "High",
badge: "bg-orange-500/20 text-orange-400 border-orange-500/30",
icon: "🟠",
bar: "bg-orange-500",
dot: "bg-orange-500"
};
if (priority >= 3) return {
label: "Medium",
badge: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
icon: "🟡",
bar: "bg-yellow-500",
dot: "bg-yellow-500"
};
return {
label: "Low",
badge: "bg-green-500/20 text-green-400 border-green-500/30",
icon: "🟢",
bar: "bg-green-500",
dot: "bg-green-500"
};
};
const getSessionStatusBadge = (status: string) => {
const styles: Record<string, string> = {
running: "bg-accent-blue/20 text-accent-blue border-accent-blue/30",
completed: "bg-accent-green/20 text-accent-green border-accent-green/30",
error: "bg-accent-red/20 text-accent-red border-accent-red/30",
stopped: "bg-gray-500/20 text-gray-400 border-gray-500/30",
pending: "bg-accent-orange/20 text-accent-orange border-accent-orange/30",
waiting: "bg-accent-purple/20 text-accent-purple border-accent-purple/30",
};
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${styles[status] || styles.pending}`}>
<span className="w-1.5 h-1.5 rounded-full mr-1.5 bg-current" />
<span className="capitalize">{status}</span>
</span>
);
};
if (authLoading || !user) return null;
if (loading) {
return (
<div className="flex min-h-screen bg-dark-900">
<Sidebar />
<div className="flex-1 flex flex-col">
<Header />
<main className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-10 h-10 animate-spin text-accent-cyan" />
<p className="text-gray-500">Loading client profile...</p>
</div>
</main>
</div>
</div>
);
}
if (!client) {
return (
<div className="flex min-h-screen bg-dark-900">
<Sidebar />
<div className="flex-1 flex flex-col">
<Header />
<main className="flex-1 flex items-center justify-center">
<div className="text-center">
<AlertCircle className="w-16 h-16 text-accent-red mx-auto mb-4" />
<h2 className="text-xl font-bold text-white mb-2">Client Not Found</h2>
<p className="text-gray-500 mb-6">The client you are looking for does not exist.</p>
<button
onClick={() => router.push("/clients")}
className="px-4 py-2 bg-accent-blue text-white rounded-xl hover:bg-accent-blue/80 transition-colors"
>
Back to Clients
</button>
</div>
</main>
</div>
</div>
);
}
const priority = getPriorityConfig(client.priority || 0);
const fullName = `${client.first_name || ""} ${client.last_name || ""}`.trim() || client.name || "Unnamed";
return (
<div className="flex min-h-screen bg-dark-900">
<Sidebar />
<div className="flex-1 flex flex-col">
<Header />
<main className="flex-1 p-6 overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<button
onClick={() => router.push("/clients")}
className="p-2 text-gray-400 hover:text-white rounded-lg hover:bg-dark-700 transition-colors"
>
<ChevronLeft className="w-5 h-5" />
</button>
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-accent-blue to-accent-purple rounded-xl flex items-center justify-center text-2xl shadow-lg shadow-accent-blue/20">
{getFlag(client.country)}
</div>
<div>
<h1 className="text-2xl font-bold text-white">{fullName}</h1>
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>{client.visa_type || client.visaType || "N/A"}</span>
<span>·</span>
<span className={client.is_active || client.status === "active" ? "text-accent-green" : "text-gray-500"}>
{client.is_active || client.status === "active" ? "Active" : "Inactive"}
</span>
</div>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleStartSession}
disabled={startingSession || (!client.is_active && client.status !== "active")}
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-accent-green to-emerald-600 text-white font-medium rounded-xl hover:from-accent-green/80 hover:to-emerald-600/80 transition-all shadow-lg shadow-accent-green/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
{startingSession ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
Start Session
</button>
<button
onClick={() => router.push(`/clients/${params.id}/edit`)}
className="flex items-center gap-2 px-4 py-2.5 bg-dark-700 border border-dark-600 text-gray-300 font-medium rounded-xl hover:bg-dark-600 hover:text-white transition-all"
>
<Edit className="w-4 h-4" />
Edit
</button>
<button
onClick={() => setDeleteConfirm(true)}
className="p-2.5 text-accent-red hover:bg-accent-red/10 border border-dark-600 rounded-xl transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* LEFT COLUMN - Profile & VFS */}
<div className="lg:col-span-2 space-y-6">
{/* Client Profile Card */}
<div className="bg-dark-800 rounded-2xl border border-dark-600 overflow-hidden">
{/* Card Header with Priority Banner */}
<div className={`px-6 py-4 border-b border-dark-600 flex items-center justify-between ${priority.badge}`}>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-accent-blue/20 to-accent-purple/20 flex items-center justify-center text-xl font-bold text-accent-blue border border-accent-blue/20">
{fullName.charAt(0).toUpperCase()}
</div>
<div>
<h2 className="font-bold text-white">{fullName}</h2>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xl">{getFlag(client.country)}</span>
<span className="text-sm text-gray-400">{client.country}</span>
</div>
</div>
</div>
<div className="text-right">
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-bold border ${priority.badge}`}>
{priority.icon} {priority.label}
</span>
<div className="mt-2 w-32 h-2 bg-dark-700 rounded-full overflow-hidden">
<div className={`h-full ${priority.bar} rounded-full`} style={{ width: `${Math.min(((client.priority || 0) / 10) * 100, 100)}%` }} />
</div>
</div>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Personal Information */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider flex items-center gap-2">
<User className="w-4 h-4 text-accent-blue" />
Personal Info
</h3>
{(client.email || client.mail) && (
<div className="flex items-center justify-between group">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-accent-blue/10 rounded-lg flex items-center justify-center">
<Mail className="w-4 h-4 text-accent-blue" />
</div>
<div>
<p className="text-xs text-gray-500">Email</p>
<p className="text-sm font-medium text-white">{client.email || client.mail}</p>
</div>
</div>
<button
onClick={() => copyToClipboard(client.email || client.mail, "email")}
className="opacity-0 group-hover:opacity-100 p-1.5 text-gray-500 hover:text-accent-blue hover:bg-accent-blue/10 rounded-lg transition-all"
>
{copiedField === "email" ? <Check className="w-4 h-4 text-accent-green" /> : <Copy className="w-4 h-4" />}
</button>
</div>
)}
{client.phone && (
<div className="flex items-center justify-between group">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-accent-green/10 rounded-lg flex items-center justify-center">
<Phone className="w-4 h-4 text-accent-green" />
</div>
<div>
<p className="text-xs text-gray-500">Phone</p>
<p className="text-sm font-medium text-white">{client.phone}</p>
</div>
</div>
<button
onClick={() => copyToClipboard(client.phone, "phone")}
className="opacity-0 group-hover:opacity-100 p-1.5 text-gray-500 hover:text-accent-blue hover:bg-accent-blue/10 rounded-lg transition-all"
>
{copiedField === "phone" ? <Check className="w-4 h-4 text-accent-green" /> : <Copy className="w-4 h-4" />}
</button>
</div>
)}
{(client.date_of_birth || client.dateOfBirth) && (
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-accent-purple/10 rounded-lg flex items-center justify-center">
<Calendar className="w-4 h-4 text-accent-purple" />
</div>
<div>
<p className="text-xs text-gray-500">Date of Birth</p>
<p className="text-sm font-medium text-white">{formatDate(client.date_of_birth || client.dateOfBirth)}</p>
</div>
</div>
)}
{client.gender && (
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-pink-500/10 rounded-lg flex items-center justify-center">
<User className="w-4 h-4 text-pink-400" />
</div>
<div>
<p className="text-xs text-gray-500">Gender</p>
<p className="text-sm font-medium text-white capitalize">{client.gender}</p>
</div>
</div>
)}
</div>
{/* Passport & Documents */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider flex items-center gap-2">
<BookOpen className="w-4 h-4 text-accent-orange" />
Documents
</h3>
{(client.passport_number || client.passportNumber) && (
<div className="flex items-center justify-between group">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-accent-orange/10 rounded-lg flex items-center justify-center">
<Shield className="w-4 h-4 text-accent-orange" />
</div>
<div>
<p className="text-xs text-gray-500">Passport Number</p>
<p className="text-sm font-medium text-white font-mono">{client.passport_number || client.passportNumber}</p>
</div>
</div>
<button
onClick={() => copyToClipboard(client.passport_number || client.passportNumber, "passport")}
className="opacity-0 group-hover:opacity-100 p-1.5 text-gray-500 hover:text-accent-blue hover:bg-accent-blue/10 rounded-lg transition-all"
>
{copiedField === "passport" ? <Check className="w-4 h-4 text-accent-green" /> : <Copy className="w-4 h-4" />}
</button>
</div>
)}
{(client.passport_expiry || client.passportExpiry) && (
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-accent-red/10 rounded-lg flex items-center justify-center">
<Calendar className="w-4 h-4 text-accent-red" />
</div>
<div>
<p className="text-xs text-gray-500">Passport Expiry</p>
<p className="text-sm font-medium text-white">{formatDate(client.passport_expiry || client.passportExpiry)}</p>
</div>
</div>
)}
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-dark-700 rounded-lg flex items-center justify-center">
<Clock className="w-4 h-4 text-gray-500" />
</div>
<div>
<p className="text-xs text-gray-500">Created</p>
<p className="text-sm font-medium text-white">{formatDate(client.created_at || client.createdAt)}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-dark-700 rounded-lg flex items-center justify-center">
<Activity className="w-4 h-4 text-gray-500" />
</div>
<div>
<p className="text-xs text-gray-500">Last Updated</p>
<p className="text-sm font-medium text-white">{formatDate(client.updated_at || client.updatedAt)}</p>
</div>
</div>
</div>
</div>
{/* Notes */}
{(client.notes || client.description) && (
<div className="mt-6 pt-6 border-t border-dark-600">
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider flex items-center gap-2 mb-3">
<FileText className="w-4 h-4 text-accent-orange" />
Notes
</h3>
<div className="bg-accent-orange/5 border border-accent-orange/10 rounded-xl p-4">
<p className="text-sm text-gray-300 whitespace-pre-wrap">{client.notes || client.description}</p>
</div>
</div>
)}
</div>
</div>
{/* VFS Connectivity Section — Visually Separate */}
<div className="bg-dark-800 rounded-2xl border border-accent-purple/20 overflow-hidden">
<div className="px-6 py-4 border-b border-accent-purple/20 bg-gradient-to-r from-accent-purple/10 to-accent-blue/10 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-accent-purple to-accent-blue rounded-xl flex items-center justify-center shadow-lg shadow-accent-purple/20">
<Key className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="font-bold text-white">VFS Connectivity</h2>
<p className="text-sm text-gray-500">Automated login credentials</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className={`w-2.5 h-2.5 rounded-full ${(client.vfs_username || client.vfsUsername) ? "bg-accent-green animate-pulse" : "bg-gray-600"}`} />
<span className="text-sm font-medium text-gray-400">
{(client.vfs_username || client.vfsUsername) ? "Configured" : "Not Configured"}
</span>
</div>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="flex items-center justify-between group p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-accent-purple/10 rounded-xl flex items-center justify-center">
<User className="w-5 h-5 text-accent-purple" />
</div>
<div>
<p className="text-xs text-gray-500">VFS Username</p>
<p className="text-sm font-medium text-white font-mono">
{(client.vfs_username || client.vfsUsername) || <span className="text-gray-500 italic">Not set</span>}
</p>
</div>
</div>
{(client.vfs_username || client.vfsUsername) && (
<button
onClick={() => copyToClipboard(client.vfs_username || client.vfsUsername, "vfs_username")}
className="opacity-0 group-hover:opacity-100 p-2 text-gray-500 hover:text-accent-purple hover:bg-accent-purple/10 rounded-lg transition-all"
>
{copiedField === "vfs_username" ? <Check className="w-4 h-4 text-accent-green" /> : <Copy className="w-4 h-4" />}
</button>
)}
</div>
<div className="flex items-center gap-3 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="w-10 h-10 bg-accent-blue/10 rounded-xl flex items-center justify-center">
<Key className="w-5 h-5 text-accent-blue" />
</div>
<div>
<p className="text-xs text-gray-500">VFS Password</p>
<p className="text-sm font-medium text-white">
{(client.vfs_password_encrypted || client.vfsPassword) ? (
<span className="font-mono"></span>
) : (
<span className="text-gray-500 italic">Not set</span>
)}
</p>
</div>
</div>
</div>
{!(client.vfs_username || client.vfsUsername) && (
<div className="mt-4 p-4 bg-accent-orange/5 border border-accent-orange/10 rounded-xl flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-accent-orange flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-accent-orange">VFS credentials not configured</p>
<p className="text-sm text-gray-400 mt-1">
Add VFS credentials in the edit page to enable automated session execution.
</p>
<button
onClick={() => router.push(`/clients/${params.id}/edit`)}
className="inline-flex items-center gap-1 mt-2 text-sm font-medium text-accent-cyan hover:text-accent-cyan/80"
>
Configure now <ArrowRight className="w-3.5 h-3.5" />
</button>
</div>
</div>
)}
</div>
</div>
{/* Recent Sessions */}
<div className="bg-dark-800 rounded-2xl border border-dark-600 overflow-hidden">
<div className="px-6 py-4 border-b border-dark-600 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-accent-blue/10 rounded-xl flex items-center justify-center">
<Activity className="w-5 h-5 text-accent-blue" />
</div>
<div>
<h2 className="font-bold text-white">Recent Sessions</h2>
<p className="text-sm text-gray-500">{sessions.length} total sessions</p>
</div>
</div>
<button
onClick={() => router.push("/sessions")}
className="flex items-center gap-1 text-sm font-medium text-accent-cyan hover:text-accent-cyan/80"
>
View all <ArrowRight className="w-4 h-4" />
</button>
</div>
<div className="divide-y divide-dark-600">
{sessions.length === 0 ? (
<div className="px-6 py-8 text-center">
<div className="w-12 h-12 bg-dark-700 rounded-xl flex items-center justify-center mx-auto mb-3">
<Activity className="w-6 h-6 text-gray-500" />
</div>
<p className="text-gray-500 text-sm">No sessions yet</p>
<button
onClick={handleStartSession}
disabled={!client.is_active && client.status !== "active"}
className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-accent-cyan hover:text-accent-cyan/80 disabled:text-gray-600"
>
<Play className="w-4 h-4" />
Start first session
</button>
</div>
) : (
sessions.slice(0, 5).map((session: any) => (
<div key={session.id} className="px-6 py-4 flex items-center justify-between hover:bg-dark-700/30 transition-colors">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-dark-700">
{session.status === "running" && <Activity className="w-4 h-4 text-accent-blue animate-pulse" />}
{session.status === "completed" && <CheckCircle className="w-4 h-4 text-accent-green" />}
{session.status === "error" && <XCircle className="w-4 h-4 text-accent-red" />}
{session.status === "stopped" && <XCircle className="w-4 h-4 text-gray-500" />}
{!["running", "completed", "error", "stopped"].includes(session.status) && <Clock className="w-4 h-4 text-accent-orange" />}
</div>
<div>
<div className="flex items-center gap-2">
{getSessionStatusBadge(session.status)}
<span className="text-xs text-gray-600">#{session.id?.slice(0, 8)}</span>
</div>
{session.current_action && (
<p className="text-sm text-gray-500 mt-0.5">{session.current_action}</p>
)}
</div>
</div>
<div className="text-right">
<p className="text-sm text-gray-500">
{session.started_at || session.startedAt ? formatDateTime(session.started_at || session.startedAt) : "Not started"}
</p>
{(session.completed_at || session.completedAt) && (
<p className="text-xs text-gray-600">
Completed {formatDateTime(session.completed_at || session.completedAt)}
</p>
)}
</div>
</div>
))
)}
</div>
</div>
</div>
{/* RIGHT COLUMN - Visa Info & Quick Actions */}
<div className="space-y-6">
{/* Visa Info Card */}
<div className="bg-dark-800 rounded-2xl border border-dark-600 overflow-hidden">
<div className="px-6 py-4 border-b border-dark-600">
<h2 className="font-bold text-white flex items-center gap-2">
<Globe className="w-5 h-5 text-accent-blue" />
Visa Details
</h2>
</div>
<div className="p-6 space-y-4">
<div className="flex items-center gap-3">
<span className="text-3xl">{getFlag(client.country)}</span>
<div>
<p className="text-xs text-gray-500">Country</p>
<p className="text-sm font-semibold text-white">{client.country}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-accent-blue/10 rounded-lg flex items-center justify-center">
<MapPin className="w-4 h-4 text-accent-blue" />
</div>
<div>
<p className="text-xs text-gray-500">Visa Type</p>
<p className="text-sm font-semibold text-white">{client.visa_type || client.visaType || "N/A"}</p>
</div>
</div>
{(client.visa_category || client.visaCategory) && (
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-accent-green/10 rounded-lg flex items-center justify-center">
<FileText className="w-4 h-4 text-accent-green" />
</div>
<div>
<p className="text-xs text-gray-500">Category</p>
<p className="text-sm font-semibold text-white capitalize">{(client.visa_category || client.visaCategory).replace("_", " ")}</p>
</div>
</div>
)}
{(client.visa_center || client.visaCenter) && (
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-accent-purple/10 rounded-lg flex items-center justify-center">
<MapPin className="w-4 h-4 text-accent-purple" />
</div>
<div>
<p className="text-xs text-gray-500">Visa Center</p>
<p className="text-sm font-semibold text-white">{client.visa_center || client.visaCenter}</p>
</div>
</div>
)}
{(client.visa_sub_category || client.visaSubCategory) && (
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-accent-orange/10 rounded-lg flex items-center justify-center">
<Sparkles className="w-4 h-4 text-accent-orange" />
</div>
<div>
<p className="text-xs text-gray-500">Sub Category</p>
<p className="text-sm font-semibold text-white">{client.visa_sub_category || client.visaSubCategory}</p>
</div>
</div>
)}
{(client.payment_mode || client.paymentMode) && (
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-accent-green/10 rounded-lg flex items-center justify-center">
<Shield className="w-4 h-4 text-accent-green" />
</div>
<div>
<p className="text-xs text-gray-500">Payment Mode</p>
<p className="text-sm font-semibold text-white capitalize">{client.payment_mode || client.paymentMode}</p>
</div>
</div>
)}
</div>
</div>
{/* Quick Actions */}
<div className="bg-dark-800 rounded-2xl border border-dark-600 overflow-hidden">
<div className="px-6 py-4 border-b border-dark-600">
<h2 className="font-bold text-white">Quick Actions</h2>
</div>
<div className="p-4 space-y-2">
<button
onClick={handleStartSession}
disabled={startingSession || (!client.is_active && client.status !== "active")}
className="w-full flex items-center gap-3 px-4 py-3 bg-gradient-to-r from-accent-green/10 to-emerald-600/10 border border-accent-green/20 rounded-xl hover:bg-accent-green/10 transition-colors text-left disabled:opacity-50"
>
<div className="w-8 h-8 bg-accent-green/10 rounded-lg flex items-center justify-center">
<Play className="w-4 h-4 text-accent-green" />
</div>
<div>
<p className="text-sm font-semibold text-white">Start Session</p>
<p className="text-xs text-gray-500">Launch bot for this client</p>
</div>
</button>
<button
onClick={() => router.push(`/clients/${params.id}/edit`)}
className="w-full flex items-center gap-3 px-4 py-3 bg-accent-blue/5 border border-accent-blue/20 rounded-xl hover:bg-accent-blue/10 transition-colors text-left"
>
<div className="w-8 h-8 bg-accent-blue/10 rounded-lg flex items-center justify-center">
<Edit className="w-4 h-4 text-accent-blue" />
</div>
<div>
<p className="text-sm font-semibold text-white">Edit Client</p>
<p className="text-xs text-gray-500">Update profile & credentials</p>
</div>
</button>
<button
onClick={() => router.push(`/clients/${params.id}/logs`)}
className="w-full flex items-center gap-3 px-4 py-3 bg-accent-purple/5 border border-accent-purple/20 rounded-xl hover:bg-accent-purple/10 transition-colors text-left"
>
<div className="w-8 h-8 bg-accent-purple/10 rounded-lg flex items-center justify-center">
<FileText className="w-4 h-4 text-accent-purple" />
</div>
<div>
<p className="text-sm font-semibold text-white">View Logs</p>
<p className="text-xs text-gray-500">Session & error history</p>
</div>
</button>
<button
onClick={() => setDeleteConfirm(true)}
className="w-full flex items-center gap-3 px-4 py-3 bg-accent-red/5 border border-accent-red/20 rounded-xl hover:bg-accent-red/10 transition-colors text-left"
>
<div className="w-8 h-8 bg-accent-red/10 rounded-lg flex items-center justify-center">
<Trash2 className="w-4 h-4 text-accent-red" />
</div>
<div>
<p className="text-sm font-semibold text-white">Delete Client</p>
<p className="text-xs text-gray-500">Remove permanently</p>
</div>
</button>
</div>
</div>
</div>
</div>
</main>
</div>
{/* Delete Confirmation Modal */}
{deleteConfirm && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-dark-800 rounded-2xl p-6 max-w-md w-full mx-4 border border-dark-600 shadow-2xl">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 bg-accent-red/10 rounded-xl flex items-center justify-center border border-accent-red/20">
<AlertCircle className="w-6 h-6 text-accent-red" />
</div>
<h3 className="text-lg font-semibold text-white">Delete Client?</h3>
</div>
<p className="text-gray-400 mb-2">
This will permanently delete <strong className="text-white">{fullName}</strong> and all associated sessions and logs.
</p>
<p className="text-sm text-accent-red mb-6">This action cannot be undone.</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setDeleteConfirm(false)}
className="px-4 py-2 text-gray-400 font-medium hover:bg-dark-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleDelete}
className="px-4 py-2 bg-accent-red text-white font-medium rounded-lg hover:bg-accent-red/80 transition-colors"
>
Delete Permanently
</button>
</div>
</div>
</div>
)}
</div>
);
}
+263
View File
@@ -0,0 +1,263 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "../../hooks/useAuth";
import { useClients } from "../../hooks/useClients";
import { useCountries } from "../../hooks/useCountries";
import Sidebar from "../../components/Sidebar";
import Header from "../../components/Header";
import ClientCard from "../../components/ClientCard";
import Pagination from "../../components/Pagination";
import { clientAPI } from "../../lib/api";
import { toast } from "react-toastify";
import { Plus, Search, Filter, X } from "lucide-react";
export default function ClientsPage() {
const router = useRouter();
const { user, loading } = useAuth();
const { countries } = useCountries(true);
const [page, setPage] = useState(1);
const [filters, setFilters] = useState<any>({});
const [showModal, setShowModal] = useState(false);
const [showCountryModal, setShowCountryModal] = useState(false);
const [selectedClient, setSelectedClient] = useState<any>(null);
const [form, setForm] = useState({ first_name: "", last_name: "", email: "", phone: "", passport_number: "", date_of_birth: "", nationality: "", priority: "medium", status: "active", notes: "" });
const [countryForm, setCountryForm] = useState({ country_id: "", visa_type: "", notes: "" });
const { clients, loading: clientsLoading, meta, refetch } = useClients({ ...filters, page, limit: 12 });
useEffect(() => { if (!loading && !user) router.push("/login"); }, [user, loading, router]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
try {
await clientAPI.create(form);
toast.success("Client created");
setShowModal(false);
setForm({ first_name: "", last_name: "", email: "", phone: "", passport_number: "", date_of_birth: "", nationality: "", priority: "medium", status: "active", notes: "" });
refetch();
} catch (error: any) {
toast.error(error.response?.data?.message || "Failed to create client");
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this client?")) return;
try {
await clientAPI.delete(id);
toast.success("Client deleted");
refetch();
} catch (error: any) {
toast.error(error.response?.data?.message || "Failed to delete");
}
};
const handleAddCountry = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedClient) return;
try {
await clientAPI.addCountry(selectedClient.id, countryForm);
toast.success("Country added to client");
setShowCountryModal(false);
setCountryForm({ country_id: "", visa_type: "", notes: "" });
refetch();
} catch (error: any) {
toast.error(error.response?.data?.message || "Failed to add country");
}
};
const openCountryModal = (client: any) => {
setSelectedClient(client);
setShowCountryModal(true);
};
if (loading || !user) return null;
return (
<div className="flex min-h-screen bg-dark-900">
<Sidebar />
<div className="flex-1 flex flex-col">
<Header />
<main className="flex-1 p-6 overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Clients</h1>
<p className="text-gray-500">Manage visa applicants</p>
</div>
<button onClick={() => setShowModal(true)} className="btn-primary flex items-center gap-2">
<Plus className="w-4 h-4" />
Add Client
</button>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-6">
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<input
type="text"
placeholder="Search clients..."
value={filters.search || ""}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="input pl-10 w-64"
/>
</div>
<select
value={filters.priority || ""}
onChange={(e) => setFilters({ ...filters, priority: e.target.value })}
className="select"
>
<option value="">All Priorities</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
<select
value={filters.status || ""}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="select"
>
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="completed">Completed</option>
<option value="suspended">Suspended</option>
</select>
<button onClick={() => { setFilters({}); setPage(1); }} className="px-3 py-2 text-gray-400 hover:text-white transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{/* Clients Grid */}
{clientsLoading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin w-8 h-8 border-2 border-accent-blue border-t-transparent rounded-full" />
</div>
) : clients.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500">No clients found</p>
</div>
) : (
<>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
{clients.map((client) => (
<div key={client.id} className="relative group">
<ClientCard client={client} onDelete={handleDelete} />
<button
onClick={() => openCountryModal(client)}
className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 p-2 bg-dark-700 rounded-lg text-gray-400 hover:text-accent-blue transition-all"
>
<Plus className="w-4 h-4" />
</button>
</div>
))}
</div>
{meta && (
<Pagination page={page} totalPages={meta.totalPages || 1} onPageChange={setPage} />
)}
</>
)}
{/* Create Modal */}
{showModal && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="card w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-white">Add New Client</h2>
<button onClick={() => setShowModal(false)} className="text-gray-400 hover:text-white">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleCreate} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">First Name *</label>
<input required value={form.first_name} onChange={(e) => setForm({ ...form, first_name: e.target.value })} className="input w-full" placeholder="John" />
</div>
<div>
<label className="label">Last Name *</label>
<input required value={form.last_name} onChange={(e) => setForm({ ...form, last_name: e.target.value })} className="input w-full" placeholder="Doe" />
</div>
<div>
<label className="label">Email</label>
<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className="input w-full" placeholder="john@example.com" />
</div>
<div>
<label className="label">Phone</label>
<input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} className="input w-full" placeholder="+1234567890" />
</div>
<div>
<label className="label">Passport Number</label>
<input value={form.passport_number} onChange={(e) => setForm({ ...form, passport_number: e.target.value })} className="input w-full" placeholder="AB123456" />
</div>
<div>
<label className="label">Date of Birth</label>
<input type="date" value={form.date_of_birth} onChange={(e) => setForm({ ...form, date_of_birth: e.target.value })} className="input w-full" />
</div>
<div>
<label className="label">Nationality</label>
<input value={form.nationality} onChange={(e) => setForm({ ...form, nationality: e.target.value })} className="input w-full" placeholder="USA" />
</div>
<div>
<label className="label">Priority</label>
<select value={form.priority} onChange={(e) => setForm({ ...form, priority: e.target.value })} className="select w-full">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
<div>
<label className="label">Notes</label>
<textarea value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} className="input w-full h-24 resize-none" placeholder="Additional notes..." />
</div>
<div className="flex justify-end gap-3 pt-4">
<button type="button" onClick={() => setShowModal(false)} className="px-4 py-2 text-gray-400 hover:text-white transition-colors">Cancel</button>
<button type="submit" className="btn-primary">Create Client</button>
</div>
</form>
</div>
</div>
)}
{/* Add Country Modal */}
{showCountryModal && selectedClient && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="card w-full max-w-md">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-white">Add Country for {selectedClient.first_name}</h2>
<button onClick={() => setShowCountryModal(false)} className="text-gray-400 hover:text-white"><X className="w-5 h-5" /></button>
</div>
<form onSubmit={handleAddCountry} className="space-y-4">
<div>
<label className="label">Country</label>
<select value={countryForm.country_id} onChange={(e) => setCountryForm({ ...countryForm, country_id: e.target.value })} className="select w-full" required>
<option value="">Select country</option>
{countries.map((c: any) => (
<option key={c.id} value={c.id}>{c.flag_emoji} {c.name}</option>
))}
</select>
</div>
<div>
<label className="label">Visa Type</label>
<input value={countryForm.visa_type} onChange={(e) => setCountryForm({ ...countryForm, visa_type: e.target.value })} className="input w-full" placeholder="Tourist, Business..." />
</div>
<div>
<label className="label">Notes</label>
<textarea value={countryForm.notes} onChange={(e) => setCountryForm({ ...countryForm, notes: e.target.value })} className="input w-full h-20 resize-none" placeholder="Notes..." />
</div>
<div className="flex justify-end gap-3">
<button type="button" onClick={() => setShowCountryModal(false)} className="px-4 py-2 text-gray-400 hover:text-white">Cancel</button>
<button type="submit" className="btn-primary">Add Country</button>
</div>
</form>
</div>
</div>
)}
</main>
</div>
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "../../hooks/useAuth";
import { useCountries } from "../../hooks/useCountries";
import Sidebar from "../../components/Sidebar";
import Header from "../../components/Header";
import CountryCard from "../../components/CountryCard";
import { countryAPI } from "../../lib/api";
import { toast } from "react-toastify";
import { Plus, X, Globe } from "lucide-react";
export default function CountriesPage() {
const router = useRouter();
const { user, loading, isAdmin } = useAuth();
const { countries, refetch } = useCountries();
const [showModal, setShowModal] = useState(false);
const [form, setForm] = useState({ name: "", code: "", flag_emoji: "", vfs_url: "", processing_time: "", visa_types: "", requirements: "" });
useEffect(() => { if (!loading && !user) router.push("/login"); }, [user, loading, router]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
try {
const visa_types = form.visa_types.split(",").map((s) => s.trim()).filter(Boolean);
const requirements = form.requirements.split("\n").map((s) => s.trim()).filter(Boolean);
await countryAPI.create({ ...form, visa_types, requirements });
toast.success("Country created");
setShowModal(false);
setForm({ name: "", code: "", flag_emoji: "", vfs_url: "", processing_time: "", visa_types: "", requirements: "" });
refetch();
} catch (error: any) {
toast.error(error.response?.data?.message || "Failed to create country");
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this country?")) return;
try {
await countryAPI.delete(id);
toast.success("Country deleted");
refetch();
} catch (error: any) {
toast.error(error.response?.data?.message || "Failed to delete");
}
};
if (loading || !user) return null;
return (
<div className="flex min-h-screen bg-dark-900">
<Sidebar />
<div className="flex-1 flex flex-col">
<Header />
<main className="flex-1 p-6 overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Countries</h1>
<p className="text-gray-500">Manage visa destinations</p>
</div>
{isAdmin && (
<button onClick={() => setShowModal(true)} className="btn-primary flex items-center gap-2">
<Plus className="w-4 h-4" />
Add Country
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{countries.map((country) => (
<div key={country.id} className="relative group">
<CountryCard country={country} />
{isAdmin && (
<button
onClick={() => handleDelete(country.id)}
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 p-1.5 bg-dark-800 rounded-lg text-gray-400 hover:text-accent-red transition-all"
>
<X className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
{showModal && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="card w-full max-w-lg">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Globe className="w-5 h-5 text-accent-blue" />
Add Country
</h2>
<button onClick={() => setShowModal(false)} className="text-gray-400 hover:text-white"><X className="w-5 h-5" /></button>
</div>
<form onSubmit={handleCreate} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Name *</label>
<input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="input w-full" placeholder="France" />
</div>
<div>
<label className="label">Code *</label>
<input required value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value.toUpperCase() })} className="input w-full" placeholder="FR" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Flag Emoji</label>
<input value={form.flag_emoji} onChange={(e) => setForm({ ...form, flag_emoji: e.target.value })} className="input w-full" placeholder="🇫🇷" />
</div>
<div>
<label className="label">Processing Time</label>
<input value={form.processing_time} onChange={(e) => setForm({ ...form, processing_time: e.target.value })} className="input w-full" placeholder="15-20 days" />
</div>
</div>
<div>
<label className="label">VFS URL</label>
<input type="url" value={form.vfs_url} onChange={(e) => setForm({ ...form, vfs_url: e.target.value })} className="input w-full" placeholder="https://..." />
</div>
<div>
<label className="label">Visa Types (comma separated)</label>
<input value={form.visa_types} onChange={(e) => setForm({ ...form, visa_types: e.target.value })} className="input w-full" placeholder="Tourist, Business, Student" />
</div>
<div>
<label className="label">Requirements (one per line)</label>
<textarea value={form.requirements} onChange={(e) => setForm({ ...form, requirements: e.target.value })} className="input w-full h-24 resize-none" placeholder="Passport valid 6 months&#10;2 photos&#10;Bank statement" />
</div>
<div className="flex justify-end gap-3 pt-4">
<button type="button" onClick={() => setShowModal(false)} className="px-4 py-2 text-gray-400 hover:text-white">Cancel</button>
<button type="submit" className="btn-primary">Create Country</button>
</div>
</form>
</div>
</div>
)}
</main>
</div>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "../../hooks/useAuth";
import Sidebar from "../../components/Sidebar";
import Header from "../../components/Header";
import { adminAPI, systemAPI } from "../../lib/api";
import { toast } from "react-toastify";
import {
Users, Globe, Activity, CheckCircle, Clock, AlertTriangle,
TrendingUp, MonitorPlay
} from "lucide-react";
export default function DashboardPage() {
const router = useRouter();
const { user, loading } = useAuth();
const [stats, setStats] = useState<any>(null);
const [systemStatus, setSystemStatus] = useState<any>(null);
useEffect(() => {
if (!loading && !user) router.push("/login");
}, [user, loading, router]);
useEffect(() => {
if (!user) return;
const fetchData = async () => {
try {
const [statsRes, statusRes] = await Promise.all([
adminAPI.getDashboard(),
systemAPI.getStatus()
]);
setStats(statsRes.data.data);
setSystemStatus(statusRes.data.data);
} catch (error) {
toast.error("Failed to load dashboard data");
}
};
fetchData();
}, [user]);
if (loading || !user) return null;
const statCards = [
{ label: "Total Clients", value: stats?.total_clients || 0, icon: Users, color: "blue" },
{ label: "Countries", value: stats?.total_countries || 0, icon: Globe, color: "purple" },
{ label: "Active Sessions", value: stats?.active_sessions || 0, icon: MonitorPlay, color: "cyan" },
{ label: "Total Sessions", value: stats?.total_sessions || 0, icon: Activity, color: "green" }
];
return (
<div className="flex min-h-screen bg-dark-900">
<Sidebar />
<div className="flex-1 flex flex-col">
<Header />
<main className="flex-1 p-6 overflow-y-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
<p className="text-gray-500">Welcome back, {user.first_name || "Operator"}</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{statCards.map((card) => {
const Icon = card.icon;
return (
<div key={card.label} className="card">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-400">{card.label}</span>
<div className={`p-2 rounded-lg bg-${card.color}-500/10`}>
<Icon className={`w-5 h-5 text-${card.color}-400`} />
</div>
</div>
<p className="text-2xl font-bold text-white">{card.value}</p>
</div>
);
})}
</div>
{/* System Status */}
{systemStatus && (
<div className="card mb-6">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-accent-orange" />
System Status
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{Object.entries(systemStatus.services || {}).map(([name, info]: [string, any]) => (
<div key={name} className="flex items-center gap-3 p-3 bg-dark-700 rounded-lg">
<div className={`w-3 h-3 rounded-full ${info.status === "healthy" ? "bg-green-500" : "bg-red-500"}`} />
<div>
<p className="text-sm font-medium text-white capitalize">{name}</p>
<p className="text-xs text-gray-500">{info.latency_ms ? `${info.latency_ms}ms` : info.uptime_seconds ? `${Math.floor(info.uptime_seconds / 60)}m uptime` : "N/A"}</p>
</div>
</div>
))}
</div>
</div>
)}
{/* Recent Activity */}
{stats?.recent_sessions && stats.recent_sessions.length > 0 && (
<div className="card">
<h2 className="text-lg font-semibold text-white mb-4">Recent Sessions</h2>
<div className="space-y-3">
{stats.recent_sessions.slice(0, 5).map((session: any) => (
<div key={session.id} className="flex items-center gap-4 p-3 bg-dark-700 rounded-lg">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${session.type === "checkup" ? "bg-accent-cyan/20" : "bg-accent-purple/20"}`}>
{session.type === "checkup" ? <Activity className="w-5 h-5 text-cyan-400" /> : <CheckCircle className="w-5 h-5 text-purple-400" />}
</div>
<div className="flex-1">
<p className="text-sm font-medium text-white capitalize">{session.type} Session</p>
<p className="text-xs text-gray-500">{session.client_name || "Unknown client"} {session.country_name || "Unknown country"}</p>
</div>
<span className={`badge ${session.status === "completed" ? "bg-green-500/20 text-green-400" : session.status === "running" ? "bg-accent-cyan/20 text-cyan-400" : "bg-red-500/20 text-red-400"}`}>
{session.status}
</span>
</div>
))}
</div>
</div>
)}
</main>
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "../../hooks/useAuth";
import { useClients } from "../../hooks/useClients";
import Sidebar from "../../components/Sidebar";
import Header from "../../components/Header";
import DocumentUploader from "../../components/DocumentUploader";
import { documentAPI } from "../../lib/api";
import { toast } from "react-toastify";
import { FileText, Download, Trash2, FolderOpen } from "lucide-react";
export default function DocumentsPage() {
const router = useRouter();
const { user, loading } = useAuth();
const { clients } = useClients();
const [documents, setDocuments] = useState<any[]>([]);
const [selectedClient, setSelectedClient] = useState("");
const [uploading, setUploading] = useState(false);
useEffect(() => { if (!loading && !user) router.push("/login"); }, [user, loading, router]);
useEffect(() => {
if (selectedClient) fetchDocuments();
}, [selectedClient]);
const fetchDocuments = async () => {
try {
const { data } = await documentAPI.getAll({ client_id: selectedClient });
setDocuments(data.data || []);
} catch {
toast.error("Failed to load documents");
}
};
const handleUpload = async (file: File) => {
if (!selectedClient) {
toast.error("Select a client first");
return;
}
setUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("client_id", selectedClient);
await documentAPI.upload(formData);
toast.success("Document uploaded");
fetchDocuments();
} catch (error: any) {
toast.error(error.response?.data?.message || "Upload failed");
} finally {
setUploading(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this document?")) return;
try {
await documentAPI.delete(id);
toast.success("Document deleted");
fetchDocuments();
} catch (error: any) {
toast.error(error.response?.data?.message || "Failed to delete");
}
};
if (loading || !user) return null;
return (
<div className="flex min-h-screen bg-dark-900">
<Sidebar />
<div className="flex-1 flex flex-col">
<Header />
<main className="flex-1 p-6 overflow-y-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<FileText className="w-6 h-6 text-accent-blue" />
Documents
</h1>
<p className="text-gray-500">Manage client documents</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Client Selection */}
<div>
<label className="label mb-2">Select Client</label>
<div className="space-y-1 max-h-64 overflow-y-auto card p-3">
{clients.map((client: any) => (
<button
key={client.id}
onClick={() => setSelectedClient(client.id)}
className={`w-full flex items-center gap-3 p-2 rounded-lg transition-all text-left ${
selectedClient === client.id ? "bg-accent-blue/20 text-white" : "text-gray-400 hover:bg-dark-700"
}`}
>
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-accent-blue to-accent-purple flex items-center justify-center">
<span className="text-xs font-bold text-white">{client.first_name[0]}</span>
</div>
<span className="text-sm">{client.first_name} {client.last_name}</span>
</button>
))}
</div>
</div>
{/* Upload Area */}
<div className="lg:col-span-2">
<DocumentUploader onUpload={handleUpload} uploading={uploading} />
{documents.length > 0 && (
<div className="mt-4 card">
<h3 className="font-medium text-white mb-3">Uploaded Documents</h3>
<div className="space-y-2">
{documents.map((doc) => (
<div key={doc.id} className="flex items-center gap-3 p-3 bg-dark-700 rounded-lg">
<FileText className="w-5 h-5 text-accent-blue" />
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{doc.name}</p>
<p className="text-xs text-gray-500">{doc.file_type} {(doc.file_size / 1024).toFixed(1)} KB</p>
</div>
<div className="flex gap-1">
<button onClick={() => documentAPI.download(doc.id)} className="p-1.5 text-gray-400 hover:text-accent-blue transition-colors">
<Download className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(doc.id)} className="p-1.5 text-gray-400 hover:text-accent-red transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</main>
</div>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import "../styles/globals.css";
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
export const metadata = {
title: "Tinovisas - Visa Operations Platform",
description: "Modern visa operations platform for agencies"
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark">
<body className="min-h-screen bg-dark-900 text-gray-100">
<ToastContainer
position="top-right"
autoClose={3000}
hideProgressBar={false}
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="dark"
/>
{children}
</body>
</html>
);
}
+130
View File
@@ -0,0 +1,130 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { authAPI } from "../../lib/api";
import { setAuth } from "../../lib/auth";
import { toast } from "react-toastify";
import { Globe, Loader2 } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
const [isLogin, setIsLogin] = useState(true);
const [loading, setLoading] = useState(false);
const [form, setForm] = useState({ email: "", password: "", first_name: "", last_name: "" });
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const res = isLogin
? await authAPI.login(form.email, form.password)
: await authAPI.register(form);
const { token, user } = res.data.data;
setAuth(token, user);
toast.success(isLogin ? "Welcome back!" : "Account created!");
router.push("/dashboard");
} catch (error: any) {
toast.error(error.response?.data?.message || "Authentication failed");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-dark-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-accent-blue to-accent-purple rounded-2xl flex items-center justify-center mx-auto mb-4">
<Globe className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-white mb-1">Tinovisas</h1>
<p className="text-gray-500">Visa Operations Platform</p>
</div>
<div className="card">
<div className="flex mb-6">
<button
onClick={() => setIsLogin(true)}
className={`flex-1 py-2 text-sm font-medium rounded-lg transition-colors ${
isLogin ? "bg-accent-blue text-white" : "text-gray-400 hover:text-white"
}`}
>
Sign In
</button>
<button
onClick={() => setIsLogin(false)}
className={`flex-1 py-2 text-sm font-medium rounded-lg transition-colors ${
!isLogin ? "bg-accent-blue text-white" : "text-gray-400 hover:text-white"
}`}
>
Sign Up
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{!isLogin && (
<>
<div>
<label className="label">First Name</label>
<input
type="text"
value={form.first_name}
onChange={(e) => setForm({ ...form, first_name: e.target.value })}
className="input w-full"
placeholder="John"
/>
</div>
<div>
<label className="label">Last Name</label>
<input
type="text"
value={form.last_name}
onChange={(e) => setForm({ ...form, last_name: e.target.value })}
className="input w-full"
placeholder="Doe"
/>
</div>
</>
)}
<div>
<label className="label">Email</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="input w-full"
placeholder="admin@tinovisas.com"
required
/>
</div>
<div>
<label className="label">Password</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className="input w-full"
placeholder="••••••••"
required
minLength={6}
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full flex items-center justify-center gap-2"
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{isLogin ? "Sign In" : "Create Account"}
</button>
</form>
<p className="text-xs text-gray-600 text-center mt-4">
Default admin: admin@tinovisas.com / Admin123!
</p>
</div>
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/login");
}
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "../../hooks/useAuth";
import Sidebar from "../../components/Sidebar";
import Header from "../../components/Header";
import SessionTable from "../../components/SessionTable";
import Pagination from "../../components/Pagination";
import { sessionAPI } from "../../lib/api";
import { toast } from "react-toastify";
import { MonitorPlay, Filter, X } from "lucide-react";
export default function SessionsPage() {
const router = useRouter();
const { user, loading } = useAuth();
const [sessions, setSessions] = useState<any[]>([]);
const [meta, setMeta] = useState<any>(null);
const [page, setPage] = useState(1);
const [filters, setFilters] = useState<any>({});
const [loadingData, setLoadingData] = useState(true);
useEffect(() => { if (!loading && !user) router.push("/login"); }, [user, loading, router]);
useEffect(() => {
if (!user) return;
fetchSessions();
}, [user, page, JSON.stringify(filters)]);
const fetchSessions = async () => {
try {
setLoadingData(true);
const { data } = await sessionAPI.getAll({ ...filters, page, limit: 20 });
setSessions(data.data || []);
setMeta(data.meta);
} catch (error) {
toast.error("Failed to load sessions");
} finally {
setLoadingData(false);
}
};
const handleStop = async (id: string) => {
try {
await sessionAPI.stop(id);
toast.success("Session stopped");
fetchSessions();
} catch (error: any) {
toast.error(error.response?.data?.message || "Failed to stop");
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this session?")) return;
try {
await sessionAPI.delete(id);
toast.success("Session deleted");
fetchSessions();
} catch (error: any) {
toast.error(error.response?.data?.message || "Failed to delete");
}
};
if (loading || !user) return null;
return (
<div className="flex min-h-screen bg-dark-900">
<Sidebar />
<div className="flex-1 flex flex-col">
<Header />
<main className="flex-1 p-6 overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<MonitorPlay className="w-6 h-6 text-accent-cyan" />
Sessions
</h1>
<p className="text-gray-500">Manage bot sessions</p>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-6">
<select value={filters.type || ""} onChange={(e) => { setFilters({ ...filters, type: e.target.value }); setPage(1); }} className="select">
<option value="">All Types</option>
<option value="checkup">Checkup</option>
<option value="booking">Booking</option>
</select>
<select value={filters.status || ""} onChange={(e) => { setFilters({ ...filters, status: e.target.value }); setPage(1); }} className="select">
<option value="">All Statuses</option>
<option value="running">Running</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
</select>
<button onClick={() => { setFilters({}); setPage(1); }} className="px-3 py-2 text-gray-400 hover:text-white"><X className="w-4 h-4" /></button>
</div>
{loadingData ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin w-8 h-8 border-2 border-accent-blue border-t-transparent rounded-full" />
</div>
) : sessions.length === 0 ? (
<div className="text-center py-12 card">
<MonitorPlay className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-500">No sessions yet</p>
</div>
) : (
<div className="card">
<SessionTable sessions={sessions} onStop={handleStop} onDelete={handleDelete} />
{meta && <Pagination page={page} totalPages={meta.totalPages || 1} onPageChange={setPage} />}
</div>
)}
</main>
</div>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "../../hooks/useAuth";
import Sidebar from "../../components/Sidebar";
import Header from "../../components/Header";
import ThemeToggle from "../../components/ThemeToggle";
import LanguageSelector from "../../components/LanguageSelector";
import { Settings, Globe, Palette, Bell, Shield, Key } from "lucide-react";
import { toast } from "react-toastify";
export default function SettingsPage() {
const router = useRouter();
const { user, loading } = useAuth();
useEffect(() => { if (!loading && !user) router.push("/login"); }, [user, loading, router]);
if (loading || !user) return null;
return (
<div className="flex min-h-screen bg-dark-900">
<Sidebar />
<div className="flex-1 flex flex-col">
<Header />
<main className="flex-1 p-6 overflow-y-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<Settings className="w-6 h-6 text-gray-400" />
Settings
</h1>
<p className="text-gray-500">Configure your platform</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 max-w-4xl">
{/* Appearance */}
<div className="card">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Palette className="w-5 h-5 text-accent-purple" />
Appearance
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-white">Theme</p>
<p className="text-xs text-gray-500">Toggle between dark and light mode</p>
</div>
<ThemeToggle />
</div>
</div>
</div>
{/* Language */}
<div className="card">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Globe className="w-5 h-5 text-accent-blue" />
Language & Region
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-white">Language</p>
<p className="text-xs text-gray-500">Select your preferred language</p>
</div>
<LanguageSelector />
</div>
</div>
</div>
{/* Graphical Countries */}
<div className="card lg:col-span-2">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Globe className="w-5 h-5 text-accent-green" />
Graphical Countries
</h2>
<p className="text-sm text-gray-500 mb-4">Manage country flags and visual representations used throughout the platform.</p>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-3">
{["🇫🇷","🇩🇪","🇮🇹","🇪🇸","🇬🇧","🇺🇸","🇨🇦","🇦🇺","🇯🇵","🇰🇷","🇨🇳","🇮🇳","🇧🇷","🇲🇽","🇦🇪","🇸🇦","🇹🇷","🇷🇺","🇳🇱","🇧🇪","🇨🇭","🇸🇪","🇳🇴","🇩🇰","🇫🇮","🇵🇱","🇨🇿","🇦🇹","🇬🇷","🇵🇹"].map((flag) => (
<button key={flag} className="p-3 bg-dark-700 rounded-lg hover:bg-dark-600 transition-colors text-2xl text-center">
{flag}
</button>
))}
</div>
</div>
{/* Notifications */}
<div className="card">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Bell className="w-5 h-5 text-accent-orange" />
Notifications
</h2>
<div className="space-y-3">
{["Session completed", "Booking successful", "New client added", "System alerts"].map((item) => (
<div key={item} className="flex items-center justify-between">
<span className="text-sm text-gray-300">{item}</span>
<button
onClick={() => toast.success(`${item} notifications toggled`)}
className="w-11 h-6 bg-accent-blue rounded-full relative transition-colors"
>
<span className="absolute right-1 top-1 w-4 h-4 bg-white rounded-full" />
</button>
</div>
))}
</div>
</div>
{/* Security */}
<div className="card">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Shield className="w-5 h-5 text-accent-red" />
Security
</h2>
<div className="space-y-4">
<div>
<label className="label">Current Password</label>
<input type="password" className="input w-full" placeholder="••••••••" />
</div>
<div>
<label className="label">New Password</label>
<input type="password" className="input w-full" placeholder="••••••••" />
</div>
<button onClick={() => toast.success("Password updated")} className="btn-primary w-full">Update Password</button>
</div>
</div>
</div>
</main>
</div>
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "../../hooks/useAuth";
import { useCountries } from "../../hooks/useCountries";
import Sidebar from "../../components/Sidebar";
import Header from "../../components/Header";
import WorkflowEditor from "../../components/WorkflowEditor";
import { workflowAPI } from "../../lib/api";
import { toast } from "react-toastify";
import { ClipboardList, Plus, X, Play, Trash2, Edit } from "lucide-react";
export default function WorkflowsPage() {
const router = useRouter();
const { user, loading } = useAuth();
const { countries } = useCountries(true);
const [workflows, setWorkflows] = useState<any[]>([]);
const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [form, setForm] = useState({ name: "", type: "checkup" as "checkup" | "booking", country_id: "", steps: [] as any[] });
useEffect(() => { if (!loading && !user) router.push("/login"); }, [user, loading, router]);
useEffect(() => {
if (!user) return;
fetchWorkflows();
}, [user]);
const fetchWorkflows = async () => {
try {
const { data } = await workflowAPI.getAll();
setWorkflows(data.data || []);
} catch {
toast.error("Failed to load workflows");
}
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editing) {
await workflowAPI.update(editing.id, form);
toast.success("Workflow updated");
} else {
await workflowAPI.create(form);
toast.success("Workflow created");
}
setShowModal(false);
setEditing(null);
setForm({ name: "", type: "checkup", country_id: "", steps: [] });
fetchWorkflows();
} catch (error: any) {
toast.error(error.response?.data?.message || "Failed to save");
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this workflow?")) return;
try {
await workflowAPI.delete(id);
toast.success("Workflow deleted");
fetchWorkflows();
} catch (error: any) {
toast.error(error.response?.data?.message || "Failed to delete");
}
};
const openEdit = (workflow: any) => {
setEditing(workflow);
setForm({
name: workflow.name,
type: workflow.type,
country_id: workflow.country_id || "",
steps: workflow.steps || []
});
setShowModal(true);
};
if (loading || !user) return null;
return (
<div className="flex min-h-screen bg-dark-900">
<Sidebar />
<div className="flex-1 flex flex-col">
<Header />
<main className="flex-1 p-6 overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<ClipboardList className="w-6 h-6 text-accent-purple" />
Workflows
</h1>
<p className="text-gray-500">Define automation workflows</p>
</div>
<button onClick={() => { setEditing(null); setForm({ name: "", type: "checkup", country_id: "", steps: [] }); setShowModal(true); }} className="btn-primary flex items-center gap-2">
<Plus className="w-4 h-4" />
New Workflow
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{workflows.map((workflow) => (
<div key={workflow.id} className="card">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${workflow.type === "checkup" ? "bg-accent-cyan/20" : "bg-accent-purple/20"}`}>
{workflow.type === "checkup" ? <ClipboardList className="w-5 h-5 text-cyan-400" /> : <Play className="w-5 h-5 text-purple-400" />}
</div>
<div>
<h3 className="font-semibold text-white">{workflow.name}</h3>
<p className="text-xs text-gray-500">{workflow.country_name || "Global"} {workflow.steps?.length || 0} steps</p>
</div>
</div>
<div className="flex gap-1">
<button onClick={() => openEdit(workflow)} className="p-2 text-gray-400 hover:text-accent-blue transition-colors"><Edit className="w-4 h-4" /></button>
<button onClick={() => handleDelete(workflow.id)} className="p-2 text-gray-400 hover:text-accent-red transition-colors"><Trash2 className="w-4 h-4" /></button>
</div>
</div>
<div className="flex flex-wrap gap-1">
{workflow.steps?.slice(0, 5).map((step: any, i: number) => (
<span key={i} className="px-2 py-0.5 bg-dark-700 rounded text-xs text-gray-400">{step.action}</span>
))}
{workflow.steps?.length > 5 && <span className="px-2 py-0.5 text-xs text-gray-500">+{workflow.steps.length - 5} more</span>}
</div>
</div>
))}
</div>
{showModal && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="card w-full max-w-3xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-white">{editing ? "Edit Workflow" : "New Workflow"}</h2>
<button onClick={() => setShowModal(false)} className="text-gray-400 hover:text-white"><X className="w-5 h-5" /></button>
</div>
<form onSubmit={handleSave} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Name *</label>
<input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="input w-full" placeholder="France Checkup Workflow" />
</div>
<div>
<label className="label">Type</label>
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value as any })} className="select w-full">
<option value="checkup">Checkup</option>
<option value="booking">Booking</option>
</select>
</div>
</div>
<div>
<label className="label">Country (optional)</label>
<select value={form.country_id} onChange={(e) => setForm({ ...form, country_id: e.target.value })} className="select w-full">
<option value="">All Countries</option>
{countries.map((c: any) => (
<option key={c.id} value={c.id}>{c.flag_emoji} {c.name}</option>
))}
</select>
</div>
<div>
<label className="label">Steps</label>
<WorkflowEditor initialSteps={form.steps} onChange={(steps) => setForm({ ...form, steps })} />
</div>
<div className="flex justify-end gap-3 pt-4">
<button type="button" onClick={() => setShowModal(false)} className="px-4 py-2 text-gray-400 hover:text-white">Cancel</button>
<button type="submit" className="btn-primary">{editing ? "Update" : "Create"}</button>
</div>
</form>
</div>
</div>
)}
</main>
</div>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
"use client";
import Link from "next/link";
import { formatDate } from "../lib/utils";
import { priorityColors, statusColors } from "../lib/utils";
import { Mail, Phone, Calendar, Flag, FileText, Edit, Trash2 } from "lucide-react";
interface ClientCardProps {
client: any;
onDelete?: (id: string) => void;
}
export default function ClientCard({ client, onDelete }: ClientCardProps) {
return (
<div className="card hover:border-accent-blue/50 transition-all duration-200">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-accent-blue to-accent-purple flex items-center justify-center">
<span className="text-lg font-bold text-white">
{client.first_name?.[0]}{client.last_name?.[0]}
</span>
</div>
<div>
<h3 className="font-semibold text-white">{client.first_name} {client.last_name}</h3>
<p className="text-sm text-gray-500">{client.nationality || "No nationality"}</p>
</div>
</div>
<div className="flex gap-2">
<span className={`badge border ${priorityColors[client.priority] || priorityColors.medium}`}>
{client.priority}
</span>
<span className={`badge ${statusColors[client.status] || statusColors.active}`}>
{client.status}
</span>
</div>
</div>
<div className="space-y-2 mb-4">
{client.email && (
<div className="flex items-center gap-2 text-sm text-gray-400">
<Mail className="w-4 h-4" />
{client.email}
</div>
)}
{client.phone && (
<div className="flex items-center gap-2 text-sm text-gray-400">
<Phone className="w-4 h-4" />
{client.phone}
</div>
)}
{client.passport_number && (
<div className="flex items-center gap-2 text-sm text-gray-400">
<FileText className="w-4 h-4" />
Passport: {client.passport_number}
</div>
)}
{client.date_of_birth && (
<div className="flex items-center gap-2 text-sm text-gray-400">
<Calendar className="w-4 h-4" />
DOB: {formatDate(client.date_of_birth)}
</div>
)}
</div>
{client.countries && client.countries.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{client.countries.map((cc: any) => (
<span key={cc.id} className="inline-flex items-center gap-1 px-2 py-1 bg-dark-700 rounded-md text-xs text-gray-300">
<Flag className="w-3 h-3" />
{cc.country_name} ({cc.visa_type || "N/A"})
</span>
))}
</div>
)}
<div className="flex items-center justify-between pt-4 border-t border-dark-600">
<span className="text-xs text-gray-500">Updated {formatDate(client.updated_at)}</span>
<div className="flex gap-2">
<Link
href={`/clients/${client.id}`}
className="p-2 text-gray-400 hover:text-accent-blue hover:bg-accent-blue/10 rounded-lg transition-colors"
>
<Edit className="w-4 h-4" />
</Link>
{onDelete && (
<button
onClick={() => onDelete(client.id)}
className="p-2 text-gray-400 hover:text-accent-red hover:bg-accent-red/10 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { Globe, Clock, CheckCircle, XCircle } from "lucide-react";
interface CountryCardProps {
country: any;
onSelect?: (country: any) => void;
selected?: boolean;
}
export default function CountryCard({ country, onSelect, selected }: CountryCardProps) {
return (
<div
onClick={() => onSelect?.(country)}
className={`card cursor-pointer transition-all duration-200 ${
selected ? "border-accent-blue bg-accent-blue/5" : "hover:border-dark-500"
}`}
>
<div className="flex items-center gap-4 mb-3">
<span className="text-3xl">{country.flag_emoji || "🏳️"}</span>
<div>
<h3 className="font-semibold text-white">{country.name}</h3>
<p className="text-sm text-gray-500">{country.code}</p>
</div>
</div>
<div className="space-y-2 mb-3">
{country.processing_time && (
<div className="flex items-center gap-2 text-sm text-gray-400">
<Clock className="w-4 h-4" />
{country.processing_time}
</div>
)}
{country.visa_types && country.visa_types.length > 0 && (
<div className="flex flex-wrap gap-1">
{country.visa_types.map((type: string) => (
<span key={type} className="px-2 py-0.5 bg-dark-700 rounded text-xs text-gray-300">
{type}
</span>
))}
</div>
)}
</div>
<div className="flex items-center justify-between pt-3 border-t border-dark-600">
<span className={`inline-flex items-center gap-1 text-xs ${country.is_active ? "text-green-400" : "text-gray-500"}`}>
{country.is_active ? <CheckCircle className="w-3 h-3" /> : <XCircle className="w-3 h-3" />}
{country.is_active ? "Active" : "Inactive"}
</span>
{country.vfs_url && (
<Globe className="w-4 h-4 text-accent-cyan" />
)}
</div>
</div>
);
}
@@ -0,0 +1,50 @@
"use client";
import { useState, useCallback } from "react";
import { Upload, File, X } from "lucide-react";
interface DocumentUploaderProps {
onUpload: (file: File) => void;
uploading?: boolean;
}
export default function DocumentUploader({ onUpload, uploading }: DocumentUploaderProps) {
const [dragActive, setDragActive] = useState(false);
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(e.type === "dragenter" || e.type === "dragover");
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files?.[0]) {
onUpload(e.dataTransfer.files[0]);
}
}, [onUpload]);
return (
<div
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
className={`relative border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
dragActive ? "border-accent-blue bg-accent-blue/5" : "border-dark-600 hover:border-dark-500"
}`}
>
<input
type="file"
onChange={(e) => e.target.files?.[0] && onUpload(e.target.files[0])}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Upload className="w-10 h-10 text-gray-500 mx-auto mb-3" />
<p className="text-sm text-gray-400 mb-1">
{uploading ? "Uploading..." : "Drop files here or click to browse"}
</p>
<p className="text-xs text-gray-600">PDF, JPG, PNG, DOC up to 10MB</p>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { useState, useEffect } from "react";
import { Bell, Search } from "lucide-react";
import { notificationAPI } from "../lib/api";
export default function Header() {
const [unreadCount, setUnreadCount] = useState(0);
useEffect(() => {
const fetchCount = async () => {
try {
const { data } = await notificationAPI.getUnreadCount();
setUnreadCount(data.data?.count || 0);
} catch {
// silent
}
};
fetchCount();
const interval = setInterval(fetchCount, 30000);
return () => clearInterval(interval);
}, []);
return (
<header className="h-16 bg-dark-800 border-b border-dark-600 flex items-center justify-between px-6">
<div className="flex items-center gap-4">
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<input
type="text"
placeholder="Search..."
className="input pl-10 w-64 text-sm"
/>
</div>
</div>
<div className="flex items-center gap-4">
<button className="relative p-2 text-gray-400 hover:text-white transition-colors">
<Bell className="w-5 h-5" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 w-4 h-4 bg-accent-red rounded-full text-[10px] flex items-center justify-center text-white font-medium">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</button>
</div>
</header>
);
}
@@ -0,0 +1,41 @@
"use client";
import { useState } from "react";
import { Globe } from "lucide-react";
const languages = [
{ code: "en", label: "English", flag: "🇬🇧" },
{ code: "fr", label: "Français", flag: "🇫🇷" }
];
export default function LanguageSelector() {
const [open, setOpen] = useState(false);
const [current, setCurrent] = useState(languages[0]);
return (
<div className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-dark-700 border border-dark-600 text-gray-300 hover:text-white transition-colors"
>
<Globe className="w-4 h-4" />
<span>{current.flag}</span>
<span className="text-sm">{current.label}</span>
</button>
{open && (
<div className="absolute top-full left-0 mt-1 w-40 bg-dark-800 border border-dark-600 rounded-lg shadow-xl z-50">
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => { setCurrent(lang); setOpen(false); }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-dark-700 hover:text-white transition-colors first:rounded-t-lg last:rounded-b-lg"
>
<span>{lang.flag}</span>
{lang.label}
</button>
))}
</div>
)}
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
interface PaginationProps {
page: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export default function Pagination({ page, totalPages, onPageChange }: PaginationProps) {
if (totalPages <= 1) return null;
const pages = [];
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= page - 1 && i <= page + 1)) {
pages.push(i);
} else if (i === page - 2 || i === page + 2) {
pages.push(-1);
}
}
return (
<div className="flex items-center gap-2 mt-6">
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="p-2 rounded-lg border border-dark-600 text-gray-400 hover:text-white hover:border-accent-blue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
{pages.map((p, i) => (
p === -1 ? (
<span key={`dots-${i}`} className="px-2 text-gray-500">...</span>
) : (
<button
key={p}
onClick={() => onPageChange(p)}
className={`w-10 h-10 rounded-lg text-sm font-medium transition-colors ${
p === page
? "bg-accent-blue text-white"
: "border border-dark-600 text-gray-400 hover:text-white hover:border-accent-blue"
}`}
>
{p}
</button>
)
))}
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="p-2 rounded-lg border border-dark-600 text-gray-400 hover:text-white hover:border-accent-blue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
<span className="text-sm text-gray-500 ml-2">
Page {page} of {totalPages}
</span>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { formatDateTime } from "../lib/utils";
import { statusColors } from "../lib/utils";
import { Monitor, Square, Trash2, Eye } from "lucide-react";
import Link from "next/link";
interface SessionTableProps {
sessions: any[];
onStop?: (id: string) => void;
onDelete?: (id: string) => void;
}
export default function SessionTable({ sessions, onStop, onDelete }: SessionTableProps) {
return (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wider px-4 py-3">Type</th>
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wider px-4 py-3">Client</th>
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wider px-4 py-3">Country</th>
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wider px-4 py-3">Status</th>
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wider px-4 py-3">Started</th>
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wider px-4 py-3">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-600">
{sessions.map((session) => (
<tr key={session.id} className="hover:bg-dark-700/50 transition-colors">
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 badge ${session.type === "checkup" ? "bg-accent-cyan/20 text-cyan-400" : "bg-accent-purple/20 text-purple-400"}`}>
<Monitor className="w-3 h-3" />
{session.type}
</span>
</td>
<td className="px-4 py-3 text-sm text-white">{session.client_name || "N/A"}</td>
<td className="px-4 py-3 text-sm text-gray-300">{session.country_name || "N/A"}</td>
<td className="px-4 py-3">
<span className={`badge ${statusColors[session.status] || statusColors.active}`}>
{session.status}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-400">{formatDateTime(session.started_at)}</td>
<td className="px-4 py-3">
<div className="flex gap-2">
<Link href={`/sessions/${session.id}`} className="p-1.5 text-gray-400 hover:text-accent-blue transition-colors">
<Eye className="w-4 h-4" />
</Link>
{session.status === "running" && onStop && (
<button onClick={() => onStop(session.id)} className="p-1.5 text-gray-400 hover:text-accent-orange transition-colors">
<Square className="w-4 h-4" />
</button>
)}
{onDelete && (
<button onClick={() => onDelete(session.id)} className="p-1.5 text-gray-400 hover:text-accent-red transition-colors">
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "../hooks/useAuth";
import {
LayoutDashboard,
Users,
Globe,
Activity,
CalendarCheck,
ClipboardList,
FileText,
Settings,
Shield,
LogOut,
MonitorPlay,
FolderOpen
} from "lucide-react";
const navItems = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/clients", label: "Clients", icon: Users },
{ href: "/countries", label: "Countries", icon: Globe },
{ href: "/checkup", label: "Checkup", icon: Activity },
{ href: "/booking", label: "Booking", icon: CalendarCheck },
{ href: "/sessions", label: "Sessions", icon: MonitorPlay },
{ href: "/workflows", label: "Workflows", icon: ClipboardList },
{ href: "/documents", label: "Documents", icon: FileText },
{ href: "/settings", label: "Settings", icon: Settings }
];
const adminItem = { href: "/admin", label: "Admin Panel", icon: Shield };
export default function Sidebar() {
const pathname = usePathname();
const { user, logout, isAdmin } = useAuth();
const isActive = (href: string) => pathname === href || pathname.startsWith(`${href}/`);
return (
<aside className="w-64 bg-dark-800 border-r border-dark-600 flex flex-col h-screen sticky top-0">
<div className="p-6 border-b border-dark-600">
<Link href="/dashboard" className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-accent-blue to-accent-purple rounded-xl flex items-center justify-center">
<Globe className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-lg font-bold text-white">Tinovisas</h1>
<p className="text-xs text-gray-500">Visa Operations</p>
</div>
</Link>
</div>
<nav className="flex-1 overflow-y-auto p-4 space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 ${
active
? "bg-accent-blue/20 text-accent-blue border border-accent-blue/30"
: "text-gray-400 hover:text-white hover:bg-dark-700"
}`}
>
<Icon className="w-5 h-5" />
<span className="font-medium">{item.label}</span>
</Link>
);
})}
{isAdmin && (
<Link
href={adminItem.href}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 mt-4 ${
isActive(adminItem.href)
? "bg-accent-purple/20 text-accent-purple border border-accent-purple/30"
: "text-gray-400 hover:text-white hover:bg-dark-700"
}`}
>
<Shield className="w-5 h-5" />
<span className="font-medium">{adminItem.label}</span>
</Link>
)}
</nav>
<div className="p-4 border-t border-dark-600">
<div className="flex items-center gap-3 mb-3">
<div className="w-8 h-8 rounded-full bg-accent-blue/20 flex items-center justify-center">
<span className="text-sm font-medium text-accent-blue">{user?.first_name?.[0] || "U"}</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{user?.first_name || "User"}</p>
<p className="text-xs text-gray-500 capitalize">{user?.role || "operator"}</p>
</div>
</div>
<button
onClick={logout}
className="flex items-center gap-2 w-full px-4 py-2 text-red-400 hover:bg-red-500/10 rounded-lg transition-colors text-sm"
>
<LogOut className="w-4 h-4" />
Sign Out
</button>
</div>
</aside>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { useState, useEffect } from "react";
import { Sun, Moon } from "lucide-react";
export default function ThemeToggle() {
const [dark, setDark] = useState(true);
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
}, [dark]);
return (
<button
onClick={() => setDark(!dark)}
className="p-2 rounded-lg bg-dark-700 border border-dark-600 text-gray-400 hover:text-white transition-colors"
>
{dark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import { Plus, Trash2, GripVertical } from "lucide-react";
interface Step {
id: string;
action: string;
selector?: string;
value?: string;
wait?: number;
description?: string;
}
interface WorkflowEditorProps {
initialSteps?: Step[];
onChange: (steps: Step[]) => void;
}
const actionTypes = [
{ value: "navigate", label: "Navigate to URL" },
{ value: "click", label: "Click Element" },
{ value: "fill", label: "Fill Input" },
{ value: "select", label: "Select Option" },
{ value: "wait", label: "Wait" },
{ value: "screenshot", label: "Take Screenshot" },
{ value: "check", label: "Check for Text" },
{ value: "submit", label: "Submit Form" }
];
export default function WorkflowEditor({ initialSteps = [], onChange }: WorkflowEditorProps) {
const [steps, setSteps] = useState<Step[]>(initialSteps);
const updateSteps = (newSteps: Step[]) => {
setSteps(newSteps);
onChange(newSteps);
};
const addStep = () => {
const newStep: Step = {
id: Math.random().toString(36).substr(2, 9),
action: "navigate",
description: ""
};
updateSteps([...steps, newStep]);
};
const removeStep = (id: string) => {
updateSteps(steps.filter((s) => s.id !== id));
};
const updateStep = (id: string, updates: Partial<Step>) => {
updateSteps(steps.map((s) => (s.id === id ? { ...s, ...updates } : s)));
};
return (
<div className="space-y-3">
{steps.map((step, index) => (
<div key={step.id} className="flex items-start gap-3 p-4 bg-dark-700 rounded-lg border border-dark-600">
<div className="mt-2 text-gray-500">
<GripVertical className="w-4 h-4" />
</div>
<div className="flex-1 grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className="label">Action</label>
<select
value={step.action}
onChange={(e) => updateStep(step.id, { action: e.target.value })}
className="select w-full"
>
{actionTypes.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div>
<label className="label">Selector / URL</label>
<input
type="text"
value={step.selector || ""}
onChange={(e) => updateStep(step.id, { selector: e.target.value })}
placeholder="CSS selector or URL"
className="input w-full"
/>
</div>
<div>
<label className="label">Value / Wait (ms)</label>
<input
type="text"
value={step.value || ""}
onChange={(e) => updateStep(step.id, { value: e.target.value })}
placeholder="Value to fill or wait time"
className="input w-full"
/>
</div>
<div className="md:col-span-3">
<label className="label">Description</label>
<input
type="text"
value={step.description || ""}
onChange={(e) => updateStep(step.id, { description: e.target.value })}
placeholder="Step description"
className="input w-full"
/>
</div>
</div>
<button
onClick={() => removeStep(step.id)}
className="mt-2 p-1.5 text-gray-500 hover:text-accent-red transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
<button
onClick={addStep}
className="flex items-center gap-2 w-full py-3 border-2 border-dashed border-dark-600 rounded-lg text-gray-500 hover:text-accent-blue hover:border-accent-blue transition-colors"
>
<Plus className="w-4 h-4" />
Add Step
</button>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { getUser, isAuthenticated, clearAuth } from "../lib/auth";
import { authAPI } from "../lib/api";
export function useAuth() {
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadUser = async () => {
if (isAuthenticated()) {
try {
const cached = getUser();
if (cached) setUser(cached);
const { data } = await authAPI.me();
setUser(data.data);
} catch {
clearAuth();
}
}
setLoading(false);
};
loadUser();
}, []);
const logout = useCallback(() => {
clearAuth();
window.location.href = "/login";
}, []);
return { user, loading, logout, isAdmin: user?.role === "admin", isOperator: user?.role === "admin" || user?.role === "operator" };
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { clientAPI } from "../lib/api";
export function useClients(params?: any) {
const [clients, setClients] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [meta, setMeta] = useState<any>(null);
const fetchClients = useCallback(async () => {
try {
setLoading(true);
const { data } = await clientAPI.getAll(params);
setClients(data.data || []);
setMeta(data.meta);
} catch (error) {
console.error("Failed to fetch clients:", error);
} finally {
setLoading(false);
}
}, [JSON.stringify(params)]);
useEffect(() => {
fetchClients();
}, [fetchClients]);
return { clients, loading, meta, refetch: fetchClients };
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { countryAPI } from "../lib/api";
export function useCountries(activeOnly = false) {
const [countries, setCountries] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const fetch = useCallback(async () => {
setLoading(true);
try {
const { data } = await countryAPI.getAll(activeOnly ? { active: "true" } : {});
setCountries(data.data || []);
} catch (error) {
console.error("Failed to fetch countries:", error);
} finally {
setLoading(false);
}
}, [activeOnly]);
useEffect(() => {
fetch();
}, [fetch]);
return { countries, loading, refetch: fetch };
}
+109
View File
@@ -0,0 +1,109 @@
import axios from "axios";
const api = axios.create({
baseURL: "/api",
headers: {
"Content-Type": "application/json"
}
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem("token");
localStorage.removeItem("user");
window.location.href = "/login";
}
return Promise.reject(error);
}
);
export default api;
export const authAPI = {
login: (email: string, password: string) => api.post("/auth/login", { email, password }),
register: (data: any) => api.post("/auth/register", data),
me: () => api.get("/auth/me"),
getUsers: () => api.get("/auth/users")
};
export const clientAPI = {
getAll: (params?: any) => api.get("/clients", { params }),
getById: (id: string) => api.get(`/clients/${id}`),
create: (data: any) => api.post("/clients", data),
update: (id: string, data: any) => api.put(`/clients/${id}`, data),
delete: (id: string) => api.delete(`/clients/${id}`),
addCountry: (id: string, data: any) => api.post(`/clients/${id}/countries`, data)
};
export const countryAPI = {
getAll: (params?: any) => api.get("/countries", { params }),
getById: (id: string) => api.get(`/countries/${id}`),
create: (data: any) => api.post("/countries", data),
update: (id: string, data: any) => api.put(`/countries/${id}`, data),
delete: (id: string) => api.delete(`/countries/${id}`)
};
export const sessionAPI = {
getAll: (params?: any) => api.get("/sessions", { params }),
getById: (id: string) => api.get(`/sessions/${id}`),
create: (data: any) => api.post("/sessions", data),
stop: (id: string) => api.post(`/sessions/${id}/stop`),
delete: (id: string) => api.delete(`/sessions/${id}`)
};
export const workflowAPI = {
getAll: (params?: any) => api.get("/workflows", { params }),
create: (data: any) => api.post("/workflows", data),
update: (id: string, data: any) => api.put(`/workflows/${id}`, data),
delete: (id: string) => api.delete(`/workflows/${id}`)
};
export const documentAPI = {
getAll: (params?: any) => api.get("/documents", { params }),
upload: (formData: FormData) => api.post("/documents", formData, { headers: { "Content-Type": "multipart/form-data" } }),
delete: (id: string) => api.delete(`/documents/${id}`),
download: (id: string) => api.get(`/documents/${id}/download`)
};
export const checkupAPI = {
run: (data: any) => api.post("/checkup/run", data)
};
export const bookingAPI = {
run: (data: any) => api.post("/booking/run", data)
};
export const adminAPI = {
getDashboard: () => api.get("/admin/dashboard"),
getUsers: () => api.get("/admin/users"),
updateRole: (id: string, role: string) => api.put(`/admin/users/${id}/role`, { role }),
toggleActive: (id: string) => api.put(`/admin/users/${id}/toggle`),
deleteUser: (id: string) => api.delete(`/admin/users/${id}`),
getSettings: () => api.get("/admin/settings")
};
export const notificationAPI = {
getAll: (params?: any) => api.get("/notifications", { params }),
getUnreadCount: () => api.get("/notifications/unread-count"),
markRead: (id: string) => api.put(`/notifications/${id}/read`),
markAllRead: () => api.put("/notifications/mark-all-read"),
delete: (id: string) => api.delete(`/notifications/${id}`)
};
export const auditAPI = {
getAll: (params?: any) => api.get("/audit-logs", { params })
};
export const systemAPI = {
getStatus: () => api.get("/system/status")
};
+18
View File
@@ -0,0 +1,18 @@
export const getToken = (): string | null => localStorage.getItem("token");
export const getUser = (): any | null => {
const user = localStorage.getItem("user");
return user ? JSON.parse(user) : null;
};
export const setAuth = (token: string, user: any): void => {
localStorage.setItem("token", token);
localStorage.setItem("user", JSON.stringify(user));
};
export const clearAuth = (): void => {
localStorage.removeItem("token");
localStorage.removeItem("user");
};
export const isAuthenticated = (): boolean => !!getToken();
export const hasRole = (role: string): boolean => {
const user = getUser();
return user?.role === role || user?.role === "admin";
};
+48
View File
@@ -0,0 +1,48 @@
import { clsx, type ClassValue } from "clsx";
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}
export function formatDate(date: string | Date): string {
if (!date) return "N/A";
const d = new Date(date);
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}
export function formatDateTime(date: string | Date): string {
if (!date) return "N/A";
const d = new Date(date);
return d.toLocaleString("en-US", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
}
export const priorityColors: Record<string, string> = {
low: "bg-green-500/20 text-green-400 border-green-500/30",
medium: "bg-blue-500/20 text-blue-400 border-blue-500/30",
high: "bg-orange-500/20 text-orange-400 border-orange-500/30",
urgent: "bg-red-500/20 text-red-400 border-red-500/30"
};
export const statusColors: Record<string, string> = {
active: "bg-green-500/20 text-green-400",
inactive: "bg-gray-500/20 text-gray-400",
completed: "bg-blue-500/20 text-blue-400",
suspended: "bg-red-500/20 text-red-400",
running: "bg-accent-cyan/20 text-cyan-400",
paused: "bg-yellow-500/20 text-yellow-400",
failed: "bg-red-500/20 text-red-400",
cancelled: "bg-gray-500/20 text-gray-400",
pending: "bg-yellow-500/20 text-yellow-400",
in_progress: "bg-blue-500/20 text-blue-400",
approved: "bg-green-500/20 text-green-400",
rejected: "bg-red-500/20 text-red-400"
};
import axios from 'axios';
export const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || '/api',
headers: {
'Content-Type': 'application/json'
}
});
+64
View File
@@ -0,0 +1,64 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #0a0a0f;
--foreground: #e2e8f0;
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: "Inter", system-ui, -apple-system, sans-serif;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #12121a;
}
::-webkit-scrollbar-thumb {
background: #2d2d44;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #3d3d5c;
}
.card {
@apply bg-dark-800 border border-dark-600 rounded-xl p-6;
}
.btn-primary {
@apply bg-accent-blue hover:bg-blue-600 text-white px-4 py-2 rounded-lg transition-all duration-200 font-medium;
}
.btn-danger {
@apply bg-accent-red hover:bg-red-600 text-white px-4 py-2 rounded-lg transition-all duration-200 font-medium;
}
.btn-success {
@apply bg-accent-green hover:bg-green-600 text-white px-4 py-2 rounded-lg transition-all duration-200 font-medium;
}
.input {
@apply bg-dark-700 border border-dark-500 rounded-lg px-4 py-2 text-white placeholder-gray-500 focus:outline-none focus:border-accent-blue transition-colors;
}
.select {
@apply bg-dark-700 border border-dark-500 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-accent-blue;
}
.label {
@apply block text-sm font-medium text-gray-400 mb-1;
}
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
+31
View File
@@ -0,0 +1,31 @@
/** @type {import(tailwindcss).Config} */
module.exports = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}"
],
darkMode: "class",
theme: {
extend: {
colors: {
dark: {
900: "#0a0a0f",
800: "#12121a",
700: "#1a1a2e",
600: "#24243a",
500: "#2d2d44"
},
accent: {
blue: "#3b82f6",
cyan: "#06b6d4",
purple: "#8b5cf6",
green: "#10b981",
red: "#ef4444",
orange: "#f59e0b"
}
}
}
},
plugins: []
};
+1
View File
@@ -0,0 +1 @@
{"compilerOptions":{"target":"ES2022","lib":["dom","dom.iterable","esnext"],"allowJs":true,"skipLibCheck":true,"strict":true,"noEmit":true,"esModuleInterop":true,"module":"esnext","moduleResolution":"bundler","resolveJsonModule":true,"isolatedModules":true,"jsx":"preserve","incremental":true,"plugins":[{"name":"next"}],"paths":{"@/*":["./src/*"]}},"include":["next-env.d.ts","**/*.ts","**/*.tsx",".next/types/**/*.ts"],"exclude":["node_modules"]}