Initial commit: tinovisas (visa management app)
This commit is contained in:
commit
ab0d059a63
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# deps
|
||||||
|
**/node_modules/
|
||||||
|
**/dist/
|
||||||
|
**/.next/
|
||||||
|
**/out/
|
||||||
|
**/build/
|
||||||
|
|
||||||
|
# env (jamais)
|
||||||
|
**/.env
|
||||||
|
**/.env.local
|
||||||
|
**/.env.*.local
|
||||||
|
*.env
|
||||||
|
|
||||||
|
# logs
|
||||||
|
**/*.log
|
||||||
|
**/npm-debug.log*
|
||||||
|
**/yarn-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# runtime data
|
||||||
|
postgres-data/
|
||||||
|
redis-data/
|
||||||
|
uploads/
|
||||||
|
screenshots/
|
||||||
|
backend/uploads/
|
||||||
|
|
||||||
|
# backups locaux
|
||||||
|
docker-compose.yml.bak*
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.cache/
|
||||||
|
coverage/
|
||||||
4
backend/.dockerignore
Normal file
4
backend/.dockerignore
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
26
backend/.env.example
Normal file
26
backend/.env.example
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
NODE_ENV=development
|
||||||
|
PORT=4000
|
||||||
|
API_URL=http://localhost:4000
|
||||||
|
|
||||||
|
DB_HOST=postgres
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=tinovisas
|
||||||
|
DB_USER=tinovisas
|
||||||
|
DB_PASSWORD=tinovisas_secret_2024
|
||||||
|
|
||||||
|
REDIS_HOST=redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
JWT_SECRET=tinovisas_super_secret_key_change_in_production
|
||||||
|
JWT_EXPIRES_IN=24h
|
||||||
|
|
||||||
|
ENCRYPTION_KEY=tinovisas_encryption_key_32chars!!
|
||||||
|
|
||||||
|
UPLOAD_DIR=/app/uploads
|
||||||
|
MAX_FILE_SIZE=10485760
|
||||||
|
|
||||||
|
PLAYWRIGHT_HEADLESS=true
|
||||||
|
PLAYWRIGHT_TIMEOUT=30000
|
||||||
|
|
||||||
|
DEFAULT_ADMIN_EMAIL=admin@tinovisas.com
|
||||||
|
DEFAULT_ADMIN_PASSWORD=Admin123!
|
||||||
17
backend/Dockerfile
Normal file
17
backend/Dockerfile
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
chromium \
|
||||||
|
nss \
|
||||||
|
freetype \
|
||||||
|
freetype-dev \
|
||||||
|
harfbuzz \
|
||||||
|
ca-certificates \
|
||||||
|
ttf-freefont
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/bin/chromium-browser
|
||||||
|
EXPOSE 4000
|
||||||
|
CMD ["npm", "start"]
|
||||||
2539
backend/package-lock.json
generated
Normal file
2539
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
backend/package.json
Normal file
1
backend/package.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"name":"tinovisas-backend","version":"1.0.0","scripts":{"build":"tsc","start":"node dist/server.js","dev":"ts-node-dev --respawn src/server.ts"},"dependencies":{"express":"^4.18.2","cors":"^2.8.5","helmet":"^7.1.0","bcryptjs":"^2.4.3","jsonwebtoken":"^9.0.2","pg":"^8.11.3","redis":"^4.6.10","playwright":"^1.40.1","multer":"^1.4.5-lts.1","express-rate-limit":"^7.1.5","dotenv":"^16.3.1","uuid":"^9.0.1","joi":"^17.11.0","morgan":"^1.10.0","date-fns":"^2.30.0","crypto-js":"^4.2.0"},"devDependencies":{"@types/node":"^20.10.4","@types/express":"^4.17.21","@types/cors":"^2.8.17","@types/bcryptjs":"^2.4.6","@types/jsonwebtoken":"^9.0.5","@types/multer":"^1.4.11","@types/uuid":"^9.0.7","@types/morgan":"^1.9.9","@types/crypto-js":"^4.2.1","@types/pg":"^8.10.9","typescript":"^5.3.3","ts-node-dev":"^2.0.0"}}
|
||||||
188
backend/src/config/database.ts
Normal file
188
backend/src/config/database.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { Pool, PoolClient } from "pg";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
let pool: Pool | null = null;
|
||||||
|
|
||||||
|
export const getPool = (): Pool => {
|
||||||
|
if (!pool) {
|
||||||
|
pool = new Pool({
|
||||||
|
host: env.DB_HOST,
|
||||||
|
port: env.DB_PORT,
|
||||||
|
database: env.DB_NAME,
|
||||||
|
user: env.DB_USER,
|
||||||
|
password: env.DB_PASSWORD,
|
||||||
|
max: 20,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on("error", (err) => {
|
||||||
|
console.error("Unexpected database error:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pool;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const connectDB = async (): Promise<void> => {
|
||||||
|
const client = await getPool().connect();
|
||||||
|
try {
|
||||||
|
const result = await client.query("SELECT NOW()");
|
||||||
|
console.log("Database connected:", result.rows[0].now);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const query = async (text: string, params?: any[]): Promise<any> => {
|
||||||
|
const client = await getPool().connect();
|
||||||
|
try {
|
||||||
|
const result = await client.query(text, params);
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getClient = async (): Promise<PoolClient> => {
|
||||||
|
return getPool().connect();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initDatabase = async (): Promise<void> => {
|
||||||
|
const client = await getPool().connect();
|
||||||
|
try {
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
first_name VARCHAR(100),
|
||||||
|
last_name VARCHAR(100),
|
||||||
|
role VARCHAR(20) DEFAULT operator CHECK (role IN (admin, operator, viewer)),
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
last_login TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS clients (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
last_name VARCHAR(100) NOT NULL,
|
||||||
|
email VARCHAR(255),
|
||||||
|
phone VARCHAR(50),
|
||||||
|
passport_number VARCHAR(100),
|
||||||
|
date_of_birth DATE,
|
||||||
|
nationality VARCHAR(100),
|
||||||
|
priority VARCHAR(20) DEFAULT medium CHECK (priority IN (low, medium, high, urgent)),
|
||||||
|
status VARCHAR(20) DEFAULT active CHECK (status IN (active, inactive, completed, suspended)),
|
||||||
|
notes TEXT,
|
||||||
|
created_by UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS countries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
code VARCHAR(10) NOT NULL UNIQUE,
|
||||||
|
flag_emoji VARCHAR(10),
|
||||||
|
vfs_url VARCHAR(500),
|
||||||
|
vfs_credentials JSONB,
|
||||||
|
requirements JSONB DEFAULT [],
|
||||||
|
processing_time VARCHAR(50),
|
||||||
|
visa_types JSONB DEFAULT [],
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS client_countries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
client_id UUID REFERENCES clients(id) ON DELETE CASCADE,
|
||||||
|
country_id UUID REFERENCES countries(id) ON DELETE CASCADE,
|
||||||
|
visa_type VARCHAR(50),
|
||||||
|
status VARCHAR(20) DEFAULT pending CHECK (status IN (pending, in_progress, approved, rejected, completed)),
|
||||||
|
appointment_date TIMESTAMP,
|
||||||
|
application_ref VARCHAR(100),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE(client_id, country_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
client_id UUID REFERENCES clients(id) ON DELETE SET NULL,
|
||||||
|
country_id UUID REFERENCES countries(id) ON DELETE SET NULL,
|
||||||
|
type VARCHAR(20) NOT NULL CHECK (type IN (checkup, booking)),
|
||||||
|
status VARCHAR(20) DEFAULT running CHECK (status IN (running, paused, completed, failed, cancelled)),
|
||||||
|
started_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
ended_at TIMESTAMP,
|
||||||
|
logs JSONB DEFAULT [],
|
||||||
|
screenshot_path VARCHAR(500),
|
||||||
|
error_message TEXT,
|
||||||
|
created_by UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workflows (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
country_id UUID REFERENCES countries(id) ON DELETE SET NULL,
|
||||||
|
type VARCHAR(20) NOT NULL CHECK (type IN (checkup, booking)),
|
||||||
|
steps JSONB NOT NULL DEFAULT [],
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_by UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
client_id UUID REFERENCES clients(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
file_path VARCHAR(500) NOT NULL,
|
||||||
|
file_type VARCHAR(50),
|
||||||
|
file_size INTEGER,
|
||||||
|
category VARCHAR(50) DEFAULT general,
|
||||||
|
uploaded_by UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
type VARCHAR(20) DEFAULT info CHECK (type IN (info, success, warning, error)),
|
||||||
|
is_read BOOLEAN DEFAULT false,
|
||||||
|
link VARCHAR(500),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
action VARCHAR(100) NOT NULL,
|
||||||
|
entity_type VARCHAR(50) NOT NULL,
|
||||||
|
entity_id UUID,
|
||||||
|
details JSONB,
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clients_status ON clients(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clients_priority ON clients(priority);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_type ON sessions(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(is_read);
|
||||||
|
`);
|
||||||
|
console.log("Database tables initialized");
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
29
backend/src/config/env.ts
Normal file
29
backend/src/config/env.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
export const env = {
|
||||||
|
NODE_ENV: process.env.NODE_ENV || "development",
|
||||||
|
PORT: parseInt(process.env.PORT || "4000"),
|
||||||
|
API_URL: process.env.API_URL || "http://localhost:4000",
|
||||||
|
|
||||||
|
DB_HOST: process.env.DB_HOST || "localhost",
|
||||||
|
DB_PORT: parseInt(process.env.DB_PORT || "5432"),
|
||||||
|
DB_NAME: process.env.DB_NAME || "tinovisas",
|
||||||
|
DB_USER: process.env.DB_USER || "tinovisas",
|
||||||
|
DB_PASSWORD: process.env.DB_PASSWORD || "tinovisas_secret",
|
||||||
|
|
||||||
|
REDIS_HOST: process.env.REDIS_HOST || "localhost",
|
||||||
|
REDIS_PORT: parseInt(process.env.REDIS_PORT || "6379"),
|
||||||
|
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
|
||||||
|
|
||||||
|
JWT_SECRET: process.env.JWT_SECRET || "default-secret-change-me",
|
||||||
|
JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN || "24h",
|
||||||
|
|
||||||
|
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY || "default-encryption-key-32chars!",
|
||||||
|
|
||||||
|
UPLOAD_DIR: process.env.UPLOAD_DIR || "/app/uploads",
|
||||||
|
MAX_FILE_SIZE: parseInt(process.env.MAX_FILE_SIZE || "10485760"),
|
||||||
|
|
||||||
|
PLAYWRIGHT_HEADLESS: process.env.PLAYWRIGHT_HEADLESS === "true",
|
||||||
|
PLAYWRIGHT_TIMEOUT: parseInt(process.env.PLAYWRIGHT_TIMEOUT || "30000"),
|
||||||
|
|
||||||
|
DEFAULT_ADMIN_EMAIL: process.env.DEFAULT_ADMIN_EMAIL || "admin@tinovisas.com",
|
||||||
|
DEFAULT_ADMIN_PASSWORD: process.env.DEFAULT_ADMIN_PASSWORD || "Admin123!"
|
||||||
|
};
|
||||||
27
backend/src/config/redis.ts
Normal file
27
backend/src/config/redis.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { createClient, RedisClientType } from "redis";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
let redisClient: RedisClientType | null = null;
|
||||||
|
|
||||||
|
export const getRedis = (): RedisClientType => {
|
||||||
|
if (!redisClient) {
|
||||||
|
redisClient = createClient({
|
||||||
|
socket: {
|
||||||
|
host: env.REDIS_HOST,
|
||||||
|
port: env.REDIS_PORT
|
||||||
|
},
|
||||||
|
password: env.REDIS_PASSWORD || undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
redisClient.on("error", (err) => {
|
||||||
|
console.error("Redis error:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return redisClient;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const connectRedis = async (): Promise<void> => {
|
||||||
|
const client = getRedis();
|
||||||
|
await client.connect();
|
||||||
|
console.log("Redis connected");
|
||||||
|
};
|
||||||
105
backend/src/controllers/admin.ts
Normal file
105
backend/src/controllers/admin.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
import { getAllUsers, updateUser, deleteUser, findUserById } from "../models/User";
|
||||||
|
import { getAllClients } from "../models/Client";
|
||||||
|
import { getAllSessions } from "../models/Session";
|
||||||
|
import { getAuditLogs } from "../models/AuditLog";
|
||||||
|
import { getAllCountries } from "../models/Country";
|
||||||
|
import { successResponse, errorResponse } from "../utils/response";
|
||||||
|
import { logAction } from "../services/auditService";
|
||||||
|
|
||||||
|
export const getDashboardStats = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { users } = await getAllUsers(1000, 0);
|
||||||
|
const { clients, total: totalClients } = await getAllClients({}, 1000, 0);
|
||||||
|
const { sessions, total: totalSessions } = await getAllSessions({}, 1000, 0);
|
||||||
|
const { countries } = await getAllCountries();
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total_users: users.length,
|
||||||
|
total_clients: totalClients,
|
||||||
|
total_sessions: totalSessions,
|
||||||
|
total_countries: countries.length,
|
||||||
|
active_sessions: sessions.filter((s: any) => s.status === "running").length,
|
||||||
|
clients_by_status: {
|
||||||
|
active: clients.filter((c: any) => c.status === "active").length,
|
||||||
|
inactive: clients.filter((c: any) => c.status === "inactive").length,
|
||||||
|
completed: clients.filter((c: any) => c.status === "completed").length
|
||||||
|
},
|
||||||
|
sessions_by_type: {
|
||||||
|
checkup: sessions.filter((s: any) => s.type === "checkup").length,
|
||||||
|
booking: sessions.filter((s: any) => s.type === "booking").length
|
||||||
|
},
|
||||||
|
recent_sessions: sessions.slice(0, 10),
|
||||||
|
users_by_role: {
|
||||||
|
admin: users.filter((u: any) => u.role === "admin").length,
|
||||||
|
operator: users.filter((u: any) => u.role === "operator").length,
|
||||||
|
viewer: users.filter((u: any) => u.role === "viewer").length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await logAction(req, "VIEW", "admin_dashboard", undefined, { stats });
|
||||||
|
return successResponse(res, stats);
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAdminUsers = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { users, total } = await getAllUsers();
|
||||||
|
return successResponse(res, users, undefined, { total });
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateUserRole = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
const user = await updateUser(req.params.id, { role: req.body.role });
|
||||||
|
await logAction(req, "UPDATE_ROLE", "user", req.params.id, { new_role: req.body.role });
|
||||||
|
return successResponse(res, user, "User role updated");
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleUserActive = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
const user = await findUserById(req.params.id);
|
||||||
|
if (!user) return errorResponse(res, "User not found", 404);
|
||||||
|
|
||||||
|
const updated = await updateUser(req.params.id, { is_active: !user.is_active });
|
||||||
|
await logAction(req, "TOGGLE_ACTIVE", "user", req.params.id, { is_active: !user.is_active });
|
||||||
|
return successResponse(res, updated, `User ${!user.is_active ? "activated" : "deactivated"}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteUserHandler = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
await deleteUser(req.params.id);
|
||||||
|
await logAction(req, "DELETE", "user", req.params.id);
|
||||||
|
return successResponse(res, null, "User deleted");
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSystemSettings = async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
return successResponse(res, {
|
||||||
|
app_name: "Tinovisas",
|
||||||
|
version: "1.0.0",
|
||||||
|
features: {
|
||||||
|
checkup: true,
|
||||||
|
booking: true,
|
||||||
|
workflows: true,
|
||||||
|
documents: true,
|
||||||
|
notifications: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
25
backend/src/controllers/auditLogs.ts
Normal file
25
backend/src/controllers/auditLogs.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
import { getAuditLogs } from "../models/AuditLog";
|
||||||
|
import { successResponse, errorResponse } from "../utils/response";
|
||||||
|
|
||||||
|
export const getAuditLogsHandler = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { user_id, entity_type, action, page = "1", limit = "50" } = req.query;
|
||||||
|
const filters: any = {};
|
||||||
|
if (user_id) filters.user_id = user_id;
|
||||||
|
if (entity_type) filters.entity_type = entity_type;
|
||||||
|
if (action) filters.action = action;
|
||||||
|
|
||||||
|
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
|
||||||
|
const result = await getAuditLogs(filters, parseInt(limit as string), offset);
|
||||||
|
|
||||||
|
return successResponse(res, result.logs, undefined, {
|
||||||
|
page: parseInt(page as string),
|
||||||
|
limit: parseInt(limit as string),
|
||||||
|
total: result.total,
|
||||||
|
totalPages: Math.ceil(result.total / parseInt(limit as string))
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
192
backend/src/controllers/auth.ts
Normal file
192
backend/src/controllers/auth.ts
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
import { findUserByEmail, createUser, updateLastLogin, getAllUsers, updateUser, deleteUser, findUserById } from "../models/User";
|
||||||
|
import { generateToken } from "../utils/jwt";
|
||||||
|
import { hashPassword, comparePassword } from "../utils/password";
|
||||||
|
import { successResponse, errorResponse } from "../utils/response";
|
||||||
|
import { env } from "../config/env";
|
||||||
|
import Joi from "joi";
|
||||||
|
|
||||||
|
const loginSchema = Joi.object({
|
||||||
|
email: Joi.string().email().required(),
|
||||||
|
password: Joi.string().required()
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerSchema = Joi.object({
|
||||||
|
email: Joi.string().email().required(),
|
||||||
|
password: Joi.string().min(6).required(),
|
||||||
|
first_name: Joi.string().allow(""),
|
||||||
|
last_name: Joi.string().allow(""),
|
||||||
|
role: Joi.string().valid("admin", "operator", "viewer").default("operator")
|
||||||
|
});
|
||||||
|
|
||||||
|
export const login = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { error } = loginSchema.validate(req.body);
|
||||||
|
if (error) return errorResponse(res, error.details[0].message, 400);
|
||||||
|
|
||||||
|
const { email, password } = req.body;
|
||||||
|
const user = await findUserByEmail(email);
|
||||||
|
|
||||||
|
if (!user || !user.is_active) {
|
||||||
|
return errorResponse(res, "Invalid credentials", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await comparePassword(password, user.password_hash);
|
||||||
|
if (!valid) return errorResponse(res, "Invalid credentials", 401);
|
||||||
|
|
||||||
|
await updateLastLogin(user.id);
|
||||||
|
|
||||||
|
const token = generateToken({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role
|
||||||
|
});
|
||||||
|
|
||||||
|
return successResponse(res, {
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
first_name: user.first_name,
|
||||||
|
last_name: user.last_name,
|
||||||
|
role: user.role
|
||||||
|
}
|
||||||
|
}, "Login successful");
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const register = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { error } = registerSchema.validate(req.body);
|
||||||
|
if (error) return errorResponse(res, error.details[0].message, 400);
|
||||||
|
|
||||||
|
const { email, password, first_name, last_name, role } = req.body;
|
||||||
|
|
||||||
|
const existing = await findUserByEmail(email);
|
||||||
|
if (existing) return errorResponse(res, "Email already registered", 409);
|
||||||
|
|
||||||
|
const password_hash = await hashPassword(password);
|
||||||
|
const user = await createUser({ email, password_hash, first_name, last_name, role });
|
||||||
|
|
||||||
|
const token = generateToken({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role
|
||||||
|
});
|
||||||
|
|
||||||
|
return successResponse(res, {
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
first_name: user.first_name,
|
||||||
|
last_name: user.last_name,
|
||||||
|
role: user.role
|
||||||
|
}
|
||||||
|
}, "Registration successful", 201);
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMe = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
const user = await findUserById(req.user.userId);
|
||||||
|
if (!user) return errorResponse(res, "User not found", 404);
|
||||||
|
|
||||||
|
return successResponse(res, {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
first_name: user.first_name,
|
||||||
|
last_name: user.last_name,
|
||||||
|
role: user.role,
|
||||||
|
is_active: user.is_active,
|
||||||
|
last_login: user.last_login,
|
||||||
|
created_at: user.created_at
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUsers = async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { users, total } = await getAllUsers();
|
||||||
|
return successResponse(res, users, undefined, { total });
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateUserProfile = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const updates = req.body;
|
||||||
|
|
||||||
|
if (updates.password) {
|
||||||
|
updates.password_hash = await hashPassword(updates.password);
|
||||||
|
delete updates.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await updateUser(id, updates);
|
||||||
|
return successResponse(res, user, "User updated");
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteUserAccount = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
await deleteUser(req.params.id);
|
||||||
|
return successResponse(res, null, "User deleted");
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const guestLogin = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const user = await findUserByEmail("guest@tinovisas.com");
|
||||||
|
if (!user) {
|
||||||
|
return errorResponse(res, "Guest account not available", 503);
|
||||||
|
}
|
||||||
|
const token = generateToken({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role
|
||||||
|
});
|
||||||
|
return successResponse(res, {
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
first_name: user.first_name,
|
||||||
|
last_name: user.last_name,
|
||||||
|
role: user.role
|
||||||
|
}
|
||||||
|
}, "Guest login successful");
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDefaultAdmin = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const existing = await findUserByEmail(env.DEFAULT_ADMIN_EMAIL);
|
||||||
|
if (!existing) {
|
||||||
|
const password_hash = await hashPassword(env.DEFAULT_ADMIN_PASSWORD);
|
||||||
|
await createUser({
|
||||||
|
email: env.DEFAULT_ADMIN_EMAIL,
|
||||||
|
password_hash,
|
||||||
|
first_name: "Admin",
|
||||||
|
last_name: "User",
|
||||||
|
role: "admin"
|
||||||
|
});
|
||||||
|
console.log("Default admin created:", env.DEFAULT_ADMIN_EMAIL);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create default admin:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
79
backend/src/controllers/booking.ts
Normal file
79
backend/src/controllers/booking.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
import { getBrowser, createPage, navigateTo, fillForm, clickElement, takeScreenshot, waitForElement } from "../services/playwright";
|
||||||
|
import { createSession, updateSession } from "../models/Session";
|
||||||
|
import { findClientById } from "../models/Client";
|
||||||
|
import { findCountryById } from "../models/Country";
|
||||||
|
import { addLog, isSessionAborted } from "../services/sessionManager";
|
||||||
|
import { sendNotification } from "../services/notificationService";
|
||||||
|
import { successResponse, errorResponse } from "../utils/response";
|
||||||
|
import { logAction } from "../services/auditService";
|
||||||
|
import path from "path";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
const screenshotsDir = "/app/uploads/screenshots";
|
||||||
|
if (!fs.existsSync(screenshotsDir)) fs.mkdirSync(screenshotsDir, { recursive: true });
|
||||||
|
|
||||||
|
export const runBooking = async (req: any, res: Response) => {
|
||||||
|
const { client_id, country_id, preferred_date, visa_type } = req.body;
|
||||||
|
const sessionId = uuidv4();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await createSession({
|
||||||
|
id: sessionId,
|
||||||
|
client_id,
|
||||||
|
country_id,
|
||||||
|
type: "booking",
|
||||||
|
status: "running",
|
||||||
|
created_by: req.user.userId
|
||||||
|
});
|
||||||
|
|
||||||
|
addLog(sessionId, { message: "Booking session started", level: "info" });
|
||||||
|
|
||||||
|
const client = await findClientById(client_id);
|
||||||
|
const country = await findCountryById(country_id);
|
||||||
|
|
||||||
|
if (!country?.vfs_url) {
|
||||||
|
await updateSession(sessionId, { status: "failed", error_message: "No VFS URL configured" });
|
||||||
|
return errorResponse(res, "No VFS URL configured", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await getBrowser();
|
||||||
|
const { page, context } = await createPage(browser);
|
||||||
|
|
||||||
|
try {
|
||||||
|
addLog(sessionId, { message: `Navigating to ${country.vfs_url}`, level: "info" });
|
||||||
|
await navigateTo(page, country.vfs_url);
|
||||||
|
|
||||||
|
const screenshotPath = path.join(screenshotsDir, `${sessionId}_booking.png`);
|
||||||
|
await takeScreenshot(page, screenshotPath);
|
||||||
|
|
||||||
|
// Check if aborted
|
||||||
|
if (isSessionAborted(sessionId)) {
|
||||||
|
await updateSession(sessionId, { status: "cancelled", ended_at: new Date() });
|
||||||
|
return successResponse(res, null, "Booking cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog(sessionId, { message: "Booking process simulated (actual VFS implementation required)", level: "info" });
|
||||||
|
|
||||||
|
await updateSession(sessionId, {
|
||||||
|
status: "completed",
|
||||||
|
ended_at: new Date(),
|
||||||
|
logs: [{ message: "Booking process completed", preferred_date, visa_type, timestamp: new Date().toISOString() }]
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendNotification(req.user.userId, "Booking Complete", `Booking attempt for ${country.name} completed.`, "success", `/sessions/${sessionId}`);
|
||||||
|
await logAction(req, "BOOKING", "session", sessionId, { country: country.name, client: `${client?.first_name} ${client?.last_name}`, preferred_date });
|
||||||
|
|
||||||
|
return successResponse(res, { session_id: sessionId, screenshot: screenshotPath }, "Booking process completed");
|
||||||
|
} catch (error: any) {
|
||||||
|
await updateSession(sessionId, { status: "failed", error_message: error.message, ended_at: new Date() });
|
||||||
|
addLog(sessionId, { message: `Error: ${error.message}`, level: "error" });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
108
backend/src/controllers/checkup.ts
Normal file
108
backend/src/controllers/checkup.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
import { getBrowser, createPage, navigateTo, takeScreenshot, getText, checkForText, waitForElement } from "../services/playwright";
|
||||||
|
import { createSession, updateSession } from "../models/Session";
|
||||||
|
import { findClientById } from "../models/Client";
|
||||||
|
import { findCountryById } from "../models/Country";
|
||||||
|
import { addLog, isSessionAborted } from "../services/sessionManager";
|
||||||
|
import { sendNotification } from "../services/notificationService";
|
||||||
|
import { successResponse, errorResponse } from "../utils/response";
|
||||||
|
import { logAction } from "../services/auditService";
|
||||||
|
import path from "path";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
const screenshotsDir = "/app/uploads/screenshots";
|
||||||
|
if (!fs.existsSync(screenshotsDir)) fs.mkdirSync(screenshotsDir, { recursive: true });
|
||||||
|
|
||||||
|
export const runCheckup = async (req: any, res: Response) => {
|
||||||
|
const { client_id, country_id } = req.body;
|
||||||
|
const sessionId = uuidv4();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create session record
|
||||||
|
const session = await createSession({
|
||||||
|
id: sessionId,
|
||||||
|
client_id,
|
||||||
|
country_id,
|
||||||
|
type: "checkup",
|
||||||
|
status: "running",
|
||||||
|
created_by: req.user.userId
|
||||||
|
});
|
||||||
|
|
||||||
|
addLog(sessionId, { message: "Checkup session started", level: "info" });
|
||||||
|
|
||||||
|
// Get client and country info
|
||||||
|
const client = await findClientById(client_id);
|
||||||
|
const country = await findCountryById(country_id);
|
||||||
|
|
||||||
|
if (!country?.vfs_url) {
|
||||||
|
await updateSession(sessionId, { status: "failed", error_message: "No VFS URL configured for this country" });
|
||||||
|
return errorResponse(res, "No VFS URL configured", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start browser automation
|
||||||
|
const browser = await getBrowser();
|
||||||
|
const { page, context } = await createPage(browser);
|
||||||
|
|
||||||
|
try {
|
||||||
|
addLog(sessionId, { message: `Navigating to ${country.vfs_url}`, level: "info" });
|
||||||
|
await navigateTo(page, country.vfs_url);
|
||||||
|
|
||||||
|
// Take initial screenshot
|
||||||
|
const screenshotPath = path.join(screenshotsDir, `${sessionId}_initial.png`);
|
||||||
|
await takeScreenshot(page, screenshotPath);
|
||||||
|
await updateSession(sessionId, { screenshot_path: screenshotPath });
|
||||||
|
|
||||||
|
addLog(sessionId, { message: "Page loaded successfully", level: "success" });
|
||||||
|
|
||||||
|
// Check for common appointment indicators
|
||||||
|
const hasAppointments = await checkForText(page, "appointment") || await checkForText(page, "slot");
|
||||||
|
addLog(sessionId, { message: `Appointment indicators found: ${hasAppointments}`, level: hasAppointments ? "success" : "warning" });
|
||||||
|
|
||||||
|
// Try to extract available dates info
|
||||||
|
let availableDates: string[] = [];
|
||||||
|
try {
|
||||||
|
const dateElements = await page.locator("[data-date], .date-available, .calendar-day").all();
|
||||||
|
for (const el of dateElements.slice(0, 10)) {
|
||||||
|
const text = await el.textContent();
|
||||||
|
if (text) availableDates.push(text.trim());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
addLog(sessionId, { message: "Could not extract date information", level: "warning" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final screenshot
|
||||||
|
const finalScreenshot = path.join(screenshotsDir, `${sessionId}_final.png`);
|
||||||
|
await takeScreenshot(page, finalScreenshot);
|
||||||
|
|
||||||
|
// Complete session
|
||||||
|
await updateSession(sessionId, {
|
||||||
|
status: "completed",
|
||||||
|
ended_at: new Date(),
|
||||||
|
logs: [{ message: "Checkup completed", available_dates: availableDates, timestamp: new Date().toISOString() }]
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendNotification(req.user.userId, "Checkup Complete", `Checkup for ${country.name} completed. ${availableDates.length} dates found.`, "success", `/sessions/${sessionId}`);
|
||||||
|
await logAction(req, "CHECKUP", "session", sessionId, { country: country.name, client: `${client?.first_name} ${client?.last_name}` });
|
||||||
|
|
||||||
|
return successResponse(res, { session_id: sessionId, available_dates: availableDates, screenshots: [screenshotPath, finalScreenshot] }, "Checkup completed");
|
||||||
|
} catch (error: any) {
|
||||||
|
await updateSession(sessionId, { status: "failed", error_message: error.message, ended_at: new Date() });
|
||||||
|
addLog(sessionId, { message: `Error: ${error.message}`, level: "error" });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCheckupStatus = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
// Implementation for getting status
|
||||||
|
return successResponse(res, { status: "ok" });
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
113
backend/src/controllers/clients.ts
Normal file
113
backend/src/controllers/clients.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
import { createClient, findClientById, updateClient, deleteClient, getAllClients, addClientCountry, getClientCountries, updateClientCountry, removeClientCountry } from "../models/Client";
|
||||||
|
import { successResponse, errorResponse } from "../utils/response";
|
||||||
|
import { logAction } from "../services/auditService";
|
||||||
|
import Joi from "joi";
|
||||||
|
|
||||||
|
const clientSchema = Joi.object({
|
||||||
|
first_name: Joi.string().required(),
|
||||||
|
last_name: Joi.string().required(),
|
||||||
|
email: Joi.string().email().allow("", null),
|
||||||
|
phone: Joi.string().allow("", null),
|
||||||
|
passport_number: Joi.string().allow("", null),
|
||||||
|
date_of_birth: Joi.date().allow(null),
|
||||||
|
nationality: Joi.string().allow("", null),
|
||||||
|
priority: Joi.string().valid("low", "medium", "high", "urgent").default("medium"),
|
||||||
|
status: Joi.string().valid("active", "inactive", "completed", "suspended").default("active"),
|
||||||
|
notes: Joi.string().allow("", null)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createClientHandler = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { error } = clientSchema.validate(req.body);
|
||||||
|
if (error) return errorResponse(res, error.details[0].message, 400);
|
||||||
|
|
||||||
|
const client = await createClient({ ...req.body, created_by: req.user.userId });
|
||||||
|
await logAction(req, "CREATE", "client", client.id, { name: `${client.first_name} ${client.last_name}` });
|
||||||
|
|
||||||
|
return successResponse(res, client, "Client created", 201);
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getClients = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { status, priority, search, page = "1", limit = "50" } = req.query;
|
||||||
|
const filters: any = {};
|
||||||
|
if (status) filters.status = status;
|
||||||
|
if (priority) filters.priority = priority;
|
||||||
|
if (search) filters.search = search;
|
||||||
|
|
||||||
|
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
|
||||||
|
const result = await getAllClients(filters, parseInt(limit as string), offset);
|
||||||
|
|
||||||
|
return successResponse(res, result.clients, undefined, {
|
||||||
|
page: parseInt(page as string),
|
||||||
|
limit: parseInt(limit as string),
|
||||||
|
total: result.total,
|
||||||
|
totalPages: Math.ceil(result.total / parseInt(limit as string))
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getClient = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const client = await findClientById(req.params.id);
|
||||||
|
if (!client) return errorResponse(res, "Client not found", 404);
|
||||||
|
|
||||||
|
const countries = await getClientCountries(req.params.id);
|
||||||
|
return successResponse(res, { ...client, countries });
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateClientHandler = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
const client = await updateClient(req.params.id, req.body);
|
||||||
|
await logAction(req, "UPDATE", "client", client.id, req.body);
|
||||||
|
return successResponse(res, client, "Client updated");
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteClientHandler = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
await deleteClient(req.params.id);
|
||||||
|
await logAction(req, "DELETE", "client", req.params.id);
|
||||||
|
return successResponse(res, null, "Client deleted");
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addCountryToClient = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
const cc = await addClientCountry({ ...req.body, client_id: req.params.id });
|
||||||
|
return successResponse(res, cc, "Country added to client", 201);
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateClientCountryHandler = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
const cc = await updateClientCountry(req.params.countryId, req.body);
|
||||||
|
return successResponse(res, cc, "Client country updated");
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeClientCountryHandler = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
await removeClientCountry(req.params.countryId);
|
||||||
|
return successResponse(res, null, "Country removed from client");
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
70
backend/src/controllers/countries.ts
Normal file
70
backend/src/controllers/countries.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
import { createCountry, findCountryById, updateCountry, deleteCountry, getAllCountries } from "../models/Country";
|
||||||
|
import { successResponse, errorResponse } from "../utils/response";
|
||||||
|
import { logAction } from "../services/auditService";
|
||||||
|
import Joi from "joi";
|
||||||
|
|
||||||
|
const countrySchema = Joi.object({
|
||||||
|
name: Joi.string().required(),
|
||||||
|
code: Joi.string().required(),
|
||||||
|
flag_emoji: Joi.string().allow("", null),
|
||||||
|
vfs_url: Joi.string().uri().allow("", null),
|
||||||
|
vfs_credentials: Joi.object().allow(null),
|
||||||
|
requirements: Joi.array().items(Joi.string()).default([]),
|
||||||
|
processing_time: Joi.string().allow("", null),
|
||||||
|
visa_types: Joi.array().items(Joi.string()).default([]),
|
||||||
|
is_active: Joi.boolean().default(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createCountryHandler = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { error } = countrySchema.validate(req.body);
|
||||||
|
if (error) return errorResponse(res, error.details[0].message, 400);
|
||||||
|
|
||||||
|
const country = await createCountry(req.body);
|
||||||
|
await logAction(req, "CREATE", "country", country.id, { name: country.name, code: country.code });
|
||||||
|
return successResponse(res, country, "Country created", 201);
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCountries = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const activeOnly = req.query.active === "true";
|
||||||
|
const countries = await getAllCountries(activeOnly);
|
||||||
|
return successResponse(res, countries);
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCountry = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const country = await findCountryById(req.params.id);
|
||||||
|
if (!country) return errorResponse(res, "Country not found", 404);
|
||||||
|
return successResponse(res, country);
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateCountryHandler = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
const country = await updateCountry(req.params.id, req.body);
|
||||||
|
await logAction(req, "UPDATE", "country", country.id, req.body);
|
||||||
|
return successResponse(res, country, "Country updated");
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteCountryHandler = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
await deleteCountry(req.params.id);
|
||||||
|
await logAction(req, "DELETE", "country", req.params.id);
|
||||||
|
return successResponse(res, null, "Country deleted");
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
71
backend/src/controllers/documents.ts
Normal file
71
backend/src/controllers/documents.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
import { createDocument, findDocumentById, deleteDocument, getDocumentsByClient } from "../models/Document";
|
||||||
|
import { successResponse, errorResponse } from "../utils/response";
|
||||||
|
import { logAction } from "../services/auditService";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { env } from "../config/env";
|
||||||
|
|
||||||
|
export const uploadDocument = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) return errorResponse(res, "No file uploaded", 400);
|
||||||
|
|
||||||
|
const { client_id, category = "general" } = req.body;
|
||||||
|
const doc = await createDocument({
|
||||||
|
client_id,
|
||||||
|
name: req.file.originalname,
|
||||||
|
file_path: req.file.filename,
|
||||||
|
file_type: req.file.mimetype,
|
||||||
|
file_size: req.file.size,
|
||||||
|
category,
|
||||||
|
uploaded_by: req.user.userId
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAction(req, "UPLOAD", "document", doc.id, { name: req.file.originalname, client_id });
|
||||||
|
return successResponse(res, doc, "Document uploaded", 201);
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDocuments = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { client_id } = req.query;
|
||||||
|
if (!client_id) return errorResponse(res, "client_id required", 400);
|
||||||
|
|
||||||
|
const documents = await getDocumentsByClient(client_id as string);
|
||||||
|
return successResponse(res, documents);
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteDocumentHandler = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
const doc = await findDocumentById(req.params.id);
|
||||||
|
if (!doc) return errorResponse(res, "Document not found", 404);
|
||||||
|
|
||||||
|
const filePath = path.join(env.UPLOAD_DIR, doc.file_path);
|
||||||
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||||
|
|
||||||
|
await deleteDocument(req.params.id);
|
||||||
|
await logAction(req, "DELETE", "document", req.params.id);
|
||||||
|
return successResponse(res, null, "Document deleted");
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const downloadDocument = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const doc = await findDocumentById(req.params.id);
|
||||||
|
if (!doc) return errorResponse(res, "Document not found", 404);
|
||||||
|
|
||||||
|
const filePath = path.join(env.UPLOAD_DIR, doc.file_path);
|
||||||
|
if (!fs.existsSync(filePath)) return errorResponse(res, "File not found", 404);
|
||||||
|
|
||||||
|
res.download(filePath, doc.name);
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
50
backend/src/controllers/notifications.ts
Normal file
50
backend/src/controllers/notifications.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
import { getNotificationsByUser, markAsRead, markAllAsRead, deleteNotification } from "../models/Notification";
|
||||||
|
import { getUnreadCount } from "../services/notificationService";
|
||||||
|
import { successResponse, errorResponse } from "../utils/response";
|
||||||
|
|
||||||
|
export const getNotifications = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
const unreadOnly = req.query.unread === "true";
|
||||||
|
const notifications = await getNotificationsByUser(req.user.userId, unreadOnly);
|
||||||
|
return successResponse(res, notifications);
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUnreadCountHandler = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
const count = await getUnreadCount(req.user.userId);
|
||||||
|
return successResponse(res, { count });
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markNotificationRead = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
await markAsRead(req.params.id);
|
||||||
|
return successResponse(res, null, "Notification marked as read");
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markAllNotificationsRead = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
await markAllAsRead(req.user.userId);
|
||||||
|
return successResponse(res, null, "All notifications marked as read");
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteNotificationHandler = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
await deleteNotification(req.params.id);
|
||||||
|
return successResponse(res, null, "Notification deleted");
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
79
backend/src/controllers/sessions.ts
Normal file
79
backend/src/controllers/sessions.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
import { createSession, findSessionById, updateSession, deleteSession, getAllSessions } from "../models/Session";
|
||||||
|
import { successResponse, errorResponse } from "../utils/response";
|
||||||
|
import { startSession, stopSession, clearSession } from "../services/sessionManager";
|
||||||
|
import { logAction } from "../services/auditService";
|
||||||
|
|
||||||
|
export const createSessionHandler = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
const session = await createSession({ ...req.body, created_by: req.user.userId });
|
||||||
|
startSession(session.id);
|
||||||
|
await logAction(req, "CREATE", "session", session.id, req.body);
|
||||||
|
return successResponse(res, session, "Session created", 201);
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSessions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { status, type, client_id, page = "1", limit = "50" } = req.query;
|
||||||
|
const filters: any = {};
|
||||||
|
if (status) filters.status = status;
|
||||||
|
if (type) filters.type = type;
|
||||||
|
if (client_id) filters.client_id = client_id;
|
||||||
|
|
||||||
|
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
|
||||||
|
const result = await getAllSessions(filters, parseInt(limit as string), offset);
|
||||||
|
|
||||||
|
return successResponse(res, result.sessions, undefined, {
|
||||||
|
page: parseInt(page as string),
|
||||||
|
limit: parseInt(limit as string),
|
||||||
|
total: result.total,
|
||||||
|
totalPages: Math.ceil(result.total / parseInt(limit as string))
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSession = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const session = await findSessionById(req.params.id);
|
||||||
|
if (!session) return errorResponse(res, "Session not found", 404);
|
||||||
|
return successResponse(res, session);
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateSessionHandler = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
const session = await updateSession(req.params.id, req.body);
|
||||||
|
return successResponse(res, session, "Session updated");
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stopSessionHandler = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
stopSession(req.params.id);
|
||||||
|
await updateSession(req.params.id, { status: "cancelled", ended_at: new Date() });
|
||||||
|
await logAction(req, "STOP", "session", req.params.id);
|
||||||
|
return successResponse(res, null, "Session stopped");
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteSessionHandler = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
clearSession(req.params.id);
|
||||||
|
await deleteSession(req.params.id);
|
||||||
|
await logAction(req, "DELETE", "session", req.params.id);
|
||||||
|
return successResponse(res, null, "Session deleted");
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
54
backend/src/controllers/system.ts
Normal file
54
backend/src/controllers/system.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
import { getPool } from "../config/database";
|
||||||
|
import { getRedis } from "../config/redis";
|
||||||
|
import { successResponse, errorResponse } from "../utils/response";
|
||||||
|
|
||||||
|
export const getSystemStatus = async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
// Check database
|
||||||
|
let dbStatus = "healthy";
|
||||||
|
let dbLatency = 0;
|
||||||
|
try {
|
||||||
|
const start = Date.now();
|
||||||
|
const client = await getPool().connect();
|
||||||
|
await client.query("SELECT 1");
|
||||||
|
client.release();
|
||||||
|
dbLatency = Date.now() - start;
|
||||||
|
} catch {
|
||||||
|
dbStatus = "unhealthy";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Redis
|
||||||
|
let redisStatus = "healthy";
|
||||||
|
let redisLatency = 0;
|
||||||
|
try {
|
||||||
|
const start = Date.now();
|
||||||
|
const redis = getRedis();
|
||||||
|
await redis.ping();
|
||||||
|
redisLatency = Date.now() - start;
|
||||||
|
} catch {
|
||||||
|
redisStatus = "unhealthy";
|
||||||
|
}
|
||||||
|
|
||||||
|
// System info
|
||||||
|
const uptime = process.uptime();
|
||||||
|
const memory = process.memoryUsage();
|
||||||
|
|
||||||
|
return successResponse(res, {
|
||||||
|
status: dbStatus === "healthy" && redisStatus === "healthy" ? "healthy" : "degraded",
|
||||||
|
services: {
|
||||||
|
database: { status: dbStatus, latency_ms: dbLatency },
|
||||||
|
redis: { status: redisStatus, latency_ms: redisLatency },
|
||||||
|
api: { status: "healthy", uptime_seconds: uptime }
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
used_mb: Math.round(memory.heapUsed / 1024 / 1024),
|
||||||
|
total_mb: Math.round(memory.heapTotal / 1024 / 1024),
|
||||||
|
rss_mb: Math.round(memory.rss / 1024 / 1024)
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
70
backend/src/controllers/workflows.ts
Normal file
70
backend/src/controllers/workflows.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
import { createWorkflow, findWorkflowById, updateWorkflow, deleteWorkflow, getAllWorkflows } from "../models/Workflow";
|
||||||
|
import { successResponse, errorResponse } from "../utils/response";
|
||||||
|
import { logAction } from "../services/auditService";
|
||||||
|
import Joi from "joi";
|
||||||
|
|
||||||
|
const workflowSchema = Joi.object({
|
||||||
|
name: Joi.string().required(),
|
||||||
|
country_id: Joi.string().uuid().allow(null),
|
||||||
|
type: Joi.string().valid("checkup", "booking").required(),
|
||||||
|
steps: Joi.array().items(Joi.object()).default([]),
|
||||||
|
is_active: Joi.boolean().default(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createWorkflowHandler = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { error } = workflowSchema.validate(req.body);
|
||||||
|
if (error) return errorResponse(res, error.details[0].message, 400);
|
||||||
|
|
||||||
|
const workflow = await createWorkflow({ ...req.body, created_by: req.user.userId });
|
||||||
|
await logAction(req, "CREATE", "workflow", workflow.id, { name: workflow.name });
|
||||||
|
return successResponse(res, workflow, "Workflow created", 201);
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getWorkflows = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { type, country_id } = req.query;
|
||||||
|
const filters: any = {};
|
||||||
|
if (type) filters.type = type;
|
||||||
|
if (country_id) filters.country_id = country_id;
|
||||||
|
|
||||||
|
const workflows = await getAllWorkflows(filters);
|
||||||
|
return successResponse(res, workflows);
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getWorkflow = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const workflow = await findWorkflowById(req.params.id);
|
||||||
|
if (!workflow) return errorResponse(res, "Workflow not found", 404);
|
||||||
|
return successResponse(res, workflow);
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateWorkflowHandler = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
const workflow = await updateWorkflow(req.params.id, req.body);
|
||||||
|
await logAction(req, "UPDATE", "workflow", workflow.id, req.body);
|
||||||
|
return successResponse(res, workflow, "Workflow updated");
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteWorkflowHandler = async (req: any, res: Response) => {
|
||||||
|
try {
|
||||||
|
await deleteWorkflow(req.params.id);
|
||||||
|
await logAction(req, "DELETE", "workflow", req.params.id);
|
||||||
|
return successResponse(res, null, "Workflow deleted");
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
42
backend/src/middleware/auth.ts
Normal file
42
backend/src/middleware/auth.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { env } from "../config/env";
|
||||||
|
import { findUserById } from "../models/User";
|
||||||
|
import { JWTPayload } from "../types";
|
||||||
|
|
||||||
|
export interface AuthenticatedRequest extends Request {
|
||||||
|
user?: JWTPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authenticate = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const token = req.headers.authorization?.replace("Bearer ", "");
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ success: false, message: "Authentication required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = jwt.verify(token, env.JWT_SECRET) as JWTPayload;
|
||||||
|
const user = await findUserById(decoded.userId);
|
||||||
|
|
||||||
|
if (!user || !user.is_active) {
|
||||||
|
return res.status(401).json({ success: false, message: "User not found or inactive" });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = decoded;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(401).json({ success: false, message: "Invalid token" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const authorize = (...roles: string[]) => {
|
||||||
|
return (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ success: false, message: "Authentication required" });
|
||||||
|
}
|
||||||
|
if (!roles.includes(req.user.role)) {
|
||||||
|
return res.status(403).json({ success: false, message: "Insufficient permissions" });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
22
backend/src/middleware/errorHandler.ts
Normal file
22
backend/src/middleware/errorHandler.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
|
||||||
|
export const errorHandler = (err: any, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
|
console.error("Error:", err);
|
||||||
|
|
||||||
|
if (err.code === "23505") {
|
||||||
|
return res.status(409).json({ success: false, message: "Duplicate entry found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.code === "23503") {
|
||||||
|
return res.status(400).json({ success: false, message: "Referenced record not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCode = err.statusCode || err.status || 500;
|
||||||
|
const message = err.message || "Internal server error";
|
||||||
|
|
||||||
|
res.status(statusCode).json({
|
||||||
|
success: false,
|
||||||
|
message,
|
||||||
|
...(process.env.NODE_ENV === "development" && { stack: err.stack })
|
||||||
|
});
|
||||||
|
};
|
||||||
15
backend/src/middleware/rateLimiter.ts
Normal file
15
backend/src/middleware/rateLimiter.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import rateLimit from "express-rate-limit";
|
||||||
|
|
||||||
|
export const rateLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 200, // limit each IP to 200 requests per windowMs
|
||||||
|
message: { success: false, message: "Too many requests, please try again later" },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false
|
||||||
|
});
|
||||||
|
|
||||||
|
export const strictRateLimiter = rateLimit({
|
||||||
|
windowMs: 5 * 60 * 1000, // 5 minutes
|
||||||
|
max: 10,
|
||||||
|
message: { success: false, message: "Too many attempts, please try again later" }
|
||||||
|
});
|
||||||
34
backend/src/middleware/upload.ts
Normal file
34
backend/src/middleware/upload.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import multer from "multer";
|
||||||
|
import path from "path";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { env } from "../config/env";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
const uploadDir = env.UPLOAD_DIR;
|
||||||
|
if (!fs.existsSync(uploadDir)) {
|
||||||
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (_req, _file, cb) => {
|
||||||
|
cb(null, uploadDir);
|
||||||
|
},
|
||||||
|
filename: (_req, file, cb) => {
|
||||||
|
const uniqueName = `${uuidv4()}${path.extname(file.originalname)}`;
|
||||||
|
cb(null, uniqueName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: env.MAX_FILE_SIZE },
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
const allowedTypes = [".pdf", ".jpg", ".jpeg", ".png", ".doc", ".docx"];
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
if (allowedTypes.includes(ext)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error("Invalid file type. Allowed: PDF, JPG, PNG, DOC, DOCX"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
26
backend/src/models/AuditLog.ts
Normal file
26
backend/src/models/AuditLog.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { query } from "../config/database";
|
||||||
|
|
||||||
|
export const createAuditLog = async (data: any) => {
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO audit_logs (user_id, action, entity_type, entity_id, details, ip_address, user_agent)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||||
|
[data.user_id, data.action, data.entity_type, data.entity_id, data.details ? JSON.stringify(data.details) : null, data.ip_address, data.user_agent]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAuditLogs = async (filters: any = {}, limit: number = 1000, offset: number = 0) => {
|
||||||
|
let sql = `SELECT a.*, u.email as user_email, u.first_name || || u.last_name as user_name
|
||||||
|
FROM audit_logs a
|
||||||
|
LEFT JOIN users u ON a.user_id = u.id
|
||||||
|
WHERE 1=1`;
|
||||||
|
const params: any[] = [];
|
||||||
|
if (filters?.user_id) { sql += ` AND a.user_id = $${params.length + 1}`; params.push(filters.user_id); }
|
||||||
|
if (filters?.entity_type) { sql += ` AND a.entity_type = $${params.length + 1}`; params.push(filters.entity_type); }
|
||||||
|
if (filters?.action) { sql += ` AND a.action = $${params.length + 1}`; params.push(filters.action); }
|
||||||
|
const countResult = await query(`SELECT COUNT(*) FROM (${sql}) AS t`, [...params]);
|
||||||
|
sql += ` ORDER BY a.created_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
|
||||||
|
params.push(limit, offset);
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return { logs: result.rows, total: parseInt(countResult.rows[0].count, 10) };
|
||||||
|
};
|
||||||
78
backend/src/models/Client.ts
Normal file
78
backend/src/models/Client.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { query } from "../config/database";
|
||||||
|
|
||||||
|
export const createClient = async (data: any) => {
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO clients (first_name, last_name, email, phone, passport_number, date_of_birth, nationality, priority, status, notes, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
|
||||||
|
[data.first_name, data.last_name, data.email, data.phone, data.passport_number, data.date_of_birth, data.nationality, data.priority || "medium", data.status || "active", data.notes, data.created_by]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findClientById = async (id: string) => {
|
||||||
|
const result = await query("SELECT * FROM clients WHERE id = $1", [id]);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateClient = async (id: string, data: any) => {
|
||||||
|
const fields = Object.keys(data).filter(k => data[k] !== undefined && k !== "id");
|
||||||
|
const setClause = fields.map((k, i) => `${k} = $${i + 2}`).join(", ");
|
||||||
|
const values = fields.map(k => data[k]);
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE clients SET ${setClause}, updated_at = NOW() WHERE id = $1 RETURNING *`,
|
||||||
|
[id, ...values]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteClient = async (id: string) => {
|
||||||
|
await query("DELETE FROM clients WHERE id = $1", [id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllClients = async (filters: any = {}, limit: number = 1000, offset: number = 0) => {
|
||||||
|
let sql = "SELECT * FROM clients WHERE 1=1";
|
||||||
|
const params: any[] = [];
|
||||||
|
if (filters?.status) { sql += ` AND status = $${params.length + 1}`; params.push(filters.status); }
|
||||||
|
if (filters?.priority) { sql += ` AND priority = $${params.length + 1}`; params.push(filters.priority); }
|
||||||
|
if (filters?.search) { sql += ` AND (first_name ILIKE $${params.length + 1} OR last_name ILIKE $${params.length + 1} OR email ILIKE $${params.length + 1})`; params.push(`%${filters.search}%`); }
|
||||||
|
const countResult = await query(`SELECT COUNT(*) FROM (${sql}) AS t`, [...params]);
|
||||||
|
sql += ` ORDER BY created_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
|
||||||
|
params.push(limit, offset);
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return { clients: result.rows, total: parseInt(countResult.rows[0].count, 10) };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addClientCountry = async (data: any) => {
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO client_countries (client_id, country_id, visa_type, status, appointment_date, application_ref, notes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||||
|
[data.client_id, data.country_id, data.visa_type, data.status || "pending", data.appointment_date, data.application_ref, data.notes]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getClientCountries = async (clientId: string) => {
|
||||||
|
const result = await query(
|
||||||
|
`SELECT cc.*, c.name as country_name, c.code as country_code, c.flag_emoji
|
||||||
|
FROM client_countries cc
|
||||||
|
JOIN countries c ON cc.country_id = c.id
|
||||||
|
WHERE cc.client_id = $1`,
|
||||||
|
[clientId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateClientCountry = async (id: string, data: any) => {
|
||||||
|
const fields = Object.keys(data).filter(k => data[k] !== undefined && k !== "id");
|
||||||
|
const setClause = fields.map((k, i) => `${k} = $${i + 2}`).join(", ");
|
||||||
|
const values = fields.map(k => data[k]);
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE client_countries SET ${setClause}, updated_at = NOW() WHERE id = $1 RETURNING *`,
|
||||||
|
[id, ...values]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeClientCountry = async (id: string) => {
|
||||||
|
await query("DELETE FROM client_countries WHERE id = $1", [id]);
|
||||||
|
};
|
||||||
42
backend/src/models/Country.ts
Normal file
42
backend/src/models/Country.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { query } from "../config/database";
|
||||||
|
|
||||||
|
export const createCountry = async (data: any) => {
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO countries (name, code, flag_emoji, vfs_url, vfs_credentials, requirements, processing_time, visa_types)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||||
|
[data.name, data.code, data.flag_emoji, data.vfs_url, data.vfs_credentials ? JSON.stringify(data.vfs_credentials) : null, data.requirements ? JSON.stringify(data.requirements) : null, data.processing_time, data.visa_types ? JSON.stringify(data.visa_types) : null]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findCountryById = async (id: string) => {
|
||||||
|
const result = await query("SELECT * FROM countries WHERE id = $1", [id]);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateCountry = async (id: string, data: any) => {
|
||||||
|
const fields = Object.keys(data).filter(k => data[k] !== undefined && k !== "id");
|
||||||
|
const setClause = fields.map((k, i) => `${k} = $${i + 2}`).join(", ");
|
||||||
|
const values = fields.map(k => data[k]);
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE countries SET ${setClause}, updated_at = NOW() WHERE id = $1 RETURNING *`,
|
||||||
|
[id, ...values]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteCountry = async (id: string) => {
|
||||||
|
await query("DELETE FROM countries WHERE id = $1", [id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllCountries = async (filters: any = {}, limit: number = 1000, offset: number = 0) => {
|
||||||
|
let sql = "SELECT * FROM countries WHERE 1=1";
|
||||||
|
const params: any[] = [];
|
||||||
|
if (filters?.is_active !== undefined) { sql += ` AND is_active = $${params.length + 1}`; params.push(filters.is_active); }
|
||||||
|
if (filters?.search) { sql += ` AND name ILIKE $${params.length + 1}`; params.push(`%${filters.search}%`); }
|
||||||
|
const countResult = await query(`SELECT COUNT(*) FROM (${sql}) AS t`, [...params]);
|
||||||
|
sql += ` ORDER BY name ASC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
|
||||||
|
params.push(limit, offset);
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return { countries: result.rows, total: parseInt(countResult.rows[0].count, 10) };
|
||||||
|
};
|
||||||
27
backend/src/models/Document.ts
Normal file
27
backend/src/models/Document.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { query } from "../config/database";
|
||||||
|
|
||||||
|
export const createDocument = async (data: any) => {
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO documents (client_id, name, file_path, file_type, file_size, category, uploaded_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||||
|
[data.client_id, data.name, data.file_path, data.file_type, data.file_size, data.category || "general", data.uploaded_by]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findDocumentById = async (id: string) => {
|
||||||
|
const result = await query("SELECT * FROM documents WHERE id = $1", [id]);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteDocument = async (id: string) => {
|
||||||
|
await query("DELETE FROM documents WHERE id = $1", [id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDocumentsByClient = async (clientId: string) => {
|
||||||
|
const result = await query(
|
||||||
|
"SELECT * FROM documents WHERE client_id = $1 ORDER BY created_at DESC",
|
||||||
|
[clientId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
38
backend/src/models/Notification.ts
Normal file
38
backend/src/models/Notification.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { query } from "../config/database";
|
||||||
|
|
||||||
|
export const createNotification = async (data: any) => {
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO notifications (user_id, title, message, type, link)
|
||||||
|
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||||
|
[data.user_id, data.title, data.message, data.type || "info", data.link]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getNotificationsByUser = async (userId: string, unreadOnly?: boolean) => {
|
||||||
|
let sql = "SELECT * FROM notifications WHERE user_id = $1";
|
||||||
|
const params: any[] = [userId];
|
||||||
|
if (unreadOnly) { sql += " AND is_read = false"; }
|
||||||
|
sql += " ORDER BY created_at DESC";
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markAsRead = async (id: string) => {
|
||||||
|
const result = await query(
|
||||||
|
"UPDATE notifications SET is_read = true WHERE id = $1 RETURNING *",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markAllAsRead = async (userId: string) => {
|
||||||
|
await query(
|
||||||
|
"UPDATE notifications SET is_read = true WHERE user_id = $1 AND is_read = false",
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteNotification = async (id: string) => {
|
||||||
|
await query("DELETE FROM notifications WHERE id = $1", [id]);
|
||||||
|
};
|
||||||
47
backend/src/models/Session.ts
Normal file
47
backend/src/models/Session.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { query } from "../config/database";
|
||||||
|
|
||||||
|
export const createSession = async (data: any) => {
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO sessions (client_id, country_id, type, status, logs, screenshot_path, error_message, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||||
|
[data.client_id, data.country_id, data.type, data.status || "running", data.logs ? JSON.stringify(data.logs) : null, data.screenshot_path, data.error_message, data.created_by]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findSessionById = async (id: string) => {
|
||||||
|
const result = await query("SELECT * FROM sessions WHERE id = $1", [id]);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateSession = async (id: string, data: any) => {
|
||||||
|
const fields = Object.keys(data).filter(k => data[k] !== undefined && k !== "id");
|
||||||
|
const setClause = fields.map((k, i) => `${k} = $${i + 2}`).join(", ");
|
||||||
|
const values = fields.map(k => data[k]);
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE sessions SET ${setClause}, ended_at = CASE WHEN status IN (completed, failed, cancelled) THEN COALESCE(ended_at, NOW()) ELSE ended_at END WHERE id = $1 RETURNING *`,
|
||||||
|
[id, ...values]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteSession = async (id: string) => {
|
||||||
|
await query("DELETE FROM sessions WHERE id = $1", [id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllSessions = async (filters: any = {}, limit: number = 1000, offset: number = 0) => {
|
||||||
|
let sql = `SELECT s.*, c.first_name || || c.last_name as client_name, co.name as country_name
|
||||||
|
FROM sessions s
|
||||||
|
LEFT JOIN clients c ON s.client_id = c.id
|
||||||
|
LEFT JOIN countries co ON s.country_id = co.id
|
||||||
|
WHERE 1=1`;
|
||||||
|
const params: any[] = [];
|
||||||
|
if (filters?.status) { sql += ` AND s.status = $${params.length + 1}`; params.push(filters.status); }
|
||||||
|
if (filters?.type) { sql += ` AND s.type = $${params.length + 1}`; params.push(filters.type); }
|
||||||
|
if (filters?.client_id) { sql += ` AND s.client_id = $${params.length + 1}`; params.push(filters.client_id); }
|
||||||
|
const countResult = await query(`SELECT COUNT(*) FROM (${sql}) AS t`, [...params]);
|
||||||
|
sql += ` ORDER BY s.created_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
|
||||||
|
params.push(limit, offset);
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return { sessions: result.rows, total: parseInt(countResult.rows[0].count, 10) };
|
||||||
|
};
|
||||||
45
backend/src/models/User.ts
Normal file
45
backend/src/models/User.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { query } from "../config/database";
|
||||||
|
import { User } from "../types";
|
||||||
|
|
||||||
|
export const createUser = async (userData: Partial<User>): Promise<User> => {
|
||||||
|
const result = await query(
|
||||||
|
"INSERT INTO users (email, password_hash, first_name, last_name, role) VALUES ($1, $2, $3, $4, $5) RETURNING *",
|
||||||
|
[userData.email, userData.password_hash, userData.first_name, userData.last_name, userData.role || "operator"]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findUserByEmail = async (email: string): Promise<User | null> => {
|
||||||
|
const result = await query("SELECT * FROM users WHERE email = $1", [email]);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findUserById = async (id: string): Promise<User | null> => {
|
||||||
|
const result = await query("SELECT * FROM users WHERE id = $1", [id]);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateUser = async (id: string, updates: Partial<User>): Promise<User> => {
|
||||||
|
const fields = Object.keys(updates).filter(k => k !== "id");
|
||||||
|
const values = fields.map(f => updates[f as keyof User]);
|
||||||
|
const setClause = fields.map((f, i) => f + " = $" + (i + 1)).join(", ");
|
||||||
|
const result = await query(
|
||||||
|
"UPDATE users SET " + setClause + ", updated_at = NOW() WHERE id = $" + (fields.length + 1) + " RETURNING *",
|
||||||
|
[...values, id]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteUser = async (id: string): Promise<void> => {
|
||||||
|
await query("DELETE FROM users WHERE id = $1", [id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllUsers = async (limit = 50, offset = 0): Promise<{ users: User[]; total: number }> => {
|
||||||
|
const countResult = await query("SELECT COUNT(*) FROM users");
|
||||||
|
const result = await query("SELECT * FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2", [limit, offset]);
|
||||||
|
return { users: result.rows, total: parseInt(countResult.rows[0].count) };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateLastLogin = async (id: string): Promise<void> => {
|
||||||
|
await query("UPDATE users SET last_login = NOW() WHERE id = $1", [id]);
|
||||||
|
};
|
||||||
43
backend/src/models/Workflow.ts
Normal file
43
backend/src/models/Workflow.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { query } from "../config/database";
|
||||||
|
|
||||||
|
export const createWorkflow = async (data: any) => {
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO workflows (name, country_id, type, steps, is_active, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||||
|
[data.name, data.country_id, data.type, data.steps ? JSON.stringify(data.steps) : null, data.is_active !== false, data.created_by]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findWorkflowById = async (id: string) => {
|
||||||
|
const result = await query("SELECT * FROM workflows WHERE id = $1", [id]);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateWorkflow = async (id: string, data: any) => {
|
||||||
|
const fields = Object.keys(data).filter(k => data[k] !== undefined);
|
||||||
|
const setClause = fields.map((k, i) => `${k} = $${i + 2}`).join(", ");
|
||||||
|
const values = fields.map(k => data[k]);
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE workflows SET ${setClause}, updated_at = NOW() WHERE id = $1 RETURNING *`,
|
||||||
|
[id, ...values]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteWorkflow = async (id: string) => {
|
||||||
|
await query("DELETE FROM workflows WHERE id = $1", [id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllWorkflows = async (filters?: any) => {
|
||||||
|
let sql = `SELECT w.*, c.name as country_name
|
||||||
|
FROM workflows w
|
||||||
|
LEFT JOIN countries c ON w.country_id = c.id
|
||||||
|
WHERE 1=1`;
|
||||||
|
const params: any[] = [];
|
||||||
|
if (filters?.type) { sql += ` AND w.type = $${params.length + 1}`; params.push(filters.type); }
|
||||||
|
if (filters?.country_id) { sql += ` AND w.country_id = $${params.length + 1}`; params.push(filters.country_id); }
|
||||||
|
sql += " ORDER BY w.created_at DESC";
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
14
backend/src/routes/admin.ts
Normal file
14
backend/src/routes/admin.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { getDashboardStats, getAdminUsers, updateUserRole, toggleUserActive, deleteUserHandler, getSystemSettings } from "../controllers/admin";
|
||||||
|
import { authenticate, authorize } from "../middleware/auth";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/dashboard", authenticate, authorize("admin"), getDashboardStats);
|
||||||
|
router.get("/users", authenticate, authorize("admin"), getAdminUsers);
|
||||||
|
router.put("/users/:id/role", authenticate, authorize("admin"), updateUserRole);
|
||||||
|
router.put("/users/:id/toggle", authenticate, authorize("admin"), toggleUserActive);
|
||||||
|
router.delete("/users/:id", authenticate, authorize("admin"), deleteUserHandler);
|
||||||
|
router.get("/settings", authenticate, authorize("admin"), getSystemSettings);
|
||||||
|
|
||||||
|
export default router;
|
||||||
9
backend/src/routes/auditLogs.ts
Normal file
9
backend/src/routes/auditLogs.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { getAuditLogsHandler } from "../controllers/auditLogs";
|
||||||
|
import { authenticate, authorize } from "../middleware/auth";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/", authenticate, authorize("admin"), getAuditLogsHandler);
|
||||||
|
|
||||||
|
export default router;
|
||||||
15
backend/src/routes/auth.ts
Normal file
15
backend/src/routes/auth.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { login, register, guestLogin, getMe, getUsers, updateUserProfile, deleteUserAccount } from "../controllers/auth";
|
||||||
|
import { authenticate, authorize } from "../middleware/auth";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post("/guest", guestLogin);
|
||||||
|
router.post("/login", login);
|
||||||
|
router.post("/register", register);
|
||||||
|
router.get("/me", authenticate, getMe);
|
||||||
|
router.get("/users", authenticate, authorize("admin"), getUsers);
|
||||||
|
router.put("/users/:id", authenticate, authorize("admin"), updateUserProfile);
|
||||||
|
router.delete("/users/:id", authenticate, authorize("admin"), deleteUserAccount);
|
||||||
|
|
||||||
|
export default router;
|
||||||
9
backend/src/routes/booking.ts
Normal file
9
backend/src/routes/booking.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { runBooking } from "../controllers/booking";
|
||||||
|
import { authenticate } from "../middleware/auth";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post("/run", authenticate, runBooking);
|
||||||
|
|
||||||
|
export default router;
|
||||||
10
backend/src/routes/checkup.ts
Normal file
10
backend/src/routes/checkup.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { runCheckup, getCheckupStatus } from "../controllers/checkup";
|
||||||
|
import { authenticate } from "../middleware/auth";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post("/run", authenticate, runCheckup);
|
||||||
|
router.get("/status", authenticate, getCheckupStatus);
|
||||||
|
|
||||||
|
export default router;
|
||||||
16
backend/src/routes/clients.ts
Normal file
16
backend/src/routes/clients.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { createClientHandler, getClients, getClient, updateClientHandler, deleteClientHandler, addCountryToClient, updateClientCountryHandler, removeClientCountryHandler } from "../controllers/clients";
|
||||||
|
import { authenticate } from "../middleware/auth";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post("/", authenticate, createClientHandler);
|
||||||
|
router.get("/", authenticate, getClients);
|
||||||
|
router.get("/:id", authenticate, getClient);
|
||||||
|
router.put("/:id", authenticate, updateClientHandler);
|
||||||
|
router.delete("/:id", authenticate, deleteClientHandler);
|
||||||
|
router.post("/:id/countries", authenticate, addCountryToClient);
|
||||||
|
router.put("/:id/countries/:countryId", authenticate, updateClientCountryHandler);
|
||||||
|
router.delete("/:id/countries/:countryId", authenticate, removeClientCountryHandler);
|
||||||
|
|
||||||
|
export default router;
|
||||||
13
backend/src/routes/countries.ts
Normal file
13
backend/src/routes/countries.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { createCountryHandler, getCountries, getCountry, updateCountryHandler, deleteCountryHandler } from "../controllers/countries";
|
||||||
|
import { authenticate, authorize } from "../middleware/auth";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post("/", authenticate, authorize("admin", "operator"), createCountryHandler);
|
||||||
|
router.get("/", authenticate, getCountries);
|
||||||
|
router.get("/:id", authenticate, getCountry);
|
||||||
|
router.put("/:id", authenticate, authorize("admin", "operator"), updateCountryHandler);
|
||||||
|
router.delete("/:id", authenticate, authorize("admin"), deleteCountryHandler);
|
||||||
|
|
||||||
|
export default router;
|
||||||
13
backend/src/routes/documents.ts
Normal file
13
backend/src/routes/documents.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { uploadDocument, getDocuments, deleteDocumentHandler, downloadDocument } from "../controllers/documents";
|
||||||
|
import { authenticate } from "../middleware/auth";
|
||||||
|
import { upload } from "../middleware/upload";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post("/", authenticate, upload.single("file"), uploadDocument);
|
||||||
|
router.get("/", authenticate, getDocuments);
|
||||||
|
router.get("/:id/download", authenticate, downloadDocument);
|
||||||
|
router.delete("/:id", authenticate, deleteDocumentHandler);
|
||||||
|
|
||||||
|
export default router;
|
||||||
13
backend/src/routes/notifications.ts
Normal file
13
backend/src/routes/notifications.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { getNotifications, getUnreadCountHandler, markNotificationRead, markAllNotificationsRead, deleteNotificationHandler } from "../controllers/notifications";
|
||||||
|
import { authenticate } from "../middleware/auth";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/", authenticate, getNotifications);
|
||||||
|
router.get("/unread-count", authenticate, getUnreadCountHandler);
|
||||||
|
router.put("/:id/read", authenticate, markNotificationRead);
|
||||||
|
router.put("/mark-all-read", authenticate, markAllNotificationsRead);
|
||||||
|
router.delete("/:id", authenticate, deleteNotificationHandler);
|
||||||
|
|
||||||
|
export default router;
|
||||||
14
backend/src/routes/sessions.ts
Normal file
14
backend/src/routes/sessions.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { createSessionHandler, getSessions, getSession, updateSessionHandler, stopSessionHandler, deleteSessionHandler } from "../controllers/sessions";
|
||||||
|
import { authenticate } from "../middleware/auth";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post("/", authenticate, createSessionHandler);
|
||||||
|
router.get("/", authenticate, getSessions);
|
||||||
|
router.get("/:id", authenticate, getSession);
|
||||||
|
router.put("/:id", authenticate, updateSessionHandler);
|
||||||
|
router.post("/:id/stop", authenticate, stopSessionHandler);
|
||||||
|
router.delete("/:id", authenticate, deleteSessionHandler);
|
||||||
|
|
||||||
|
export default router;
|
||||||
8
backend/src/routes/system.ts
Normal file
8
backend/src/routes/system.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { getSystemStatus } from "../controllers/system";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/status", getSystemStatus);
|
||||||
|
|
||||||
|
export default router;
|
||||||
13
backend/src/routes/workflows.ts
Normal file
13
backend/src/routes/workflows.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { createWorkflowHandler, getWorkflows, getWorkflow, updateWorkflowHandler, deleteWorkflowHandler } from "../controllers/workflows";
|
||||||
|
import { authenticate } from "../middleware/auth";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post("/", authenticate, createWorkflowHandler);
|
||||||
|
router.get("/", authenticate, getWorkflows);
|
||||||
|
router.get("/:id", authenticate, getWorkflow);
|
||||||
|
router.put("/:id", authenticate, updateWorkflowHandler);
|
||||||
|
router.delete("/:id", authenticate, deleteWorkflowHandler);
|
||||||
|
|
||||||
|
export default router;
|
||||||
84
backend/src/server.ts
Normal file
84
backend/src/server.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import express, { Application } from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
import helmet from "helmet";
|
||||||
|
import morgan from "morgan";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import { rateLimiter } from "./middleware/rateLimiter";
|
||||||
|
import { errorHandler } from "./middleware/errorHandler";
|
||||||
|
import { connectDB } from "./config/database";
|
||||||
|
import { connectRedis } from "./config/redis";
|
||||||
|
import authRoutes from "./routes/auth";
|
||||||
|
import clientRoutes from "./routes/clients";
|
||||||
|
import countryRoutes from "./routes/countries";
|
||||||
|
import sessionRoutes from "./routes/sessions";
|
||||||
|
import workflowRoutes from "./routes/workflows";
|
||||||
|
import documentRoutes from "./routes/documents";
|
||||||
|
import adminRoutes from "./routes/admin";
|
||||||
|
import checkupRoutes from "./routes/checkup";
|
||||||
|
import bookingRoutes from "./routes/booking";
|
||||||
|
import notificationRoutes from "./routes/notifications";
|
||||||
|
import auditLogRoutes from "./routes/auditLogs";
|
||||||
|
import systemRoutes from "./routes/system";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const app: Application = express();
|
||||||
|
const PORT = process.env.PORT || 4000;
|
||||||
|
|
||||||
|
// Security middleware
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(cors({ origin: process.env.CLIENT_URL || "*", credentials: true }));
|
||||||
|
app.use(rateLimiter);
|
||||||
|
app.use(morgan("combined"));
|
||||||
|
|
||||||
|
// Body parsing
|
||||||
|
app.use(express.json({ limit: "10mb" }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
||||||
|
|
||||||
|
// Static files
|
||||||
|
app.use("/uploads", express.static(process.env.UPLOAD_DIR || "/app/uploads"));
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get("/health", (_req, res) => {
|
||||||
|
res.json({ status: "ok", timestamp: new Date().toISOString(), version: "1.0.0" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
app.use("/api/auth", authRoutes);
|
||||||
|
app.use("/api/clients", clientRoutes);
|
||||||
|
app.use("/api/countries", countryRoutes);
|
||||||
|
app.use("/api/sessions", sessionRoutes);
|
||||||
|
app.use("/api/workflows", workflowRoutes);
|
||||||
|
app.use("/api/documents", documentRoutes);
|
||||||
|
app.use("/api/admin", adminRoutes);
|
||||||
|
app.use("/api/checkup", checkupRoutes);
|
||||||
|
app.use("/api/booking", bookingRoutes);
|
||||||
|
app.use("/api/notifications", notificationRoutes);
|
||||||
|
app.use("/api/audit-logs", auditLogRoutes);
|
||||||
|
app.use("/api/system", systemRoutes);
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
// 404 handler
|
||||||
|
app.use((_req, res) => {
|
||||||
|
res.status(404).json({ success: false, message: "Route not found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
const startServer = async () => {
|
||||||
|
try {
|
||||||
|
await connectDB();
|
||||||
|
await connectRedis();
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Tinovisas API running on port ${PORT}`);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to start server:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
startServer();
|
||||||
|
|
||||||
|
export default app;
|
||||||
33
backend/src/services/auditService.ts
Normal file
33
backend/src/services/auditService.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { createAuditLog } from "../models/AuditLog";
|
||||||
|
import { AuthenticatedRequest } from "../middleware/auth";
|
||||||
|
import { logAudit } from "../utils/logger";
|
||||||
|
|
||||||
|
export const logAction = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
action: string,
|
||||||
|
entityType: string,
|
||||||
|
entityId?: string,
|
||||||
|
details?: any
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||||
|
const userAgent = req.headers["user-agent"] || "unknown";
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
user_id: userId,
|
||||||
|
action,
|
||||||
|
entity_type: entityType,
|
||||||
|
entity_id: entityId,
|
||||||
|
details,
|
||||||
|
ip_address: ip,
|
||||||
|
user_agent: userAgent
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
logAudit(action, userId, { entityType, entityId, details });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create audit log:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
39
backend/src/services/notificationService.ts
Normal file
39
backend/src/services/notificationService.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { createNotification, getNotificationsByUser } from "../models/Notification";
|
||||||
|
import { getRedis } from "../config/redis";
|
||||||
|
import { logInfo } from "../utils/logger";
|
||||||
|
|
||||||
|
export const sendNotification = async (userId: string, title: string, message: string, type = "info", link?: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await createNotification({ user_id: userId, title, message, type, link });
|
||||||
|
|
||||||
|
// Cache unread count
|
||||||
|
const redis = getRedis();
|
||||||
|
const key = `notifications:unread:${userId}`;
|
||||||
|
await redis.incr(key);
|
||||||
|
await redis.expire(key, 3600);
|
||||||
|
|
||||||
|
logInfo(`Notification sent to user ${userId}: ${title}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send notification:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUnreadCount = async (userId: string): Promise<number> => {
|
||||||
|
try {
|
||||||
|
const redis = getRedis();
|
||||||
|
const cached = await redis.get(`notifications:unread:${userId}`);
|
||||||
|
if (cached) return parseInt(cached);
|
||||||
|
|
||||||
|
const notifications = await getNotificationsByUser(userId, true);
|
||||||
|
const count = notifications.length;
|
||||||
|
await redis.setEx(`notifications:unread:${userId}`, 3600, count.toString());
|
||||||
|
return count;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const broadcastToAdmins = async (title: string, message: string, type = "info"): Promise<void> => {
|
||||||
|
// Implementation would query all admin users and send to each
|
||||||
|
logInfo(`Admin broadcast: ${title}`);
|
||||||
|
};
|
||||||
71
backend/src/services/playwright.ts
Normal file
71
backend/src/services/playwright.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { chromium, Browser, Page, BrowserContext } from "playwright";
|
||||||
|
import { env } from "../config/env";
|
||||||
|
import { logInfo, logError } from "../utils/logger";
|
||||||
|
|
||||||
|
let browserInstance: Browser | null = null;
|
||||||
|
|
||||||
|
export const getBrowser = async (): Promise<Browser> => {
|
||||||
|
if (!browserInstance) {
|
||||||
|
browserInstance = await chromium.launch({
|
||||||
|
headless: env.PLAYWRIGHT_HEADLESS,
|
||||||
|
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]
|
||||||
|
});
|
||||||
|
logInfo("Playwright browser launched");
|
||||||
|
}
|
||||||
|
return browserInstance;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const closeBrowser = async (): Promise<void> => {
|
||||||
|
if (browserInstance) {
|
||||||
|
await browserInstance.close();
|
||||||
|
browserInstance = null;
|
||||||
|
logInfo("Playwright browser closed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPage = async (browser: Browser): Promise<{ page: Page; context: BrowserContext }> => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1920, height: 1080 },
|
||||||
|
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(env.PLAYWRIGHT_TIMEOUT);
|
||||||
|
return { page, context };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const takeScreenshot = async (page: Page, path: string): Promise<string> => {
|
||||||
|
await page.screenshot({ path, fullPage: true });
|
||||||
|
logInfo(`Screenshot saved: ${path}`);
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const navigateTo = async (page: Page, url: string): Promise<void> => {
|
||||||
|
logInfo(`Navigating to: ${url}`);
|
||||||
|
await page.goto(url, { waitUntil: "networkidle" });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fillForm = async (page: Page, fields: { selector: string; value: string }[]): Promise<void> => {
|
||||||
|
for (const field of fields) {
|
||||||
|
await page.waitForSelector(field.selector, { state: "visible" });
|
||||||
|
await page.fill(field.selector, field.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clickElement = async (page: Page, selector: string): Promise<void> => {
|
||||||
|
await page.waitForSelector(selector, { state: "visible" });
|
||||||
|
await page.click(selector);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getText = async (page: Page, selector: string): Promise<string | null> => {
|
||||||
|
await page.waitForSelector(selector);
|
||||||
|
return page.textContent(selector);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const waitForElement = async (page: Page, selector: string, timeout?: number): Promise<void> => {
|
||||||
|
await page.waitForSelector(selector, { state: "visible", timeout: timeout || env.PLAYWRIGHT_TIMEOUT });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkForText = async (page: Page, text: string): Promise<boolean> => {
|
||||||
|
const body = await page.textContent("body");
|
||||||
|
return body ? body.includes(text) : false;
|
||||||
|
};
|
||||||
46
backend/src/services/sessionManager.ts
Normal file
46
backend/src/services/sessionManager.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { updateSession } from "../models/Session";
|
||||||
|
import { logInfo, logError } from "../utils/logger";
|
||||||
|
|
||||||
|
const activeSessions = new Map<string, { abort: boolean; logs: any[] }>();
|
||||||
|
|
||||||
|
export const startSession = (sessionId: string): void => {
|
||||||
|
activeSessions.set(sessionId, { abort: false, logs: [] });
|
||||||
|
logInfo(`Session started: ${sessionId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stopSession = (sessionId: string): void => {
|
||||||
|
const session = activeSessions.get(sessionId);
|
||||||
|
if (session) {
|
||||||
|
session.abort = true;
|
||||||
|
logInfo(`Session stopped: ${sessionId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isSessionAborted = (sessionId: string): boolean => {
|
||||||
|
return activeSessions.get(sessionId)?.abort || false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addLog = (sessionId: string, log: { message: string; level?: string; timestamp?: string }): void => {
|
||||||
|
const session = activeSessions.get(sessionId);
|
||||||
|
if (session) {
|
||||||
|
const entry = {
|
||||||
|
message: log.message,
|
||||||
|
level: log.level || "info",
|
||||||
|
timestamp: log.timestamp || new Date().toISOString()
|
||||||
|
};
|
||||||
|
session.logs.push(entry);
|
||||||
|
updateSession(sessionId, { logs: session.logs }).catch((err: any) => logError("Failed to update session logs", err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSessionLogs = (sessionId: string): any[] => {
|
||||||
|
return activeSessions.get(sessionId)?.logs || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearSession = (sessionId: string): void => {
|
||||||
|
activeSessions.delete(sessionId);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllActiveSessions = (): string[] => {
|
||||||
|
return Array.from(activeSessions.keys());
|
||||||
|
};
|
||||||
140
backend/src/types/index.ts
Normal file
140
backend/src/types/index.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
password_hash: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
role: "admin" | "operator" | "viewer";
|
||||||
|
is_active: boolean;
|
||||||
|
last_login?: Date;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Client {
|
||||||
|
id: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
passport_number?: string;
|
||||||
|
date_of_birth?: Date;
|
||||||
|
nationality?: string;
|
||||||
|
priority: "low" | "medium" | "high" | "urgent";
|
||||||
|
status: "active" | "inactive" | "completed" | "suspended";
|
||||||
|
notes?: string;
|
||||||
|
created_by?: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Country {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
flag_emoji?: string;
|
||||||
|
vfs_url?: string;
|
||||||
|
vfs_credentials?: any;
|
||||||
|
requirements?: any[];
|
||||||
|
processing_time?: string;
|
||||||
|
visa_types?: string[];
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientCountry {
|
||||||
|
id: string;
|
||||||
|
client_id: string;
|
||||||
|
country_id: string;
|
||||||
|
visa_type?: string;
|
||||||
|
status: "pending" | "in_progress" | "approved" | "rejected" | "completed";
|
||||||
|
appointment_date?: Date;
|
||||||
|
application_ref?: string;
|
||||||
|
notes?: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
client_id?: string;
|
||||||
|
country_id?: string;
|
||||||
|
type: "checkup" | "booking";
|
||||||
|
status: "running" | "paused" | "completed" | "failed" | "cancelled";
|
||||||
|
started_at: Date;
|
||||||
|
ended_at?: Date;
|
||||||
|
logs?: any[];
|
||||||
|
screenshot_path?: string;
|
||||||
|
error_message?: string;
|
||||||
|
created_by?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Workflow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
country_id?: string;
|
||||||
|
type: "checkup" | "booking";
|
||||||
|
steps: any[];
|
||||||
|
is_active: boolean;
|
||||||
|
created_by?: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Document {
|
||||||
|
id: string;
|
||||||
|
client_id: string;
|
||||||
|
name: string;
|
||||||
|
file_path: string;
|
||||||
|
file_type?: string;
|
||||||
|
file_size?: number;
|
||||||
|
category?: string;
|
||||||
|
uploaded_by?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
type: "info" | "success" | "warning" | "error";
|
||||||
|
is_read: boolean;
|
||||||
|
link?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLog {
|
||||||
|
id: string;
|
||||||
|
user_id?: string;
|
||||||
|
action: string;
|
||||||
|
entity_type: string;
|
||||||
|
entity_id?: string;
|
||||||
|
details?: any;
|
||||||
|
ip_address?: string;
|
||||||
|
user_agent?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JWTPayload {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
meta?: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
total?: number;
|
||||||
|
totalPages?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
12
backend/src/types/pg.d.ts
vendored
Normal file
12
backend/src/types/pg.d.ts
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
declare module "pg" {
|
||||||
|
export class Pool {
|
||||||
|
constructor(config?: any);
|
||||||
|
connect(): Promise<PoolClient>;
|
||||||
|
query(text: string, params?: any[]): Promise<any>;
|
||||||
|
on(event: string, callback: (...args: any[]) => void): void;
|
||||||
|
}
|
||||||
|
export class PoolClient {
|
||||||
|
query(text: string, params?: any[]): Promise<any>;
|
||||||
|
release(): void;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
backend/src/utils/jwt.ts
Normal file
11
backend/src/utils/jwt.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { env } from "../config/env";
|
||||||
|
import { JWTPayload } from "../types";
|
||||||
|
|
||||||
|
export const generateToken = (payload: Omit<JWTPayload, "iat" | "exp">): string => {
|
||||||
|
return jwt.sign(payload, env.JWT_SECRET as jwt.Secret, { expiresIn: env.JWT_EXPIRES_IN as any });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyToken = (token: string): JWTPayload => {
|
||||||
|
return jwt.verify(token, env.JWT_SECRET as jwt.Secret) as JWTPayload;
|
||||||
|
};
|
||||||
11
backend/src/utils/logger.ts
Normal file
11
backend/src/utils/logger.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export const logInfo = (message: string, meta?: any) => {
|
||||||
|
console.log(`[INFO] ${new Date().toISOString()} ${message}`, meta ? JSON.stringify(meta) : "");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logError = (message: string, error?: any) => {
|
||||||
|
console.error(`[ERROR] ${new Date().toISOString()} ${message}`, error);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logAudit = (action: string, userId: string, details: any) => {
|
||||||
|
console.log(`[AUDIT] ${new Date().toISOString()} ${action} user=${userId}`, JSON.stringify(details));
|
||||||
|
};
|
||||||
9
backend/src/utils/password.ts
Normal file
9
backend/src/utils/password.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
export const hashPassword = async (password: string): Promise<string> => {
|
||||||
|
return bcrypt.hash(password, 12);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const comparePassword = async (password: string, hash: string): Promise<boolean> => {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
};
|
||||||
17
backend/src/utils/response.ts
Normal file
17
backend/src/utils/response.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Response } from "express";
|
||||||
|
|
||||||
|
export const successResponse = <T>(res: Response, data: T, message?: string, meta?: any) => {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
message,
|
||||||
|
meta
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const errorResponse = (res: Response, message: string, statusCode = 400) => {
|
||||||
|
return res.status(statusCode).json({
|
||||||
|
success: false,
|
||||||
|
message
|
||||||
|
});
|
||||||
|
};
|
||||||
22
backend/tsconfig.json
Normal file
22
backend/tsconfig.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictNullChecks": false,
|
||||||
|
"noImplicitReturns": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
102
docker-compose.yml
Normal file
102
docker-compose.yml
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: tinovisas-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: tinovisas
|
||||||
|
POSTGRES_USER: tinovisas
|
||||||
|
POSTGRES_PASSWORD: tinovisas_secret_2024
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./postgres-init:/docker-entrypoint-initdb.d
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U tinovisas -d tinovisas"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- tinovisas-network
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: tinovisas-redis
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- tinovisas-network
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
container_name: tinovisas-backend
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 4000
|
||||||
|
DB_HOST: postgres
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_NAME: tinovisas
|
||||||
|
DB_USER: tinovisas
|
||||||
|
DB_PASSWORD: tinovisas_secret_2024
|
||||||
|
REDIS_HOST: redis
|
||||||
|
REDIS_PORT: 6379
|
||||||
|
JWT_SECRET: tinovisas_super_secret_key_change_me
|
||||||
|
JWT_EXPIRES_IN: 24h
|
||||||
|
ENCRYPTION_KEY: tinovisas_encryption_key_32chars!!
|
||||||
|
UPLOAD_DIR: /app/uploads
|
||||||
|
MAX_FILE_SIZE: 10485760
|
||||||
|
PLAYWRIGHT_HEADLESS: "true"
|
||||||
|
PLAYWRIGHT_TIMEOUT: 30000
|
||||||
|
DEFAULT_ADMIN_EMAIL: admin@tinovisas.com
|
||||||
|
DEFAULT_ADMIN_PASSWORD: Admin123!
|
||||||
|
volumes:
|
||||||
|
- uploads_data:/app/uploads
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- tinovisas-network
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
container_name: tinovisas-frontend
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- tinovisas-network
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: tinovisas-nginx
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
|
||||||
|
- uploads_data:/app/uploads
|
||||||
|
- ./nginx/html:/etc/nginx/html
|
||||||
|
- ./screenshots:/app/tinovisas/screenshots
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- tinovisas-network
|
||||||
|
- coolify
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
uploads_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
tinovisas-network:
|
||||||
|
driver: bridge
|
||||||
|
coolify:
|
||||||
|
external: true
|
||||||
4
frontend/.dockerignore
Normal file
4
frontend/.dockerignore
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
16
frontend/Dockerfile
Normal file
16
frontend/Dockerfile
Normal 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
frontend/next-env.d.ts
vendored
Normal file
5
frontend/next-env.d.ts
vendored
Normal 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
frontend/next.config.js
Normal file
14
frontend/next.config.js
Normal 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
frontend/package-lock.json
generated
Normal file
6643
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/package.json
Normal file
1
frontend/package.json
Normal 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
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
311
frontend/src/app/admin/page.tsx
Normal file
311
frontend/src/app/admin/page.tsx
Normal 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
frontend/src/app/booking/page.tsx
Normal file
173
frontend/src/app/booking/page.tsx
Normal 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
frontend/src/app/checkup/page.tsx
Normal file
148
frontend/src/app/checkup/page.tsx
Normal 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
frontend/src/app/clients/[id]/page.tsx
Normal file
794
frontend/src/app/clients/[id]/page.tsx
Normal 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
frontend/src/app/clients/page.tsx
Normal file
263
frontend/src/app/clients/page.tsx
Normal 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
frontend/src/app/countries/page.tsx
Normal file
140
frontend/src/app/countries/page.tsx
Normal 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 2 photos 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
frontend/src/app/dashboard/page.tsx
Normal file
126
frontend/src/app/dashboard/page.tsx
Normal 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
frontend/src/app/documents/page.tsx
Normal file
139
frontend/src/app/documents/page.tsx
Normal 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
frontend/src/app/layout.tsx
Normal file
30
frontend/src/app/layout.tsx
Normal 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
frontend/src/app/login/page.tsx
Normal file
130
frontend/src/app/login/page.tsx
Normal 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
frontend/src/app/page.tsx
Normal file
5
frontend/src/app/page.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
117
frontend/src/app/sessions/page.tsx
Normal file
117
frontend/src/app/sessions/page.tsx
Normal 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
frontend/src/app/settings/page.tsx
Normal file
129
frontend/src/app/settings/page.tsx
Normal 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
frontend/src/app/workflows/page.tsx
Normal file
175
frontend/src/app/workflows/page.tsx
Normal 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
frontend/src/components/ClientCard.tsx
Normal file
96
frontend/src/components/ClientCard.tsx
Normal 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
frontend/src/components/CountryCard.tsx
Normal file
55
frontend/src/components/CountryCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
frontend/src/components/DocumentUploader.tsx
Normal file
50
frontend/src/components/DocumentUploader.tsx
Normal file
@ -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
frontend/src/components/Header.tsx
Normal file
48
frontend/src/components/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
frontend/src/components/LanguageSelector.tsx
Normal file
41
frontend/src/components/LanguageSelector.tsx
Normal file
@ -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
frontend/src/components/Pagination.tsx
Normal file
63
frontend/src/components/Pagination.tsx
Normal 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
frontend/src/components/SessionTable.tsx
Normal file
67
frontend/src/components/SessionTable.tsx
Normal 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
frontend/src/components/Sidebar.tsx
Normal file
109
frontend/src/components/Sidebar.tsx
Normal 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
frontend/src/components/ThemeToggle.tsx
Normal file
20
frontend/src/components/ThemeToggle.tsx
Normal 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
frontend/src/components/WorkflowEditor.tsx
Normal file
124
frontend/src/components/WorkflowEditor.tsx
Normal 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
frontend/src/hooks/useAuth.ts
Normal file
33
frontend/src/hooks/useAuth.ts
Normal 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
frontend/src/hooks/useClients.ts
Normal file
28
frontend/src/hooks/useClients.ts
Normal 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
frontend/src/hooks/useCountries.ts
Normal file
26
frontend/src/hooks/useCountries.ts
Normal 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
frontend/src/lib/api.ts
Normal file
109
frontend/src/lib/api.ts
Normal 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
frontend/src/lib/auth.ts
Normal file
18
frontend/src/lib/auth.ts
Normal 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
frontend/src/lib/utils.ts
Normal file
48
frontend/src/lib/utils.ts
Normal 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
frontend/src/styles/globals.css
Normal file
64
frontend/src/styles/globals.css
Normal 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
frontend/tailwind.config.js
Normal file
31
frontend/tailwind.config.js
Normal 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
frontend/tsconfig.json
Normal file
1
frontend/tsconfig.json
Normal 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"]}
|
||||||
235
nginx/html/index.html
Normal file
235
nginx/html/index.html
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DZ Projects Dashboard</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
background: linear-gradient(90deg, #00d4ff, #7b2cbf, #ff006e);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
color: #8892b0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 30px;
|
||||||
|
max-width: 1200px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 30px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, var(--accent-color), var(--accent-color-2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
background: linear-gradient(135deg, var(--accent-color), var(--accent-color-2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card p {
|
||||||
|
color: #8892b0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #64ffda;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-meta span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.online {
|
||||||
|
background: rgba(39, 174, 96, 0.2);
|
||||||
|
color: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.online::before {
|
||||||
|
content: '';
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #27ae60;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Project-specific colors */
|
||||||
|
.tinovisas {
|
||||||
|
--accent-color: #00d4ff;
|
||||||
|
--accent-color-2: #0099cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gold-miner {
|
||||||
|
--accent-color: #ffd700;
|
||||||
|
--accent-color-2: #ffaa00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dzgames {
|
||||||
|
--accent-color: #ff006e;
|
||||||
|
--accent-color-2: #8338ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 50px;
|
||||||
|
color: #8892b0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #64ffda;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>⚡ DZ Projects Dashboard</h1>
|
||||||
|
<p>Your unified command center for all projects</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="projects-grid">
|
||||||
|
<!-- TinoVisas -->
|
||||||
|
<a href="/tinovisas" class="project-card tinovisas">
|
||||||
|
<div class="project-icon">🤖</div>
|
||||||
|
<span class="status online">Online</span>
|
||||||
|
<h2>TinoVisas</h2>
|
||||||
|
<p>Automated VFS visa appointment bot with client management, session tracking, and real-time notifications. Monitor openings and auto-book for your clients.</p>
|
||||||
|
<div class="project-meta">
|
||||||
|
<span>⚡ Node.js</span>
|
||||||
|
<span>🔌 WebSocket</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Gol D Miner -->
|
||||||
|
<a href="/gold" class="project-card gold-miner">
|
||||||
|
<div class="project-icon">📈</div>
|
||||||
|
<span class="status online">Online</span>
|
||||||
|
<h2>Gol D Miner</h2>
|
||||||
|
<p>Gold trading dashboard with Capital.com API integration. Scalping strategies, real-time market data, position management with TP/SL controls.</p>
|
||||||
|
<div class="project-meta">
|
||||||
|
<span>💰 Trading</span>
|
||||||
|
<span>📊 Live Charts</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Dzgames -->
|
||||||
|
<a href="/dzgames" class="project-card dzgames">
|
||||||
|
<div class="project-icon">🎮</div>
|
||||||
|
<span class="status online">Online</span>
|
||||||
|
<h2>Dzgames</h2>
|
||||||
|
<p>Multilingual gaming marketplace for digital game codes. Chargily Pay integration, admin dashboard, Arabic/French/English support.</p>
|
||||||
|
<div class="project-meta">
|
||||||
|
<span>🌍 i18n</span>
|
||||||
|
<span>💳 Payments</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>VPS: <strong>51.178.36.114</strong> | Dashboard: <a href="http://51.178.36.114">http://51.178.36.114</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
91
nginx/nginx.conf
Normal file
91
nginx/nginx.conf
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
|
||||||
|
|
||||||
|
upstream tinovisas_web {
|
||||||
|
server frontend:3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream tinovisas_api {
|
||||||
|
server backend:4000;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
proxy_pass http://tinovisas_api/health;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tinovisas API - preserve /api/ prefix
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://tinovisas_api/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tinovisas static assets
|
||||||
|
location /_next/ {
|
||||||
|
proxy_pass http://tinovisas_web/_next/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_cache_valid 200 1d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tinovisas screenshots
|
||||||
|
location /screenshots/ {
|
||||||
|
alias /app/screenshots/;
|
||||||
|
autoindex off;
|
||||||
|
expires 1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tinovisas uploads
|
||||||
|
location /uploads/ {
|
||||||
|
alias /app/uploads/;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tinovisas frontend - everything else
|
||||||
|
location / {
|
||||||
|
proxy_pass http://tinovisas_web;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user