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
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { UnreportedDayProcessor } from './unreported-day.processor';
import { DeadlineScannerProcessor } from './deadline-scanner.processor';
import { PayrollCalculatorProcessor } from './payroll-calculator.processor';
import { AutoArchiveProcessor } from './auto-archive.processor';
import { ContractExpiryProcessor } from './contract-expiry.processor';
import { DeadlineScannerProcessor } from './deadline-scanner.processor';
import { DeductionAutoApplyProcessor } from './deduction-auto-apply.processor';
import { RecurringCardProcessor } from './recurring-card.processor';
import { MeetingReminderProcessor } from './meeting-reminder.processor';
import { NotificationCleanupProcessor } from './notification-cleanup.processor';
import { WebhookDispatchProcessor } from './webhook-dispatch.processor';
import { PayrollCalculatorProcessor } from './payroll-calculator.processor';
import { RecurringCardProcessor } from './recurring-card.processor';
import { UnreportedDayProcessor } from './unreported-day.processor';
@Injectable()
export class SchedulerService implements OnModuleInit {
private readonly logger = new Logger(SchedulerService.name);
constructor(
private readonly unreportedDay: UnreportedDayProcessor,
private readonly deadlineScanner: DeadlineScannerProcessor,
private readonly payrollCalculator: PayrollCalculatorProcessor,
private readonly autoArchive: AutoArchiveProcessor,
private readonly contractExpiry: ContractExpiryProcessor,
private readonly deadlineScanner: DeadlineScannerProcessor,
private readonly deductionAutoApply: DeductionAutoApplyProcessor,
private readonly recurringCard: RecurringCardProcessor,
private readonly meetingReminder: MeetingReminderProcessor,
private readonly notificationCleanup: NotificationCleanupProcessor,
private readonly webhookDispatch: WebhookDispatchProcessor,
private readonly payrollCalculator: PayrollCalculatorProcessor,
private readonly recurringCard: RecurringCardProcessor,
private readonly unreportedDay: UnreportedDayProcessor,
) {}
onModuleInit(): void {
this.logger.log('🕐 Job Scheduler initialized. All cron jobs registered.');
this.logger.log(' ├─ Unreported Day Detection: Daily at 01:00 AM');
this.logger.log(' ├─ Deadline Scanner: Every hour at :05');
this.logger.log(' ├─ Deduction Auto-Apply: Every hour at :30');
this.logger.log(' ├─ Auto Archive Done Cards: Daily at 02:00 AM');
this.logger.log(' ├─ Contract Expiry Check: Daily at 03:00 AM');
this.logger.log(' ├─ Recurring Card Creation: Daily at 04:00 AM');
this.logger.log(' ├─ Meeting Reminders: Every 30 minutes');
this.logger.log(' ├─ Notification Cleanup: Daily at 05:00 AM');
this.logger.log(' ├─ Payroll Calculator: Monthly on 25th at 06:00 AM');
this.logger.log(' └─ Webhook Retry: Every 15 minutes');
onModuleInit() {
this.logger.log('Scheduler service initialized — all cron jobs registered');
}
// ─── Daily at 1:00 AM — Detect unreported working days ───────────
@Cron('0 1 * * *', { name: 'unreported-day-detection', timeZone: 'Africa/Cairo' })
async handleUnreportedDayDetection(): Promise<void> {
this.logger.log('⏰ [CRON] Unreported Day Detection — START');
const start = Date.now();
// Every day at 1:00 AM — detect unreported days
@Cron('0 1 * * *', { name: 'unreported-day-detection' })
async handleUnreportedDays() {
this.logger.log('Running unreported day detection...');
try {
const result = await this.unreportedDay.process();
this.logger.log(
`✅ [CRON] Unreported Day Detection — DONE in ${Date.now() - start}ms. ` +
`${result.detected} unreported days found, ${result.deductionsCreated} deductions created.`,
);
this.logger.log(`Unreported day detection complete: ${result.deductionsCreated} deductions created, ${result.contractorsProcessed} contractors processed`);
} catch (err) {
this.logger.error(`❌ [CRON] Unreported Day Detection — FAILED: ${err.message}`, err.stack);
this.logger.error(`Unreported day detection failed: ${err.message}`, err.stack);
}
}
// ─── Every hour at :05 — Scan for overdue card deadlines ─────────
@Cron('5 * * * *', { name: 'deadline-scanner', timeZone: 'Africa/Cairo' })
async handleDeadlineScanner(): Promise<void> {
this.logger.log('⏰ [CRON] Deadline Scanner — START');
const start = Date.now();
// Every hour — scan for overdue card deadlines
@Cron(CronExpression.EVERY_HOUR, { name: 'deadline-scanner' })
async handleDeadlineScanner() {
this.logger.log('Running deadline scanner...');
try {
const result = await this.deadlineScanner.process();
this.logger.log(
`✅ [CRON] Deadline Scanner — DONE in ${Date.now() - start}ms. ` +
`${result.overdueCards} overdue cards found, ${result.deductionsCreated} new deductions, ${result.deductionsEscalated} escalated.`,
);
this.logger.log(`Deadline scanner complete: ${result.newDeductions} new, ${result.escalated} escalated, ${result.cardsScanned} cards scanned`);
} catch (err) {
this.logger.error(`❌ [CRON] Deadline Scanner — FAILED: ${err.message}`, err.stack);
this.logger.error(`Deadline scanner failed: ${err.message}`, err.stack);
}
}
// ─── Every hour at :30 — Auto-apply expired deduction responses ──
@Cron('30 * * * *', { name: 'deduction-auto-apply', timeZone: 'Africa/Cairo' })
async handleDeductionAutoApply(): Promise<void> {
this.logger.log('⏰ [CRON] Deduction Auto-Apply — START');
const start = Date.now();
try {
const result = await this.deductionAutoApply.process();
this.logger.log(
`✅ [CRON] Deduction Auto-Apply — DONE in ${Date.now() - start}ms. ` +
`${result.applied} deductions auto-applied.`,
);
} catch (err) {
this.logger.error(`❌ [CRON] Deduction Auto-Apply — FAILED: ${err.message}`, err.stack);
}
}
// ─── Daily at 2:00 AM — Archive old Done cards ───────────────────
@Cron('0 2 * * *', { name: 'auto-archive', timeZone: 'Africa/Cairo' })
async handleAutoArchive(): Promise<void> {
this.logger.log('⏰ [CRON] Auto Archive — START');
const start = Date.now();
// Every day at 2:00 AM — auto-archive Done cards
@Cron('0 2 * * *', { name: 'auto-archive' })
async handleAutoArchive() {
this.logger.log('Running auto-archive...');
try {
const result = await this.autoArchive.process();
this.logger.log(
`✅ [CRON] Auto Archive — DONE in ${Date.now() - start}ms. ` +
`${result.archived} cards archived across ${result.boardsProcessed} boards.`,
);
this.logger.log(`Auto-archive complete: ${result.archived} cards archived across ${result.boardsProcessed} boards`);
} catch (err) {
this.logger.error(`❌ [CRON] Auto Archive — FAILED: ${err.message}`, err.stack);
this.logger.error(`Auto-archive failed: ${err.message}`, err.stack);
}
}
// ─── Daily at 3:00 AM — Check for expiring contracts ─────────────
@Cron('0 3 * * *', { name: 'contract-expiry', timeZone: 'Africa/Cairo' })
async handleContractExpiry(): Promise<void> {
this.logger.log('⏰ [CRON] Contract Expiry Check — START');
const start = Date.now();
// Every day at 6:00 AM — check expiring contracts
@Cron('0 6 * * *', { name: 'contract-expiry' })
async handleContractExpiry() {
this.logger.log('Running contract expiry check...');
try {
const result = await this.contractExpiry.process();
this.logger.log(
`✅ [CRON] Contract Expiry Check — DONE in ${Date.now() - start}ms. ` +
`${result.notificationsSent} expiry notifications sent.`,
);
this.logger.log(`Contract expiry check complete: ${result.notificationsSent} notifications sent`);
} catch (err) {
this.logger.error(`❌ [CRON] Contract Expiry Check — FAILED: ${err.message}`, err.stack);
this.logger.error(`Contract expiry check failed: ${err.message}`, err.stack);
}
}
// ─── Daily at 4:00 AM — Create recurring card instances ──────────
@Cron('0 4 * * *', { name: 'recurring-card', timeZone: 'Africa/Cairo' })
async handleRecurringCard(): Promise<void> {
this.logger.log('⏰ [CRON] Recurring Card Creation — START');
const start = Date.now();
// Every 30 minutes — auto-apply deductions with expired response windows
@Cron('*/30 * * * *', { name: 'deduction-auto-apply' })
async handleDeductionAutoApply() {
try {
const result = await this.recurringCard.process();
this.logger.log(
`✅ [CRON] Recurring Card Creation — DONE in ${Date.now() - start}ms. ` +
`${result.cardsCreated} cards created.`,
);
const result = await this.deductionAutoApply.process();
if (result.applied > 0) {
this.logger.log(`Deduction auto-apply: ${result.applied} deductions auto-applied`);
}
} catch (err) {
this.logger.error(`❌ [CRON] Recurring Card Creation — FAILED: ${err.message}`, err.stack);
this.logger.error(`Deduction auto-apply failed: ${err.message}`, err.stack);
}
}
// ─── Every 30 minutes — Send meeting reminders ───────────────────
@Cron('*/30 * * * *', { name: 'meeting-reminder', timeZone: 'Africa/Cairo' })
async handleMeetingReminder(): Promise<void> {
this.logger.debug('⏰ [CRON] Meeting Reminder — START');
const start = Date.now();
// Every 15 minutes — send meeting reminders
@Cron('*/15 * * * *', { name: 'meeting-reminder' })
async handleMeetingReminders() {
try {
const result = await this.meetingReminder.process();
if (result.remindersSent > 0) {
this.logger.log(
`✅ [CRON] Meeting Reminder — DONE in ${Date.now() - start}ms. ` +
`${result.remindersSent} reminders sent.`,
);
this.logger.log(`Meeting reminders: ${result.remindersSent} reminders sent`);
}
} catch (err) {
this.logger.error(`❌ [CRON] Meeting Reminder — FAILED: ${err.message}`, err.stack);
this.logger.error(`Meeting reminder failed: ${err.message}`, err.stack);
}
}
// ─── Daily at 5:00 AM — Cleanup old notifications ────────────────
@Cron('0 5 * * *', { name: 'notification-cleanup', timeZone: 'Africa/Cairo' })
async handleNotificationCleanup(): Promise<void> {
this.logger.log('⏰ [CRON] Notification Cleanup — START');
const start = Date.now();
// Every day at 3:00 AM — create recurring card instances
@Cron('0 3 * * *', { name: 'recurring-cards' })
async handleRecurringCards() {
this.logger.log('Running recurring card creation...');
try {
const result = await this.notificationCleanup.process();
this.logger.log(
`✅ [CRON] Notification Cleanup — DONE in ${Date.now() - start}ms. ` +
`${result.deleted} old notifications removed.`,
);
const result = await this.recurringCard.process();
this.logger.log(`Recurring cards: ${result.cardsCreated} cards created from ${result.definitionsProcessed} definitions`);
} catch (err) {
this.logger.error(`❌ [CRON] Notification Cleanup — FAILED: ${err.message}`, err.stack);
this.logger.error(`Recurring card creation failed: ${err.message}`, err.stack);
}
}
// ─── Monthly on the 25th at 6:00 AM — Auto-calculate payroll ─────
@Cron('0 6 25 * *', { name: 'payroll-calculator', timeZone: 'Africa/Cairo' })
async handlePayrollCalculation(): Promise<void> {
this.logger.log('⏰ [CRON] Payroll Auto-Calculation — START');
const start = Date.now();
// Every day at 4:00 AM — cleanup old read notifications
@Cron('0 4 * * *', { name: 'notification-cleanup' })
async handleNotificationCleanup() {
try {
const result = await this.payrollCalculator.process();
this.logger.log(
`✅ [CRON] Payroll Auto-Calculation — DONE in ${Date.now() - start}ms. ` +
`${result.contractorsProcessed} contractors, ${result.totalNetPiasters} piasters total net.`,
);
const result = await this.notificationCleanup.process();
if (result.deleted > 0) {
this.logger.log(`Notification cleanup: ${result.deleted} old notifications deleted`);
}
} catch (err) {
this.logger.error(`❌ [CRON] Payroll Auto-Calculation — FAILED: ${err.message}`, err.stack);
this.logger.error(`Notification cleanup failed: ${err.message}`, err.stack);
}
}
// ─── Every 15 minutes — Retry failed webhooks ────────────────────
@Cron('*/15 * * * *', { name: 'webhook-retry', timeZone: 'Africa/Cairo' })
async handleWebhookRetry(): Promise<void> {
this.logger.debug('⏰ [CRON] Webhook Retry — START');
const start = Date.now();
// 25th of each month at 00:01 — auto-calculate payroll
@Cron('1 0 25 * *', { name: 'payroll-auto-calculate' })
async handlePayrollAutoCalculate() {
this.logger.log('Running monthly payroll auto-calculation...');
try {
const result = await this.webhookDispatch.processRetries();
if (result.retried > 0) {
this.logger.log(
`✅ [CRON] Webhook Retry — DONE in ${Date.now() - start}ms. ` +
`${result.retried} webhooks retried, ${result.succeeded} succeeded, ${result.failed} failed.`,
);
}
const result = await this.payrollCalculator.process();
this.logger.log(`Payroll calculation complete: ${result.contractorsProcessed} contractors, total net: ${result.totalNetPiasters} piasters`);
} catch (err) {
this.logger.error(`❌ [CRON] Webhook Retry — FAILED: ${err.message}`, err.stack);
this.logger.error(`Payroll auto-calculation failed: ${err.message}`, err.stack);
}
}
}
\ No newline at end of file
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
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiGet, apiPut, apiPost } 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 { ConfirmDialog } from '@/components/shared/confirm-dialog';
import { formatDate, daysUntil, isOverdue } from '@/lib/date';
import { cn } from '@/lib/utils';
import { AlertTriangle, Plus, CheckCircle2, XCircle, Clock, MessageSquare, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
export default function PIPsPage() {
const router = useRouter();
const [pips, setPips] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [contractors, setContractors] = useState<any[]>([]);
const [resultTarget, setResultTarget] = useState<any>(null);
const [resultDecision, setResultDecision] = useState<'PASSED' | 'FAILED' | ''>('');
const [resultNotes, setResultNotes] = useState('');
const [checkinTarget, setCheckinTarget] = useState<any>(null);
const [checkinNotes, setCheckinNotes] = useState('');
const [form, setForm] = useState({
userId: '',
duration: 30,
specificIssues: '',
improvementTargets: '',
successCriteria: '',
consequenceOfFailure: 'Termination of engagement.',
checkInSchedule: 'WEEKLY',
});
useEffect(() => { loadData(); }, [statusFilter]);
const loadData = async () => {
try {
const params: any = { limit: 50, sortOrder: 'desc' };
if (statusFilter) params.status = statusFilter;
const [pipRes, contractorRes] = await Promise.all([
apiGet('/pips', params),
apiGet('/users', { role: 'CONTRACTOR', status: 'ACTIVE', limit: 100 }),
]);
setPips(pipRes.data || []);
setContractors(contractorRes.data || []);
} catch (err) {
console.error('Failed to load PIPs:', err);
} finally {
setIsLoading(false);
}
};
const handleCreate = async () => {
if (!form.userId) { toast.error('Select a contractor'); return; }
if (form.specificIssues.length < 50) { toast.error('Specific issues must be at least 50 characters'); return; }
if (form.improvementTargets.length < 50) { toast.error('Improvement targets must be at least 50 characters'); return; }
if (form.successCriteria.length < 100) { toast.error('Success criteria must be at least 100 characters'); return; }
setIsCreating(true);
try {
await apiPost('/pips', {
userId: form.userId,
durationDays: form.duration,
specificIssues: form.specificIssues,
improvementTargets: form.improvementTargets,
successCriteria: form.successCriteria,
consequenceOfFailure: form.consequenceOfFailure,
checkInSchedule: form.checkInSchedule,
});
toast.success('PIP created and contractor notified');
setShowCreate(false);
setForm({ userId: '', duration: 30, specificIssues: '', improvementTargets: '', successCriteria: '', consequenceOfFailure: 'Termination of engagement.', checkInSchedule: 'WEEKLY' });
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to create PIP');
} finally {
setIsCreating(false);
}
};
const handleResult = async () => {
if (!resultTarget || !resultDecision) return;
try {
await apiPut(`/pips/${resultTarget.id}/result`, { result: resultDecision, notes: resultNotes });
toast.success(`PIP ${resultDecision.toLowerCase()}`);
setResultTarget(null);
setResultDecision('');
setResultNotes('');
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to record result');
}
};
const handleCheckin = async () => {
if (!checkinTarget || checkinNotes.length < 20) { toast.error('Notes must be at least 20 characters'); return; }
try {
await apiPost(`/pips/${checkinTarget.id}/checkin`, { notes: checkinNotes });
toast.success('Check-in logged');
setCheckinTarget(null);
setCheckinNotes('');
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to log check-in');
}
};
if (isLoading) return <PageLoadingSkeleton />;
const activePips = pips.filter(p => p.status === 'ACTIVE');
const closedPips = pips.filter(p => p.status !== 'ACTIVE');
return (
<div className="space-y-6">
<PageHeader
title="Performance Improvement Plans"
description={`${activePips.length} active PIPs`}
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} /> Create PIP
</button>
}
/>
{/* Create Form */}
{showCreate && (
<div className="bg-card rounded-xl border p-6 space-y-4">
<h3 className="font-semibold">New Performance Improvement Plan</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} (@{c.username})</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Duration *</label>
<select value={form.duration} onChange={e => setForm({ ...form, duration: Number(e.target.value) })} className="w-full px-3 py-2 rounded-lg border bg-background text-sm">
<option value={30}>30 days</option>
<option value={45}>45 days</option>
<option value={60}>60 days</option>
</select>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Check-in Schedule *</label>
<select value={form.checkInSchedule} onChange={e => setForm({ ...form, checkInSchedule: e.target.value })} className="w-full px-3 py-2 rounded-lg border bg-background text-sm">
<option value="WEEKLY">Weekly</option>
<option value="BIWEEKLY">Biweekly</option>
</select>
</div>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Specific Issues * (min 50 chars)</label>
<textarea value={form.specificIssues} onChange={e => setForm({ ...form, specificIssues: e.target.value })} rows={3} placeholder="List the specific performance issues..." 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.specificIssues.length}/50 min</p>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Improvement Targets * (min 50 chars)</label>
<textarea value={form.improvementTargets} onChange={e => setForm({ ...form, improvementTargets: e.target.value })} rows={3} placeholder="Measurable goals the contractor must achieve..." 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.improvementTargets.length}/50 min</p>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Success Criteria * (min 100 chars)</label>
<textarea value={form.successCriteria} onChange={e => setForm({ ...form, successCriteria: e.target.value })} rows={3} placeholder="Clear, unambiguous conditions for passing..." 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.successCriteria.length}/100 min</p>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Consequence of Failure</label>
<input type="text" value={form.consequenceOfFailure} onChange={e => setForm({ ...form, consequenceOfFailure: e.target.value })} className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<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-6 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50">
{isCreating ? <Loader2 size={14} className="animate-spin" /> : <AlertTriangle size={14} />}
Create PIP
</button>
</div>
</div>
)}
{/* Filters */}
<div className="flex gap-2">
{['', 'ACTIVE', 'PASSED', 'FAILED', 'CANCELLED'].map(s => (
<button key={s} onClick={() => setStatusFilter(s)} className={cn('px-3 py-1.5 text-sm rounded-lg transition-colors', statusFilter === s ? 'bg-accent font-medium' : 'hover:bg-accent/50')}>
{s || 'All'} {s === 'ACTIVE' && `(${activePips.length})`}
</button>
))}
</div>
{/* Active PIPs */}
{activePips.length > 0 && !statusFilter && (
<div className="space-y-3">
<h3 className="font-semibold text-orange-500 flex items-center gap-2"><AlertTriangle size={16} /> Active PIPs</h3>
{activePips.map(pip => {
const days = pip.endDate ? daysUntil(pip.endDate) : null;
const overdue = pip.endDate && isOverdue(pip.endDate);
return (
<div key={pip.id} className="bg-card rounded-xl border border-orange-500/20 p-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<UserAvatar firstName={pip.user?.firstName || '?'} lastName={pip.user?.lastName || '?'} avatar={pip.user?.avatar} size="sm" />
<div>
<p className="text-sm font-semibold">{pip.user?.firstName} {pip.user?.lastName}</p>
<p className="text-xs text-muted-foreground">
{formatDate(pip.startDate)}{formatDate(pip.endDate)}
<span className={cn('ml-2', overdue ? 'text-red-500 font-medium' : '')}>
{overdue ? `${Math.abs(days!)} days overdue` : `${days} days remaining`}
</span>
</p>
</div>
</div>
<div className="flex gap-1">
<button onClick={() => setCheckinTarget(pip)} className="flex items-center gap-1 px-2 py-1 text-xs rounded border hover:bg-accent">
<MessageSquare size={12} /> Check-in
</button>
<button onClick={() => { setResultTarget(pip); setResultDecision('PASSED'); }} className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20">
<CheckCircle2 size={12} /> Pass
</button>
<button onClick={() => { setResultTarget(pip); setResultDecision('FAILED'); }} className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-red-500/10 text-red-600 hover:bg-red-500/20">
<XCircle size={12} /> Fail
</button>
</div>
</div>
{pip.specificIssues && <p className="text-xs text-muted-foreground mt-2 line-clamp-2">{pip.specificIssues}</p>}
{pip.checkIns?.length > 0 && (
<p className="text-xs text-muted-foreground mt-1">{pip.checkIns.length} check-in(s) logged</p>
)}
</div>
);
})}
</div>
)}
{/* All/Closed PIPs */}
{(statusFilter ? pips : closedPips).length > 0 && (
<div className="bg-card rounded-xl border divide-y">
{(statusFilter ? pips : closedPips).map(pip => (
<div key={pip.id} className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<UserAvatar firstName={pip.user?.firstName || '?'} lastName={pip.user?.lastName || '?'} size="sm" />
<div>
<p className="text-sm font-medium">{pip.user?.firstName} {pip.user?.lastName}</p>
<p className="text-xs text-muted-foreground">{formatDate(pip.startDate)}{formatDate(pip.endDate)}</p>
</div>
</div>
<StatusBadge status={pip.status} />
</div>
))}
</div>
)}
{pips.length === 0 && (
<EmptyState icon={AlertTriangle} title="No PIPs found" description="Performance improvement plans will appear here when created." />
)}
{/* Check-in Modal */}
{checkinTarget && (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={() => setCheckinTarget(null)}>
<div className="bg-card rounded-xl border p-6 max-w-md w-full space-y-4" onClick={e => e.stopPropagation()}>
<h3 className="font-semibold">Log Check-in: {checkinTarget.user?.firstName} {checkinTarget.user?.lastName}</h3>
<textarea value={checkinNotes} onChange={e => setCheckinNotes(e.target.value)} rows={4} placeholder="Check-in notes (min 20 characters)..." 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" />
<div className="flex justify-end gap-2">
<button onClick={() => setCheckinTarget(null)} className="px-4 py-2 text-sm rounded-lg border hover:bg-accent">Cancel</button>
<button onClick={handleCheckin} className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90">Log Check-in</button>
</div>
</div>
</div>
)}
{/* Result Modal */}
{resultTarget && (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={() => setResultTarget(null)}>
<div className="bg-card rounded-xl border p-6 max-w-md w-full space-y-4" onClick={e => e.stopPropagation()}>
<h3 className="font-semibold">
{resultDecision === 'PASSED' ? '✅ Pass' : '❌ Fail'} PIP: {resultTarget.user?.firstName} {resultTarget.user?.lastName}
</h3>
<p className="text-sm text-muted-foreground">
{resultDecision === 'PASSED'
? 'The contractor has met the improvement criteria. Their status will return to Active.'
: 'The contractor has failed the PIP. This may trigger termination proceedings.'}
</p>
<textarea value={resultNotes} onChange={e => setResultNotes(e.target.value)} rows={3} placeholder="Decision notes..." 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" />
<div className="flex justify-end gap-2">
<button onClick={() => setResultTarget(null)} className="px-4 py-2 text-sm rounded-lg border hover:bg-accent">Cancel</button>
<button onClick={handleResult} className={cn('px-4 py-2 text-sm text-white rounded-lg', resultDecision === 'PASSED' ? 'bg-emerald-600 hover:bg-emerald-700' : 'bg-red-600 hover:bg-red-700')}>
Confirm {resultDecision === 'PASSED' ? 'Pass' : 'Fail'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
\ No newline at end of file
......@@ -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