Cervo - Sell Project
Listed April 10, 2026
About Cervo
HRM System: Employee, Attendance, Leave, Payroll
Project details
Built with: TypeScript,JavaScript,CSS
Website: https://cervohrm.roncodes.site
Next step: Cervo HRM - Expansion Plan<p>A comprehensive guide for developers and buyers on how to extend, customize, and scale Cervo HRM beyond its core features.</p><h2>Table of Contents</h2><ol><li data-list="ordered"><a href="https://file+.vscode-resource.vscode-cdn.net/e%3A/RON%20DEV/NEXTJS/Cervo/EXPANSION_PLAN.md#1-project-architecture" rel="nofollow noopener noreferrer" target="_blank">Project Architecture</a></li><li data-list="ordered"><a href="https://file+.vscode-resource.vscode-cdn.net/e%3A/RON%20DEV/NEXTJS/Cervo/EXPANSION_PLAN.md#2-database-extension" rel="nofollow noopener noreferrer" target="_blank">Database Extension</a></li><li data-list="ordered"><a href="https://file+.vscode-resource.vscode-cdn.net/e%3A/RON%20DEV/NEXTJS/Cervo/EXPANSION_PLAN.md#3-adding-new-modules" rel="nofollow noopener noreferrer" target="_blank">Adding New Modules</a></li><li data-list="ordered"><a href="https://file+.vscode-resource.vscode-cdn.net/e%3A/RON%20DEV/NEXTJS/Cervo/EXPANSION_PLAN.md#4-extending-core-features" rel="nofollow noopener noreferrer" target="_blank">Extending Core Features</a></li><li data-list="ordered"><a href="https://file+.vscode-resource.vscode-cdn.net/e%3A/RON%20DEV/NEXTJS/Cervo/EXPANSION_PLAN.md#5-customizing-uiux" rel="nofollow noopener noreferrer" target="_blank">Customizing UI/UX</a></li><li data-list="ordered"><a href="https://file+.vscode-resource.vscode-cdn.net/e%3A/RON%20DEV/NEXTJS/Cervo/EXPANSION_PLAN.md#6-integrating-external-services" rel="nofollow noopener noreferrer" target="_blank">Integrating External Services</a></li><li data-list="ordered"><a href="https://file+.vscode-resource.vscode-cdn.net/e%3A/RON%20DEV/NEXTJS/Cervo/EXPANSION_PLAN.md#7-building-mobile-app" rel="nofollow noopener noreferrer" target="_blank">Building Mobile App</a></li><li data-list="ordered"><a href="https://file+.vscode-resource.vscode-cdn.net/e%3A/RON%20DEV/NEXTJS/Cervo/EXPANSION_PLAN.md#8-multi-tenant-setup" rel="nofollow noopener noreferrer" target="_blank">Multi-Tenant Setup</a></li><li data-list="ordered"><a href="https://file+.vscode-resource.vscode-cdn.net/e%3A/RON%20DEV/NEXTJS/Cervo/EXPANSION_PLAN.md#9-white-label--resell" rel="nofollow noopener noreferrer" target="_blank">White-Label & Resell</a></li></ol><h2>1. Project Architecture</h2><h3>Tech Stack</h3>LayerTechnologyFrontendNext.js 16 (App Router), React 19, TypeScript 5StylingTailwind CSSBackendNext.js Server ActionsDatabasePostgreSQL via Prisma ORMAuthJWT (jose) + bcrypt<h3>Folder Structure</h3><pre><code>Cervo/ βββ prisma/ β βββ schema.prisma # Database models β βββ seed.ts # Demo data seeder βββ src/ β βββ actions/ # Server Actions (API) β β βββ auth.ts β β βββ employees.ts β β βββ departments.ts β β βββ leave.ts β β βββ attendance.ts β β βββ payroll.ts β β βββ ... β βββ app/ # Next.js Pages β β βββ employees/ β β βββ attendance/ β β βββ leave/ β β βββ ... β βββ components/ # React Components β β βββ ui/ β βββ lib/ # Utilities β βββ authz.ts # RBAC permissions β βββ prisma.ts # Database client β βββ i18n.ts # Translations βββ docs/ # Documentation</code></pre><h3>Data Flow</h3><pre><code>User Action β Server Action β Prisma β PostgreSQL β Audit Log β Notification</code></pre><h2>2. Database Extension</h2><h3>Adding a New Field</h3><p><code>prisma/schema.prisma</code></p><pre><code>model Employee { // ... existing fields ... // Add new field employeeCode String? birthDate DateTime? emergencyContact String? }</code></pre><p>Run migration:</p><pre><code>npx prisma db push</code></pre><h3>Adding a New Model</h3><pre><code>model Training { id String @id @default(cuid()) title String description String? startDate DateTime endDate DateTime createdAt DateTime @default(now()) // Relations employees TrainingEnrollment[] } model TrainingEnrollment { id String @id @default(cuid()) trainingId String employeeId String status String @default("ENROLLED") training Training @relation(fields: [trainingId], references: [id]) employee Employee @relation(fields: [employeeId], references: [id]) }</code></pre><h2>3. Adding New Modules</h2><h3>Module 3.1: Recruitment / ATS</h3><h4>Database Models</h4><pre><code>model JobPosting { id String @id @default(cuid()) title String description String requirements String departmentId String salaryMin Decimal? salaryMax Decimal? status String @default("OPEN") createdAt DateTime @default(now()) department Department @relation(fields: [departmentId], references: [id]) applications Application[] } model Application { id String @id @default(cuid()) jobPostingId String firstName String lastName String email String phone String? resumeUrl String? status String @default("PENDING") createdAt DateTime @default(now()) jobPosting JobPosting @relation(fields: [jobPostingId], references: [id]) }</code></pre><h4>Server Action</h4><pre><code>// src/actions/recruitment.ts 'use server' import { prisma } from '@/lib/prisma' export async function getJobPostings() { return prisma.jobPosting.findMany({ where: { status: 'OPEN' }, include: { department: true } }) } export async function createApplication(data: { jobPostingId: string firstName: string lastName: string email: string }) { return prisma.application.create({ data }) }</code></pre><h3>Module 3.2: Asset Management</h3><pre><code>model Asset { id String @id @default(cuid()) name String type String // LAPTOP, PHONE, DESK, etc. serialNumber String? purchaseDate DateTime? status String @default("AVAILABLE") assignments AssetAssignment[] } model AssetAssignment { id String @id @default(cuid()) assetId String employeeId String assignedAt DateTime @default(now()) returnedAt DateTime? asset Asset @relation(fields: [assetId], references: [id]) employee Employee @relation(fields: [employeeId], references: [id]) }</code></pre><h3>Module 3.3: Expense Management</h3><pre><code>model ExpenseClaim { id String @id @default(cuid()) employeeId String title String amount Decimal category String // TRAVEL, MEAL, SUPPLIES, etc. status String @default("PENDING") receiptUrl String? createdAt DateTime @default(now()) employee Employee @relation(fields: [employeeId], references: [id]) }</code></pre><h3>Module 3.4: Training Management</h3><pre><code>model Course { id String @id @default(cuid()) title String description String? duration Int // hours status String @default("ACTIVE") sessions TrainingSession[] } model TrainingSession { id String @id @default(cuid()) courseId String trainer String startDate DateTime endDate DateTime location String? course Course @relation(fields: [courseId], references: [id]) enrollments TrainingEnrollment[] } model TrainingEnrollment { id String @id @default(cuid()) sessionId String employeeId String status String @default("ENROLLED") // ENROLLED, COMPLETED, CANCELLED score Int? session TrainingSession @relation(fields: [sessionId], references: [id]) employee Employee @relation(fields: [employeeId], references: [id]) }</code></pre><h2>4. Extending Core Features</h2><h3>4.1: Attendance Extensions</h3><h4>Face Recognition Check-in</h4><pre><code>// src/actions/attendance.ts import faceapi from 'face-api.js' export async function checkInWithFace(imageData: string) { // Load models await faceapi.nets.faceRecognitionNet.loadFromDisk('/models') await faceapi.nets.faceLandmark68Net.loadFromDisk('/models') // Detect and match face const img = await faceapi.bufferToImage(imageData) const result = await faceapi.detectSingleFace(img).withFaceRecognition() if (result) { // Match with employee faces stored in database const match = await findEmployeeByFace(result.descriptor) if (match) { return createCheckIn({ employeeId: match.id, method: 'FACE' }) } } return { error: 'Face not recognized' } }</code></pre><h4>Overtime Calculation</h4><pre><code>export async function calculateOvertime(employeeId: string, month: number, year: number) { const records = await prisma.attendanceRecord.findMany({ where: { employeeId, date: { gte: new Date(year, month - 1, 1), lt: new Date(year, month, 1) } } }) return records.reduce((total, record) => { if (record.checkOut) { const checkOutHour = record.checkOut.getHours() if (checkOutHour > 18) { const overtimeHours = checkOutHour - 18 return total + overtimeHours } } return total }, 0) }</code></pre><h4>Auto Holiday Detection</h4><pre><code>// Add public holidays const PUBLIC_HOLIDAYS = [ '2026-01-01', // New Year '2026-04-30', // Liberation Day '2026-05-01', // Labor Day '2026-09-02', // Independence Day ] export async function markHolidays() { for (const holiday of PUBLIC_HOLIDAYS) { await prisma.attendanceRecord.createMany({ data: { date: new Date(holiday), status: 'HOLIDAY', note: 'Public Holiday' }, skipDuplicates: true }) } }</code></pre><h3>4.2: Leave Extensions</h3><h4>Leave Carry Forward</h4><pre><code>export async function carryForwardLeave(employeeId: string, fromYear: number, toYear: number) { const balances = await prisma.leaveBalance.findMany({ where: { employeeId, year: fromYear, totalDays: { gt: 0 } } }) for (const balance of balances) { const unusedDays = balance.totalDays - balance.usedDays if (unusedDays > 0) { // Get leave type defaults for new year const leaveType = await prisma.leaveType.findUnique({ where: { id: balance.leaveTypeId } }) // Create new year balance with carried forward days await prisma.leaveBalance.create({ data: { employeeId, leaveTypeId: balance.leaveTypeId, totalDays: leaveType.defaultDays + Math.min(unusedDays, 5), // Max 5 days carry usedDays: 0, year: toYear } }) } } }</code></pre><h4>Leave Encashment</h4><pre><code>export async function encashLeave(employeeId: string, leaveTypeId: string, days: number, rate: number) { const balance = await prisma.leaveBalance.findUnique({ where: { employeeId_leaveTypeId_year: { employeeId, leaveTypeId, year: new Date().getFullYear() } } }) if (!balance || balance.totalDays - balance.usedDays < days) { return { error: 'Insufficient leave balance' } } const amount = days * rate // Create encashment record await prisma.leaveEncashment.create({ data: { employeeId, leaveTypeId, days, rate, amount, year: new Date().getFullYear() } }) // Deduct from balance await prisma.leaveBalance.update({ where: { id: balance.id }, data: { usedDays: balance.usedDays + days } }) return { success: true, amount } }</code></pre><h3>4.3: Payroll Extensions</h3><h4>Progressive Tax Calculator (Vietnam)</h4><pre><code>const TAX_BRACKETS = [ { min: 0, max: 5000000, rate: 0.05 }, { min: 5000000, max: 10000000, rate: 0.10 }, { min: 10000000, max: 18000000, rate: 0.15 }, { min: 18000000, max: 32000000, rate: 0.20 }, { min: 32000000, max: 52000000, rate: 0.25 }, { min: 52000000, max: 80000000, rate: 0.30 }, { min: 80000000, max: Infinity, rate: 0.35 }, ] export function calculateProgressiveTax(monthlyIncome: number) { let tax = 0 let remaining = monthlyIncome - 11000000 // Personal deduction for (const bracket of TAX_BRACKETS) { if (remaining <= 0) break const taxableInBracket = Math.min(remaining, bracket.max - bracket.min) tax += taxableInBracket * bracket.rate remaining -= taxableInBracket } return Math.max(0, tax) }</code></pre><h4>Bank Transfer Integration</h4><pre><code>// src/lib/payment.ts export async function processPayrollTransfer(payslipId: string) { const payslip = await prisma.payslip.findUnique({ where: { id: payslipId }, include: { employee: { include: { bankAccounts: { where: { isPrimary: true } } } } } }) if (!payslip?.employee.bankAccounts[0]) { return { error: 'No bank account found' } } const bankAccount = payslip.employee.bankAccounts[0] // Call bank API (example: Vietcombank) const response = await fetch('https://api.vietcombank.com.vn/transfer', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.BANK_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ accountNumber: bankAccount.accountNumber, bankCode: bankAccount.bankName, amount: payslip.netPay, description: `Salary ${payslip.month}/${payslip.year}` }) }) if (response.ok) { await prisma.payslip.update({ where: { id: payslipId }, data: { status: 'PAID', paidAt: new Date() } }) } return response.json() }</code></pre><h2>5. Customizing UI/UX</h2><h3>5.1: Changing Theme</h3><p><code>tailwind.config.ts</code></p><pre><code>export default { theme: { extend: { colors: { primary: { DEFAULT: '#your-brand-color', light: '#lighter-shade', dark: '#darker-shade', } } } } }</code></pre><h3>5.2: Adding Language Support</h3><p><code>src/lib/i18n.ts</code></p><pre><code>export const translations = { vi: { nav: { dashboard: 'BαΊ£ng Δiα»u khiα»n', employees: 'NhΓ’n viΓͺn' }, attendance: { checkIn: 'Check in', checkOut: 'Check out' }, leave: { request: 'YΓͺu cαΊ§u nghα» phΓ©p', balance: 'Sα» ngΓ y nghα»' }, }, en: { nav: { dashboard: 'Dashboard', employees: 'Employees' }, attendance: { checkIn: 'Check In', checkOut: 'Check Out' }, leave: { request: 'Leave Request', balance: 'Leave Balance' }, }, th: { nav: { dashboard: 'ΰΉΰΈΰΈΰΈΰΈΰΈ£ΰΉΰΈ', employees: 'ΰΈΰΈΰΈ±ΰΈΰΈΰΈ²ΰΈ' }, attendance: { checkIn: 'ΰΉΰΈΰΉΰΈΰΈΰΈ΄ΰΈ', checkOut: 'ΰΉΰΈΰΉΰΈΰΉΰΈΰΈ²ΰΈΰΉ' }, leave: { request: 'ΰΈΰΈΰΈ₯ΰΈ²ΰΈΰΈ²ΰΈ', balance: 'ΰΈ§ΰΈ±ΰΈΰΈ₯ΰΈ²ΰΈΰΈ΅ΰΉΰΈ‘ΰΈ΅' }, } }</code></pre><h3>5.3: Custom Dashboard Widgets</h3><pre><code>// src/components/dashboard/Widget.tsx export function CustomWidget({ title, children }: { title: string, children: React.ReactNode }) { return ( <div className="bg-white rounded-xl p-6 shadow-sm border border-slate-200"> <h3 className="font-bold text-lg mb-4">{title}</h3> {children} </div> ) }</code></pre><h2>6. Integrating External Services</h2><h3>6.1: Email Integration (SendGrid)</h3><pre><code>// src/lib/email.ts import sgMail from '@sendgrid/mail' sgMail.setApiKey(process.env.SENDGRID_API_KEY!) export async function sendEmail(to: string, subject: string, html: string) { return sgMail.send({ to, from: process.env.EMAIL_FROM!, subject, html }) } // Usage in notifications export async function sendLeaveApprovedEmail(employee: Employee, leave: LeaveRequest) { await sendEmail( employee.email, 'Leave Request Approved', `<h1>Your leave has been approved!</h1> <p>From: ${leave.startDate}</p> <p>To: ${leave.endDate}</p>` ) }</code></pre><h3>6.2: SMS Integration (Twilio)</h3><pre><code>// src/lib/sms.ts import twilio from 'twilio' const client = twilio( process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN ) export async function sendSMS(to: string, message: string) { return client.messages.create({ body: message, from: process.env.TWILIO_PHONE_NUMBER, to }) }</code></pre><h3>6.3: Slack Integration</h3><pre><code>// src/lib/slack.ts export async function sendSlackNotification(channel: string, message: string) { return fetch(`https://slack.com/api/chat.postMessage`, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.SLACK_BOT_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ channel, text: message }) }) } // Usage export async function notifySlackChannel(channel: string, employee: Employee, action: string) { await sendSlackNotification( channel, `:bell: *${action}*\nEmployee: ${employee.firstName} ${employee.lastName}` ) }</code></pre><h3>6.4: Google Calendar Integration</h3><pre><code>// src/lib/calendar.ts import { google } from 'googleapis' const oauth2Client = new google.auth.OAuth2( process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET ) export async function createCalendarEvent(employee: Employee, leave: LeaveRequest) { oauth2Client.setCredentials({ refresh_token: employee.calendarRefreshToken }) const calendar = google.calendar({ version: 'v3', auth: oauth2Client }) return calendar.events.insert({ calendarId: 'primary', requestBody: { summary: `Leave: ${employee.firstName} ${employee.lastName}`, start: { date: leave.startDate }, end: { date: leave.endDate }, attendees: [{ email: employee.email }] } }) }</code></pre><h2>7. Building Mobile App</h2><h3>Architecture</h3><pre><code>βββββββββββββββββββββββββββββββββββββββ β Cervo Backend β β (Next.js Server Actions) β βββββββββββββββββββ¬ββββββββββββββββββββ β REST API β βββββββββββββββββββΌββββββββββββββββββββ β Mobile App β β Flutter / React Native / PWA β βββββββββββββββββββββββββββββββββββββββ</code></pre><h3>API Wrapper (Flutter Example)</h3><pre><code>class CervoApi { final String baseUrl; Future<List<Employee>> getEmployees() async { final response = await http.get( Uri.parse('$baseUrl/api/employees'), headers: {'Authorization': 'Bearer $token'} ); return (jsonDecode(response.body) as List) .map((e) => Employee.fromJson(e)) .toList(); } Future<void> checkIn({String? lat, String? lng}) async { await http.post( Uri.parse('$baseUrl/api/attendance/checkin'), body: jsonEncode({ 'lat': lat, 'lng': lng, 'timestamp': DateTime.now().toIso8601String() }) ); } Future<void> createLeaveRequest({ required String leaveTypeId, required DateTime startDate, required DateTime endDate, String? reason }) async { await http.post( Uri.parse('$baseUrl/api/leave/request'), body: jsonEncode({ 'leaveTypeId': leaveTypeId, 'startDate': startDate.toIso8601String(), 'endDate': endDate.toIso8601String(), 'reason': reason }) ); } }</code></pre><h3>PWA (Progressive Web App)</h3><p>Cervo can be turned into PWA by adding:</p><pre><code>// public/manifest.json { "name": "Cervo HRM", "short_name": "Cervo", "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#primary-color", "icons": [ { "src": "/icons/icon-192.png", "sizes": "192x192" }, { "src": "/icons/icon-512.png", "sizes": "512x512" } ] }</code></pre><h2>8. Multi-Tenant Setup</h2><h3>Database Structure</h3><pre><code>model Tenant { id String @id @default(cuid()) name String subdomain String @unique logo String? settings Json @default("{}") createdAt DateTime @default(now()) users User[] employees Employee[] departments Department[] } model User { id String @id @default(cuid()) email String tenantId String tenant Tenant @relation(fields: [tenantId], references: [id]) }</code></pre><h3>Middleware for Multi-Tenant</h3><pre><code>// src/middleware.ts export async function middleware(request: NextRequest) { const hostname = request.headers.get('host') const subdomain = hostname?.split('.')[0] if (subdomain && subdomain !== 'www') { // Find tenant by subdomain const tenant = await prisma.tenant.findUnique({ where: { subdomain } }) if (tenant) { // Set tenant context const response = NextResponse.next() response.cookies.set('tenantId', tenant.id) return response } } return NextResponse.next() }</code></pre><h3>Tenant-Scoped Queries</h3><pre><code>export async function getEmployees() { const tenantId = cookies().get('tenantId') return prisma.employee.findMany({ where: { tenantId } }) }</code></pre><h2>9. White-Label & Resell</h2><h3>9.1: Remove Branding</h3><pre><code>// src/app/layout.tsx // Replace logo const logoUrl = process.env.NEXT_PUBLIC_COMPANY_LOGO || '/logo.svg' // Replace app name const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Cervo HRM'</code></pre><h3>9.2: Custom Email Templates</h3><pre><code>// src/lib/email-templates.ts export const emailTemplates = { leaveApproved: (data) => ` <html> <body style="font-family: Arial;"> <img src="${data.companyLogo}" alt="Logo" /> <h1>Leave Request Approved</h1> <p>Dear ${data.employeeName},</p> <p>Your leave request has been approved.</p> <p>Company: ${data.companyName}</p> </body> </html> ` }</code></pre><h3>9.3: Configurable Settings</h3><pre><code>// src/lib/tenant-config.ts interface TenantConfig { logo: string primaryColor: string companyName: string smtpConfig: { host: string port: number user: string } } export async function getTenantConfig(tenantId: string): Promise<TenantConfig> { const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { settings: true } }) return tenant?.settings as TenantConfig }</code></pre><h2>Quick Start Checklist</h2><ol><li data-list="bullet">[ ] Understand project structure</li><li data-list="bullet">[ ] Setup local development</li><li data-list="bullet">[ ] Read existing code patterns</li><li data-list="bullet">[ ] Choose extension area</li><li data-list="bullet">[ ] Plan your feature</li><li data-list="bullet">[ ] Add database models</li><li data-list="bullet">[ ] Create server actions</li><li data-list="bullet">[ ] Build UI components</li><li data-list="bullet">[ ] Test thoroughly</li><li data-list="bullet">[ ] Document your changes</li></ol><h2>Resources</h2>ResourceLinkNext.js Docs<a href="https://nextjs.org/docs" rel="nofollow noopener noreferrer" target="_blank">https://nextjs.org/docs</a>Prisma Docs<a href="https://prisma.io/docs" rel="nofollow noopener noreferrer" target="_blank">https://prisma.io/docs</a>Tailwind CSS<a href="https://tailwindcss.com/docs" rel="nofollow noopener noreferrer" target="_blank">https://tailwindcss.com/docs</a>TypeScript<a href="https://typescriptlang.org/docs" rel="nofollow noopener noreferrer" target="_blank">https://typescriptlang.org/docs</a>
For sale
Asking price: $1,763 USD
What's included
Source code
Reason for selling
I am currently in need of money and would like to sell this project.
Project metrics
Average Monthly Revenue: Pre-revenue