Commit 35640bca authored by Administrator's avatar Administrator

Update 15 files via Son of Anton

parent 731cd6e6
# Dependencies
node_modules/
.pnp
.pnp.js
# Build outputs
dist/
build/
.next/
out/
# Environment
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
*.env
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Prisma
prisma/migrations/
# Testing
coverage/
# MinIO local data
minio_data/
# Docker volumes
postgres_data/
redis_data/
# Misc
*.tsbuildinfo
next-env.d.ts
\ No newline at end of file
# The Grind — AL-Arcade HR Platform v3.0
> Gamified HR, Project Management & Internal Operations Platform
## Overview
The Grind is a comprehensive, self-hosted internal platform for AL-Arcade that manages:
- **People** — Contractor onboarding, profiles, schedules, and offboarding
- **Money** — Live salary tracking (The HUD), deductions, bounties, payroll
- **Tasks** — Kanban boards with drag-and-drop, deadlines, and bounty rewards
- **Performance** — Monthly evaluations, PIPs, learning goals, competency tracking
- **Communication** — Internal messaging, notifications (3-tier), notices
- **Documents** — Contracts, policies, audit trail
- **Analytics** — Role-based dashboards, custom report builder
**Zero external dependencies.** No email, no Slack, no third-party services. Everything is self-hosted.
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Frontend | Next.js 14 (App Router), React 18, Tailwind CSS, Zustand |
| Backend | NestJS 10, TypeScript, Prisma 5 |
| Database | PostgreSQL 16 |
| Cache/Queue | Redis 7 |
| Real-Time | Socket.io 4 |
| File Storage | MinIO (S3-compatible) |
| Background Jobs | @nestjs/schedule (cron-based) |
## Quick Start
### Prerequisites
- Node.js 20+
- Docker & Docker Compose
### 1. Clone and install
\ No newline at end of file
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Copy shared package first
COPY ../shared /shared
WORKDIR /shared
RUN npm install && npm run build 2>/dev/null || true
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm ci --legacy-peer-deps
# Copy prisma schema
COPY ../prisma ./prisma
RUN npx prisma generate
# Copy source
COPY . .
# Build
RUN npm run build
# Stage 2: Production
FROM node:20-alpine AS runner
WORKDIR /app
# Install production deps only
COPY package.json package-lock.json* ./
RUN npm ci --legacy-peer-deps --production && npm cache clean --force
# Copy built output
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/prisma ./prisma
# Create non-root user
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nestjs
USER nestjs
EXPOSE 3001
ENV NODE_ENV=production
ENV PORT=3001
CMD ["node", "dist/main.js"]
\ No newline at end of file
{
"schemaVersion": 2,
"dockerfilePath": "./Dockerfile"
}
\ No newline at end of file
This diff is collapsed.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config';
import { Logger } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { Logger, ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import * as cookieParser from 'cookie-parser';
import * as compression from 'compression';
import helmet from 'helmet';
import { AppModule } from './app.module';
import { IoAdapter } from '@nestjs/platform-socket.io';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'log', 'debug'],
logger: ['error', 'warn', 'log'],
});
const configService = app.get(ConfigService);
const port = configService.get<number>('app.port', 3001);
const frontendUrl = configService.get<string>('app.frontendUrl', 'http://localhost:3000');
const corsOrigins = configService.get<string[]>('app.corsOrigins', [frontendUrl]);
const apiPrefix = configService.get<string>('app.apiPrefix', 'api');
// Security
app.use(helmet());
app.use(cookieParser());
app.use(compression());
// Global prefix
app.setGlobalPrefix(apiPrefix);
// CORS
const corsOrigins = configService.get<string[]>('app.corsOrigins') || ['http://localhost:3000'];
app.enableCors({
origin: corsOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key', 'X-Requested-With'],
});
// Global prefix
const apiPrefix = configService.get<string>('app.apiPrefix') || 'api';
app.setGlobalPrefix(apiPrefix);
// Security
app.use(helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
contentSecurityPolicy: false,
}));
// Compression
app.use(compression());
// Cookie parser
app.use(cookieParser());
// WebSocket adapter
app.useWebSocketAdapter(new IoAdapter(app));
// Swagger
if (configService.get<string>('app.nodeEnv') !== 'production') {
if (configService.get('app.nodeEnv') !== 'production') {
const swaggerConfig = new DocumentBuilder()
.setTitle('The Grind — HR Platform API')
.setDescription('AL-Arcade HR Platform v3.0 API Documentation')
.setTitle('The Grind — AL-Arcade HR Platform API')
.setDescription('Complete API for The Grind HR, Project Management & Operations Platform')
.setVersion('3.0')
.addBearerAuth()
.addApiKey({ type: 'apiKey', name: 'X-API-Key', in: 'header' }, 'api-key')
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('docs', app, document);
logger.log('Swagger docs available at /docs');
logger.log(`Swagger docs available at http://localhost:${port}/docs`);
}
const port = configService.get<number>('app.port') || 3001;
await app.listen(port);
logger.log(`🔥 The Grind backend is running on port ${port}`);
logger.log(`📝 API prefix: /${apiPrefix}`);
logger.log(`🌍 Environment: ${configService.get<string>('app.nodeEnv')}`);
await app.listen(port, '0.0.0.0');
logger.log(`🔥 The Grind API running on http://localhost:${port}/${apiPrefix}`);
logger.log(`Environment: ${configService.get('app.nodeEnv')}`);
}
bootstrap();
\ No newline at end of file
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: thegrind-postgres
restart: unless-stopped
environment:
POSTGRES_DB: thegrind
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: thegrind-redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
minio:
image: minio/minio:latest
container_name: thegrind-minio
restart: unless-stopped
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: thegrind-backend
restart: unless-stopped
env_file:
- ./backend/.env
ports:
- "3001:3001"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
volumes:
- ./backend/src:/app/src
- ./prisma:/app/prisma
- ./shared:/app/../shared
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: thegrind-frontend
restart: unless-stopped
env_file:
- ./frontend/.env
ports:
- "3000:3000"
depends_on:
- backend
volumes:
postgres_data:
redis_data:
minio_data:
\ No newline at end of file
# Stage 1: Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --legacy-peer-deps
# Stage 2: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Stage 3: Production
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
\ No newline at end of file
{
"schemaVersion": 2,
"dockerfilePath": "./Dockerfile"
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { apiGet, apiPost, apiPut } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { PageHeader } from '@/components/shared/page-header';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { StatusBadge } from '@/components/shared/status-badge';
import { UserAvatar } from '@/components/shared/user-avatar';
import { formatEgp } from '@/lib/utils';
import { formatDate } from '@/lib/date';
import { DollarSign, Plus, CheckCircle2, XCircle, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
export default function AdjustmentsPage() {
const user = useAuthStore(s => s.user);
const [adjustments, setAdjustments] = useState<any[]>([]);
const [contractors, setContractors] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [statusFilter, setStatusFilter] = useState('');
const [form, setForm] = useState({
userId: '', type: 'POSITIVE', category: 'BONUS', amountPiasters: 0, description: '', effectiveMonth: new Date().getMonth() + 1, effectiveYear: new Date().getFullYear(),
});
useEffect(() => { loadData(); }, [statusFilter]);
const loadData = async () => {
try {
const params: any = { limit: 50, sortOrder: 'desc' };
if (statusFilter) params.status = statusFilter;
const [adjRes, contractorRes] = await Promise.all([
apiGet('/adjustments', params),
apiGet('/users', { role: 'CONTRACTOR', status: 'ACTIVE', limit: 100 }),
]);
setAdjustments(adjRes.data || []);
setContractors(contractorRes.data || []);
} catch (err) {
console.error('Failed to load adjustments:', err);
} finally {
setIsLoading(false);
}
};
const handleCreate = async () => {
if (!form.userId) { toast.error('Select a contractor'); return; }
if (form.amountPiasters <= 0) { toast.error('Enter a valid amount'); return; }
if (form.description.length < 50) { toast.error('Description must be at least 50 characters'); return; }
setIsCreating(true);
try {
await apiPost('/adjustments', form);
toast.success('Adjustment created');
setShowCreate(false);
setForm({ userId: '', type: 'POSITIVE', category: 'BONUS', amountPiasters: 0, description: '', effectiveMonth: new Date().getMonth() + 1, effectiveYear: new Date().getFullYear() });
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to create adjustment');
} finally {
setIsCreating(false);
}
};
const handleReview = async (id: string, decision: 'APPROVED' | 'REJECTED') => {
try {
await apiPut(`/adjustments/${id}/review`, { decision });
toast.success(`Adjustment ${decision.toLowerCase()}`);
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to review adjustment');
}
};
if (isLoading) return <PageLoadingSkeleton />;
const isSuperAdmin = user?.role === 'SUPER_ADMIN';
return (
<div className="space-y-6">
<PageHeader
title="Manual Adjustments"
description="Salary adjustments: advances, reimbursements, bonuses, corrections"
actions={
<button onClick={() => setShowCreate(!showCreate)} className="flex items-center gap-2 bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm font-medium hover:bg-primary/90">
<Plus size={16} /> New Adjustment
</button>
}
/>
{showCreate && (
<div className="bg-card rounded-xl border p-6 space-y-4">
<h3 className="font-semibold">Create Adjustment</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<label className="text-sm font-medium">Contractor *</label>
<select value={form.userId} onChange={e => setForm({ ...form, userId: e.target.value })} className="w-full px-3 py-2 rounded-lg border bg-background text-sm">
<option value="">Select contractor</option>
{contractors.map(c => <option key={c.id} value={c.id}>{c.firstName} {c.lastName}</option>)}
</select>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Type *</label>
<select value={form.type} onChange={e => setForm({ ...form, type: e.target.value })} className="w-full px-3 py-2 rounded-lg border bg-background text-sm">
<option value="POSITIVE">Positive (adds to salary)</option>
<option value="NEGATIVE">Negative (deducts from salary)</option>
</select>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Category *</label>
<select value={form.category} onChange={e => setForm({ ...form, category: e.target.value })} className="w-full px-3 py-2 rounded-lg border bg-background text-sm">
<option value="ADVANCE">Advance</option>
<option value="REIMBURSEMENT">Reimbursement</option>
<option value="BONUS">Bonus</option>
<option value="CORRECTION">Correction</option>
<option value="LOAN">Loan</option>
<option value="OTHER">Other</option>
</select>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Amount (piasters) *</label>
<input type="number" value={form.amountPiasters} onChange={e => setForm({ ...form, amountPiasters: Number(e.target.value) })} min={1} className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
{form.amountPiasters > 0 && <p className="text-xs text-muted-foreground">{formatEgp(form.amountPiasters)}</p>}
</div>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Description * (min 50 chars)</label>
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} rows={3} placeholder="Explain the reason for this adjustment..." className="w-full px-3 py-2 rounded-lg border bg-background text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring" />
<p className="text-xs text-muted-foreground">{form.description.length}/50 min</p>
</div>
<div className="flex justify-end gap-2">
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-sm rounded-lg border hover:bg-accent">Cancel</button>
<button onClick={handleCreate} disabled={isCreating} className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50">
{isCreating ? <Loader2 size={14} className="animate-spin" /> : <DollarSign size={14} />}
Create
</button>
</div>
</div>
)}
{/* Filters */}
<div className="flex gap-2">
{['', 'PENDING_APPROVAL', 'APPROVED', 'REJECTED'].map(s => (
<button key={s} onClick={() => setStatusFilter(s)} className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${statusFilter === s ? 'bg-accent font-medium' : 'hover:bg-accent/50'}`}>
{s ? s.replace('_', ' ') : 'All'}
</button>
))}
</div>
{adjustments.length === 0 ? (
<EmptyState icon={DollarSign} title="No adjustments" description="Manual salary adjustments will appear here." />
) : (
<div className="bg-card rounded-xl border divide-y">
{adjustments.map(adj => (
<div key={adj.id} className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<UserAvatar firstName={adj.user?.firstName || '?'} lastName={adj.user?.lastName || '?'} avatar={adj.user?.avatar} size="sm" />
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{adj.user?.firstName} {adj.user?.lastName}</p>
<StatusBadge status={adj.status} />
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">{adj.category}</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{adj.description}</p>
<p className="text-xs text-muted-foreground">{formatDate(adj.createdAt)}</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className={`font-mono font-bold ${adj.type === 'POSITIVE' ? 'text-emerald-500' : 'text-red-500'}`}>
{adj.type === 'POSITIVE' ? '+' : '-'}{formatEgp(adj.amountPiasters)}
</span>
{adj.status === 'PENDING_APPROVAL' && isSuperAdmin && (
<div className="flex gap-1">
<button onClick={() => handleReview(adj.id, 'APPROVED')} className="p-1.5 text-emerald-500 hover:bg-emerald-500/10 rounded">
<CheckCircle2 size={16} />
</button>
<button onClick={() => handleReview(adj.id, 'REJECTED')} className="p-1.5 text-red-500 hover:bg-red-500/10 rounded">
<XCircle size={16} />
</button>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { apiGet } from '@/lib/api';
import { PageHeader } from '@/components/shared/page-header';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { StatusBadge } from '@/components/shared/status-badge';
import { UserAvatar } from '@/components/shared/user-avatar';
import { formatDate, daysUntil, isOverdue } from '@/lib/date';
import { formatEgp, cn } from '@/lib/utils';
import { FileText, Search, AlertTriangle, Clock } from 'lucide-react';
export default function ContractsPage() {
const [contracts, setContracts] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
useEffect(() => { loadData(); }, [page, statusFilter]);
const loadData = async () => {
try {
const params: any = { page, limit: 20, sortOrder: 'desc' };
if (statusFilter) params.status = statusFilter;
const res = await apiGet('/contracts', params);
setContracts(res.data || []);
setTotal(res.meta?.total || 0);
} catch (err) {
console.error('Failed to load contracts:', err);
} finally {
setIsLoading(false);
}
};
if (isLoading) return <PageLoadingSkeleton />;
return (
<div className="space-y-6">
<PageHeader title="Contract Management" description={`${total} contracts`} />
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 max-w-sm">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input type="text" placeholder="Search contracts..." value={search} onChange={e => setSearch(e.target.value)} className="w-full pl-9 pr-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
<select value={statusFilter} onChange={e => { setStatusFilter(e.target.value); setPage(1); }} className="px-3 py-2 rounded-lg border bg-background text-sm">
<option value="">All Statuses</option>
<option value="ACTIVE">Active</option>
<option value="EXPIRED">Expired</option>
<option value="TERMINATED">Terminated</option>
</select>
</div>
{contracts.length === 0 ? (
<EmptyState icon={FileText} title="No contracts found" description="Contracts are created during the onboarding process." />
) : (
<div className="bg-card rounded-xl border divide-y">
{contracts.map(contract => {
const daysLeft = contract.endDate ? daysUntil(contract.endDate) : null;
const expiringSoon = daysLeft !== null && daysLeft > 0 && daysLeft <= 30;
const expired = contract.endDate && isOverdue(contract.endDate);
return (
<div key={contract.id} className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<UserAvatar firstName={contract.user?.firstName || '?'} lastName={contract.user?.lastName || '?'} avatar={contract.user?.avatar} size="sm" />
<div>
<p className="text-sm font-medium">{contract.user?.firstName} {contract.user?.lastName}</p>
<div className="flex items-center gap-2 mt-0.5">
<StatusBadge status={contract.status || 'ACTIVE'} />
<span className="text-xs text-muted-foreground">
{contract.contractorType?.replace('_', ' ')}
</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
Signed {contract.signedAt ? formatDate(contract.signedAt) : formatDate(contract.createdAt)}
{contract.endDate && ` · Ends ${formatDate(contract.endDate)}`}
</p>
</div>
</div>
<div className="text-right">
{contract.salaryAtSigning && (
<p className="text-sm font-mono">{formatEgp(contract.salaryAtSigning)}</p>
)}
{expiringSoon && (
<span className="flex items-center gap-1 text-xs text-yellow-500 mt-1">
<AlertTriangle size={12} /> {daysLeft} days left
</span>
)}
{expired && (
<span className="flex items-center gap-1 text-xs text-red-500 mt-1">
<Clock size={12} /> Expired
</span>
)}
</div>
</div>
);
})}
</div>
)}
{total > 20 && (
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Page {page} of {Math.ceil(total / 20)}</span>
<div className="flex gap-2">
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1} className="px-3 py-1.5 text-xs rounded border hover:bg-accent disabled:opacity-50">Previous</button>
<button onClick={() => setPage(p => p + 1)} disabled={page >= Math.ceil(total / 20)} className="px-3 py-1.5 text-xs rounded border hover:bg-accent disabled:opacity-50">Next</button>
</div>
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiGet, apiPost } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { PageHeader } from '@/components/shared/page-header';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { StatusBadge } from '@/components/shared/status-badge';
import { UserAvatar } from '@/components/shared/user-avatar';
import { formatMonthYear } from '@/lib/date';
import { Star, Plus, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
export default function AdminEvaluationsPage() {
const router = useRouter();
const user = useAuthStore(s => s.user);
const [evaluations, setEvaluations] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [month, setMonth] = useState(new Date().getMonth() + 1);
const [year, setYear] = useState(new Date().getFullYear());
const [isCreatingCycle, setIsCreatingCycle] = useState(false);
useEffect(() => { loadData(); }, [month, year]);
const loadData = async () => {
setIsLoading(true);
try {
const res = await apiGet('/evaluations', { month, year, limit: 100, sortOrder: 'asc' });
setEvaluations(res.data || []);
} catch (err) {
console.error('Failed to load evaluations:', err);
} finally {
setIsLoading(false);
}
};
const handleCreateCycle = async () => {
setIsCreatingCycle(true);
try {
await apiPost('/evaluations/cycle', { month, year });
toast.success(`Evaluation cycle created for ${formatMonthYear(month, year)}`);
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to create evaluation cycle');
} finally {
setIsCreatingCycle(false);
}
};
const prevMonth = () => {
if (month === 1) { setMonth(12); setYear(y => y - 1); }
else setMonth(m => m - 1);
};
const nextMonth = () => {
if (month === 12) { setMonth(1); setYear(y => y + 1); }
else setMonth(m => m + 1);
};
if (isLoading) return <PageLoadingSkeleton />;
const getRatingEmoji = (score: number | null) => {
if (!score) return '—';
if (score >= 4.5) return '⭐';
if (score >= 3.5) return '🟢';
if (score >= 2.5) return '🟡';
if (score >= 1.5) return '🟠';
return '🔴';
};
const pendingTech = evaluations.filter(e => e.status === 'PENDING_TECHNICAL').length;
const pendingProf = evaluations.filter(e => e.status === 'PENDING_PROFESSIONAL').length;
const compiled = evaluations.filter(e => ['COMPILED', 'ACKNOWLEDGED', 'RESPONDED'].includes(e.status)).length;
const avgScore = evaluations.filter(e => e.overallScore).reduce((sum, e) => sum + (e.overallScore || 0), 0) / (evaluations.filter(e => e.overallScore).length || 1);
return (
<div className="space-y-6">
<PageHeader title="Evaluation Management" description="Monthly contractor evaluations" />
{/* Month Selector */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button onClick={prevMonth} className="p-2 rounded-md hover:bg-accent"><ChevronLeft size={16} /></button>
<span className="text-sm font-semibold w-40 text-center">{formatMonthYear(month, year)}</span>
<button onClick={nextMonth} className="p-2 rounded-md hover:bg-accent"><ChevronRight size={16} /></button>
</div>
{evaluations.length === 0 && (
<button onClick={handleCreateCycle} disabled={isCreatingCycle} className="flex items-center gap-2 bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50">
{isCreatingCycle ? <Loader2 size={16} className="animate-spin" /> : <Plus size={16} />}
Create Evaluation Cycle
</button>
)}
</div>
{/* Stats */}
{evaluations.length > 0 && (
<div className="grid gap-4 md:grid-cols-4">
<div className="bg-card rounded-xl border p-4">
<p className="text-xs text-muted-foreground uppercase">Total</p>
<p className="text-2xl font-bold">{evaluations.length}</p>
</div>
<div className="bg-card rounded-xl border p-4">
<p className="text-xs text-muted-foreground uppercase">Pending Technical</p>
<p className="text-2xl font-bold text-yellow-500">{pendingTech}</p>
</div>
<div className="bg-card rounded-xl border p-4">
<p className="text-xs text-muted-foreground uppercase">Pending Professional</p>
<p className="text-2xl font-bold text-blue-500">{pendingProf}</p>
</div>
<div className="bg-card rounded-xl border p-4">
<p className="text-xs text-muted-foreground uppercase">Avg Score</p>
<p className="text-2xl font-bold">{compiled > 0 ? avgScore.toFixed(1) : '—'}</p>
</div>
</div>
)}
{/* Evaluations List */}
{evaluations.length === 0 ? (
<EmptyState icon={Star} title={`No evaluations for ${formatMonthYear(month, year)}`} description="Create an evaluation cycle to get started." />
) : (
<div className="bg-card rounded-xl border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Contractor</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Status</th>
<th className="text-center px-4 py-3 font-medium text-muted-foreground">Technical</th>
<th className="text-center px-4 py-3 font-medium text-muted-foreground">Professional</th>
<th className="text-center px-4 py-3 font-medium text-muted-foreground">Overall</th>
<th className="text-center px-4 py-3 font-medium text-muted-foreground">Rating</th>
</tr>
</thead>
<tbody className="divide-y">
{evaluations.map(ev => (
<tr key={ev.id} onClick={() => router.push(`/evaluations/${ev.id}`)} className="hover:bg-accent/50 cursor-pointer">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<UserAvatar firstName={ev.user?.firstName || '?'} lastName={ev.user?.lastName || '?'} avatar={ev.user?.avatar} size="sm" />
<span>{ev.user?.firstName} {ev.user?.lastName}</span>
</div>
</td>
<td className="px-4 py-3"><StatusBadge status={ev.status} /></td>
<td className="px-4 py-3 text-center font-mono">{ev.technicalScore?.toFixed(1) || '—'}</td>
<td className="px-4 py-3 text-center font-mono">{ev.professionalScore?.toFixed(1) || '—'}</td>
<td className="px-4 py-3 text-center font-mono font-bold">{ev.overallScore?.toFixed(1) || '—'}</td>
<td className="px-4 py-3 text-center text-lg">{getRatingEmoji(ev.overallScore)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiGet, apiPut } from '@/lib/api';
import { PageHeader } from '@/components/shared/page-header';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { StatusBadge } from '@/components/shared/status-badge';
import { UserAvatar } from '@/components/shared/user-avatar';
import { formatDate, relativeTime } from '@/lib/date';
import { ClipboardList, CheckCircle2, Clock, AlertTriangle, UserCheck, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
export default function OnboardingPipelinePage() {
const router = useRouter();
const [contractors, setContractors] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [activatingId, setActivatingId] = useState<string | null>(null);
useEffect(() => { loadData(); }, []);
const loadData = async () => {
try {
const res = await apiGet('/users', { status: 'ONBOARDING', limit: 100 });
setContractors(res.data || []);
} catch (err) {
console.error('Failed to load onboarding pipeline:', err);
} finally {
setIsLoading(false);
}
};
const handleActivate = async (userId: string) => {
setActivatingId(userId);
try {
await apiPut(`/users/${userId}`, { status: 'ACTIVE', activatedAt: new Date().toISOString() });
toast.success('Contractor activated! Super Admin has been notified to set salary.');
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to activate contractor');
} finally {
setActivatingId(null);
}
};
if (isLoading) return <PageLoadingSkeleton />;
return (
<div className="space-y-6">
<PageHeader
title="Onboarding Pipeline"
description={`${contractors.length} contractor${contractors.length !== 1 ? 's' : ''} in onboarding`}
actions={
<button onClick={() => router.push('/admin/invites')} className="flex items-center gap-2 bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm font-medium hover:bg-primary/90">
<ClipboardList size={16} /> Manage Invites
</button>
}
/>
{contractors.length === 0 ? (
<EmptyState
icon={ClipboardList}
title="No active onboardings"
description="Create an invite to start onboarding a new contractor."
action={
<button onClick={() => router.push('/admin/invites')} className="bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm font-medium hover:bg-primary/90">
Create Invite
</button>
}
/>
) : (
<div className="space-y-4">
{contractors.map(c => {
const registeredDaysAgo = c.createdAt ? Math.floor((Date.now() - new Date(c.createdAt).getTime()) / (1000 * 60 * 60 * 24)) : 0;
const isOverdue = registeredDaysAgo > 7;
// Simple checklist estimation based on available data
const checks = [
{ name: 'Profile photo', done: !!c.avatar },
{ name: 'Bank details', done: !!c.bankName },
{ name: 'Contract signed', done: !!c.contractSignedAt },
{ name: 'Board assigned', done: (c.boardCount || 0) > 0 },
];
const completedCount = checks.filter(ch => ch.done).length;
const totalChecks = checks.length;
return (
<div key={c.id} className={`bg-card rounded-xl border p-4 ${isOverdue ? 'border-red-500/30' : ''}`}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<UserAvatar firstName={c.firstName} lastName={c.lastName} avatar={c.avatar} size="md" />
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-semibold">{c.firstName} {c.lastName}</p>
<StatusBadge status={c.contractorType || 'CONTRACTOR'} />
{isOverdue && (
<span className="flex items-center gap-1 text-xs text-red-500">
<AlertTriangle size={12} /> Overdue
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
@{c.username} · Registered {c.createdAt ? relativeTime(c.createdAt) : '—'}
</p>
</div>
</div>
<button
onClick={() => handleActivate(c.id)}
disabled={activatingId === c.id || completedCount < totalChecks}
className="flex items-center gap-2 px-4 py-2 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50"
>
{activatingId === c.id ? <Loader2 size={14} className="animate-spin" /> : <UserCheck size={14} />}
Activate
</button>
</div>
{/* Checklist Progress */}
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium">{completedCount}/{totalChecks} complete</span>
<span className="text-xs text-muted-foreground">{Math.round((completedCount / totalChecks) * 100)}%</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-emerald-500 rounded-full transition-all" style={{ width: `${(completedCount / totalChecks) * 100}%` }} />
</div>
<div className="grid gap-1 mt-3 sm:grid-cols-2">
{checks.map(check => (
<div key={check.name} className="flex items-center gap-2 text-xs">
{check.done ? (
<CheckCircle2 size={14} className="text-emerald-500" />
) : (
<Clock size={14} className="text-muted-foreground" />
)}
<span className={check.done ? 'text-foreground' : 'text-muted-foreground'}>{check.name}</span>
</div>
))}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
\ No newline at end of file
This diff is collapsed.
......@@ -8,11 +8,6 @@ const config: Config = {
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
container: {
center: true,
padding: '2rem',
screens: { '2xl': '1400px' },
},
extend: {
colors: {
border: 'hsl(var(--border))',
......@@ -68,14 +63,6 @@ const config: Config = {
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'hud-pulse-red': {
'0%, 100%': { boxShadow: '0 0 0 0 rgba(239, 68, 68, 0)' },
'50%': { boxShadow: '0 0 20px 4px rgba(239, 68, 68, 0.4)' },
},
'hud-pulse-gold': {
'0%, 100%': { boxShadow: '0 0 0 0 rgba(245, 158, 11, 0)' },
'50%': { boxShadow: '0 0 20px 4px rgba(245, 158, 11, 0.4)' },
},
'fade-in': {
'0%': { opacity: '0', transform: 'translateY(4px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
......@@ -84,12 +71,20 @@ const config: Config = {
'0%': { transform: 'translateX(100%)' },
'100%': { transform: 'translateX(0)' },
},
'hud-pulse-red': {
'0%, 100%': { boxShadow: '0 0 0 0 rgba(239, 68, 68, 0)' },
'50%': { boxShadow: '0 0 0 4px rgba(239, 68, 68, 0.3)' },
},
'hud-pulse-gold': {
'0%, 100%': { boxShadow: '0 0 0 0 rgba(245, 158, 11, 0)' },
'50%': { boxShadow: '0 0 0 4px rgba(245, 158, 11, 0.3)' },
},
},
animation: {
'hud-pulse-red': 'hud-pulse-red 3s ease-in-out',
'hud-pulse-gold': 'hud-pulse-gold 3s ease-in-out',
'fade-in': 'fade-in 0.2s ease-out',
'slide-in-right': 'slide-in-right 0.3s ease-out',
'hud-pulse-red': 'hud-pulse-red 0.5s ease-in-out 3',
'hud-pulse-gold': 'hud-pulse-gold 0.5s ease-in-out 3',
},
},
},
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment