"use server"; import { createAdminClient } from "@/lib/supabase/server"; import { auth } from "@clerk/nextjs/server"; import type { LeaveType, LeaveRequest, LeaveRequestWithDetails, LeaveBalance, CreateLeaveRequestInput, } from "./leave-types"; import { DEFAULT_LEAVE_TYPES } from "./leave-types"; import { sendEmail, leaveApprovalEmailTemplate, leaveRejectionEmailTemplate, } from "@/lib/email"; import { reportUsage } from "@/lib/supabase/stripe-actions"; // ── Helpers ────────────────────────────────────────────────── async function getOrganisationId(): Promise { const { userId } = await auth(); if (!userId) throw new Error("Unauthenticated"); const supabase = await createAdminClient(); const { data: member, error } = await supabase .from("organisation_members") .select("organisation_id") .eq("user_id", userId) .single(); if (error || !member) throw new Error("No organisation found"); return member.organisation_id; } async function getCurrentUserRole(): Promise { const { userId } = await auth(); if (!userId) throw new Error("Unauthenticated"); const orgId = await getOrganisationId(); const supabase = await createAdminClient(); const { data } = await supabase .from("organisation_members") .select("role") .eq("user_id", userId) .eq("organisation_id", orgId) .single(); return data?.role || "employee"; } /** * Seed default leave types for an organisation if none exist. */ export async function seedLeaveTypes(organisationId: string): Promise { const supabase = await createAdminClient(); const { data: existing } = await supabase .from("leave_types") .select("id") .eq("organisation_id", organisationId) .limit(1); if (existing && existing.length > 0) return; await supabase .from("leave_types") .insert( DEFAULT_LEAVE_TYPES.map((t) => ({ organisation_id: organisationId, name: t.name, default_days_per_year: t.default_days_per_year, is_paid: t.is_paid, })) ); } // ── Fetch functions ────────────────────────────────────────── export async function fetchLeaveTypes(): Promise { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); const { data, error } = await supabase .from("leave_types") .select("*") .eq("organisation_id", orgId) .order("name", { ascending: true }); if (error) throw error; // Seed defaults if none exist if (!data || data.length === 0) { await seedLeaveTypes(orgId); const { data: seeded } = await supabase .from("leave_types") .select("*") .eq("organisation_id", orgId) .order("name", { ascending: true }); if (error) throw error; return (seeded || []) as LeaveType[]; } return (data || []) as LeaveType[]; } export async function fetchLeaveRequests( status?: "pending" | "approved" | "rejected" | "cancelled" ): Promise { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); let query = supabase .from("leave_requests") .select( ` *, employee:employees(id, first_name, last_name, email, job_title), leave_type:leave_types(id, name, default_days_per_year, is_paid) ` ) .eq("organisation_id", orgId) .order("created_at", { ascending: false }); if (status) { query = query.eq("status", status); } const { data, error } = await query; if (error) throw error; return (data || []).map((r: any) => ({ ...r, approved_by: r.approved_by, rejected_reason: r.rejected_reason, approver: null, // Would need to join profiles/approver details employee: r.employee ? { id: r.employee.id, first_name: r.employee.first_name, last_name: r.employee.last_name, email: r.employee.email, job_title: r.employee.job_title, } : null, leave_type: r.leave_type ? ({ id: r.leave_type.id, name: r.leave_type.name, organisation_id: orgId, default_days_per_year: r.leave_type.default_days_per_year, is_paid: r.leave_type.is_paid, created_at: "", } as LeaveType) : null, })) as LeaveRequestWithDetails[]; } export async function fetchLeaveRequestsForCalendar(): Promise< LeaveRequestWithDetails[] > { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); // Get approved leave for current month ± 1 month const now = new Date(); const monthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1) .toISOString() .split("T")[0]; const monthEnd = new Date(now.getFullYear(), now.getMonth() + 2, 0) .toISOString() .split("T")[0]; const { data, error } = await supabase .from("leave_requests") .select( ` *, employee:employees(id, first_name, last_name, email, job_title), leave_type:leave_types(id, name, default_days_per_year, is_paid) ` ) .eq("organisation_id", orgId) .eq("status", "approved") .gte("start_date", monthStart) .lte("start_date", monthEnd) .order("start_date", { ascending: true }); if (error) throw error; return (data || []).map((r: any) => ({ ...r, approver: null, employee: r.employee ? { id: r.employee.id, first_name: r.employee.first_name, last_name: r.employee.last_name, email: r.employee.email, job_title: r.employee.job_title, } : null, leave_type: r.leave_type ? ({ id: r.leave_type.id, name: r.leave_type.name, organisation_id: orgId, default_days_per_year: r.leave_type.default_days_per_year, is_paid: r.leave_type.is_paid, created_at: "", } as LeaveType) : null, })) as LeaveRequestWithDetails[]; } /** * Get leave balance for a specific employee. * Uses accrual-based calculation if accrual rules exist, * otherwise falls back to default_days_per_year. */ export async function fetchEmployeeLeaveBalance( employeeId: string ): Promise { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); // Get all leave types for this organisation const { data: leaveTypes, error: ltError } = await supabase .from("leave_types") .select("*") .eq("organisation_id", orgId) .order("name", { ascending: true }); if (ltError) throw ltError; if (!leaveTypes || leaveTypes.length === 0) return []; // Check if accrual rules exist for this organisation const { data: accrualRules } = await supabase .from("leave_accrual_rules") .select("leave_type_id, accrual_rate, accrual_frequency, max_balance") .eq("organisation_id", orgId); const hasAccrualRules = accrualRules && accrualRules.length > 0; if (hasAccrualRules) { // Use accrual-based calculation // Get employee start date once const { data: employee } = await supabase .from("employees") .select("start_date, created_at") .eq("id", employeeId) .single(); // Get all leave requests for this employee across all leave types const { data: allRequests, error: allReqError } = await supabase .from("leave_requests") .select("leave_type_id, days_count, status, accrued_balance") .eq("employee_id", employeeId) .eq("organisation_id", orgId); if (allReqError) throw allReqError; const startDate = employee ? new Date(employee.start_date || employee.created_at) : null; const now = new Date(); const monthsSince = startDate ? (now.getFullYear() - startDate.getFullYear()) * 12 + now.getMonth() - startDate.getMonth() : 0; return leaveTypes.map((lt) => { const rule = accrualRules?.find((r) => r.leave_type_id === lt.id); // Calculate total accrued let totalAccrued = lt.default_days_per_year; // fallback if (rule && employee) { let earned = 0; if (rule.accrual_frequency === "monthly") { earned = Math.max(0, monthsSince * Number(rule.accrual_rate)); } else if (rule.accrual_frequency === "fortnightly") { earned = Math.max(0, monthsSince * 2 * Number(rule.accrual_rate)); } else { earned = Math.max(0, monthsSince * 4 * Number(rule.accrual_rate)); } if (rule.max_balance) { earned = Math.min(earned, Number(rule.max_balance)); } totalAccrued = earned; } // Get actual accrued balances from leave_requests entries const typeRequests = allRequests?.filter((r) => r.leave_type_id === lt.id) || []; const totalFromEntries = typeRequests .filter((e) => e.status === "accrued") .reduce((sum, e) => sum + Number(e.accrued_balance || 0), 0); const usedDays = typeRequests .filter((r) => r.status === "approved") .reduce((sum, r) => sum + Number(r.days_count), 0); const approvedDays = typeRequests .filter((r) => r.status === "approved" || r.status === "pending") .reduce((sum, r) => sum + Number(r.days_count), 0); const effectiveTotal = totalFromEntries > 0 ? totalFromEntries : totalAccrued; return { leave_type_id: lt.id, leave_type_name: lt.name, total_days_per_year: effectiveTotal, used_days: usedDays, approved_days: approvedDays, remaining_days: Math.max(0, effectiveTotal - approvedDays), is_paid: lt.is_paid, }; }); } // Fallback: original calculation based on default_days_per_year const yearStart = `${new Date().getFullYear()}-01-01`; const yearEnd = `${new Date().getFullYear()}-12-31`; // Get approved leave requests for this employee this year const { data: requests, error: reqError } = await supabase .from("leave_requests") .select("leave_type_id, days_count, status") .eq("employee_id", employeeId) .eq("organisation_id", orgId) .gte("start_date", yearStart) .lte("start_date", yearEnd); if (reqError) throw reqError; return leaveTypes.map((lt) => { const usedDays = (requests || []) .filter( (r) => r.leave_type_id === lt.id && r.status === "approved" ) .reduce((sum, r) => sum + Number(r.days_count), 0); const approvedDays = (requests || []) .filter( (r) => r.leave_type_id === lt.id && (r.status === "approved" || r.status === "pending") ) .reduce((sum, r) => sum + Number(r.days_count), 0); return { leave_type_id: lt.id, leave_type_name: lt.name, total_days_per_year: lt.default_days_per_year, used_days: usedDays, approved_days: approvedDays, remaining_days: lt.default_days_per_year - approvedDays, is_paid: lt.is_paid, }; }); } // ── Fetch employees (minimal, for leave dropdown) ──────────── export async function fetchEmployees(): Promise<{ id: string; first_name: string; last_name: string; email: string; job_title: string | null; }[]> { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); const { data, error } = await supabase .from("employees") .select("id, first_name, last_name, email, job_title") .eq("organisation_id", orgId) .eq("status", "active") .order("first_name", { ascending: true }); if (error) throw error; return (data || []) as any[]; } // ── Server actions (writes) ────────────────────────────────── export async function createLeaveRequest( input: CreateLeaveRequestInput ): Promise<{ success: boolean; leaveRequestId?: string; error?: string }> { const { userId } = await auth(); if (!userId) return { success: false, error: "Unauthenticated" }; const orgId = await getOrganisationId(); const supabase = await createAdminClient(); const { data, error } = await supabase .from("leave_requests") .insert({ employee_id: input.employee_id, organisation_id: orgId, leave_type_id: input.leave_type_id, start_date: input.start_date, end_date: input.end_date, days_count: input.days_count, reason: input.reason || null, status: "pending", }) .select("id") .single(); if (error) { return { success: false, error: error.message }; } // Report usage for PAYG billing (non-blocking) reportUsage("leave-requests", 1).catch(() => {}); return { success: true, leaveRequestId: data.id }; } export async function approveLeaveRequest( leaveRequestId: string, approvedBy: string ): Promise<{ success: boolean; error?: string }> { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); // Fetch leave request details before updating const { data: lr } = await supabase .from("leave_requests") .select("*, employee:employees(first_name, last_name, email), leave_type:leave_types(name)") .eq("id", leaveRequestId) .eq("organisation_id", orgId) .single(); if (!lr) return { success: false, error: "Leave request not found" }; const { error } = await supabase .from("leave_requests") .update({ status: "approved", approved_by: approvedBy, updated_at: new Date().toISOString(), }) .eq("id", leaveRequestId) .eq("organisation_id", orgId) .eq("status", "pending"); if (error) return { success: false, error: error.message }; // Send approval notification email if (lr.employee?.email) { const template = leaveApprovalEmailTemplate( `${lr.employee.first_name} ${lr.employee.last_name}`, lr.leave_type?.name || "Leave", new Date(lr.start_date + "T00:00:00").toLocaleDateString("en-AU", { day: "numeric", month: "long", year: "numeric" }), new Date(lr.end_date + "T00:00:00").toLocaleDateString("en-AU", { day: "numeric", month: "long", year: "numeric" }) ); await sendEmail({ to: [lr.employee.email], subject: template.subject, html: template.html, }); } return { success: true }; } export async function rejectLeaveRequest( leaveRequestId: string, rejectedBy: string, reason: string ): Promise<{ success: boolean; error?: string }> { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); // Fetch leave request details before updating const { data: lr } = await supabase .from("leave_requests") .select("*, employee:employees(first_name, last_name, email), leave_type:leave_types(name)") .eq("id", leaveRequestId) .eq("organisation_id", orgId) .single(); if (!lr) return { success: false, error: "Leave request not found" }; const { error } = await supabase .from("leave_requests") .update({ status: "rejected", approved_by: rejectedBy, rejected_reason: reason, updated_at: new Date().toISOString(), }) .eq("id", leaveRequestId) .eq("organisation_id", orgId) .eq("status", "pending"); if (error) return { success: false, error: error.message }; // Send rejection notification email if (lr.employee?.email) { const template = leaveRejectionEmailTemplate( `${lr.employee.first_name} ${lr.employee.last_name}`, lr.leave_type?.name || "Leave", new Date(lr.start_date + "T00:00:00").toLocaleDateString("en-AU", { day: "numeric", month: "long", year: "numeric" }), new Date(lr.end_date + "T00:00:00").toLocaleDateString("en-AU", { day: "numeric", month: "long", year: "numeric" }), reason ); await sendEmail({ to: [lr.employee.email], subject: template.subject, html: template.html, }); } return { success: true }; } export async function cancelLeaveRequest( leaveRequestId: string ): Promise<{ success: boolean; error?: string }> { const { userId } = await auth(); if (!userId) return { success: false, error: "Unauthenticated" }; const orgId = await getOrganisationId(); const supabase = await createAdminClient(); const { error } = await supabase .from("leave_requests") .update({ status: "cancelled", updated_at: new Date().toISOString() }) .eq("id", leaveRequestId) .eq("organisation_id", orgId) .in("status", ["pending"]); if (error) return { success: false, error: error.message }; return { success: true }; } export async function createLeaveType( name: string, defaultDaysPerYear: number, isPaid: boolean ): Promise<{ success: boolean; error?: string }> { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); const { error } = await supabase .from("leave_types") .insert({ organisation_id: orgId, name, default_days_per_year: defaultDaysPerYear, is_paid: isPaid, }); if (error) return { success: false, error: error.message }; return { success: true }; }