// RewardRulesModal.jsx import React, { useEffect, useState } from "react"; import axios from "axios"; import Swal from "sweetalert2"; import Swal from "sweetalert2"; import { confirmHoldToDelete } from "../../components/utils/confirmHoldToDelete"; import PropTypes from "prop-types"; import BASE_URL from "@backend/config"; // MUI (dialog container + small controls) import { Dialog, DialogTitle, DialogContent, DialogActions, Button, CircularProgress, Typography, Box, IconButton, Tooltip, } from "@mui/material"; import AddIcon from "@mui/icons-material/Add"; import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; import RefreshIcon from "@mui/icons-material/Refresh"; /** * RewardRulesModal * - MUI Dialog container * - Inputs are simple HTML elements styled with Tailwind classes * - Behavior maps to your reward_rules table * * NOTE: This component expects the following backend endpoints (adjust if your routes differ): * - GET `${BASE_URL}/api/rewards/get_reward_rules` * - POST `${BASE_URL}/api/rewards/create_reward_rule` * - PUT `${BASE_URL}/api/rewards/update_reward_rule` * - DELETE `${BASE_URL}/api/rewards/delete_reward_rule` * - (optional) GET `${BASE_URL}/api/rewards/get_departments` * - (optional) GET `${BASE_URL}/api/rewards/get_position` * * The form supports scoping rules to "all", a specific department, or a specific position. */ export default function RewardRulesModal({ open, onClose }) { const [rules, setRules] = useState([]); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [editing, setEditing] = useState(null); // reward_rule_id when editing const [departments, setDepartments] = useState([]); const [positions, setPositions] = useState([]); const [loadingMeta, setLoadingMeta] = useState(false); const [form, setForm] = useState({ reward_rule_id: null, name: "", description: "", min_total_hours: "", min_daily_hours: "", // <-- added min_daily_hours_max: "", // <-- NEW: lower bound used for deduction ranges (optional) min_days_credited: "", payout_type: "fixed", payout_value: "", is_active: true, is_deduction: false, // <-- NEW: rule mode priority: 10, applies_to_employee_type: "", // NEW: scope and selected dept/position applies_scope: "all", // 'all' | 'department' | 'position' applies_to_department_id: "", applies_to_position_id: "", }); // ensure SweetAlert2 appears above MUI Dialog useEffect(() => { const id = "swal2-top-style"; if (!document.getElementById(id)) { const style = document.createElement("style"); style.id = id; style.innerHTML = ` .swal2-container, .swal2-backdrop { z-index: 20000 !important; } `; document.head.appendChild(style); } }, []); // fetch rules sorted by priority asc const fetchRules = async () => { setLoading(true); try { const res = await axios.get(`${BASE_URL}/api/rewards/get_reward_rules`); const data = res.data?.data ?? []; const normalized = (Array.isArray(data) ? data : []) .map((r) => ({ ...r, payout_value: r.payout_value ?? 0, min_total_hours: r.min_total_hours ?? null, min_daily_hours: r.min_daily_hours ?? null, min_daily_hours_max: r.min_daily_hours_max ?? null, // <-- normalize min_days_credited: r.min_days_credited ?? null, is_active: Number(r.is_active ?? r.active ?? 0) === 1, is_deduction: Number(r.is_deduction ?? 0) === 1, // <-- normalize applies_to_department_id: r.applies_to_department_id ?? null, applies_to_position_id: r.applies_to_position_id ?? null, })) .sort( (a, b) => Number(a.priority ?? 9999) - Number(b.priority ?? 9999), ); setRules(normalized); } catch (err) { console.error("Failed to fetch rules:", err); Swal.fire("Error", "Failed to load reward rules.", "error"); } finally { setLoading(false); } }; // fetch departments & positions for selects (simple endpoints) const fetchMeta = async () => { setLoadingMeta(true); try { // Try endpoints - adapt URLs if your actual path differs const [deptRes, posRes] = await Promise.allSettled([ axios.get(`${BASE_URL}/api/rewards/get_departments`), axios.get(`${BASE_URL}/api/rewards/get_position`), ]); if ( deptRes.status === "fulfilled" && Array.isArray(deptRes.value.data?.data) ) { setDepartments(deptRes.value.data.data); } else if ( deptRes.status === "fulfilled" && Array.isArray(deptRes.value.data) ) { setDepartments(deptRes.value.data); } else { setDepartments([]); } if ( posRes.status === "fulfilled" && Array.isArray(posRes.value.data?.data) ) { setPositions(posRes.value.data.data); } else if ( posRes.status === "fulfilled" && Array.isArray(posRes.value.data) ) { setPositions(posRes.value.data); } else { setPositions([]); } } catch (err) { console.warn("Failed to fetch departments/positions (non-fatal):", err); setDepartments([]); setPositions([]); } finally { setLoadingMeta(false); } }; useEffect(() => { if (open) { fetchRules(); fetchMeta(); } }, [open]); const resetForm = () => { setForm({ reward_rule_id: null, name: "", description: "", min_total_hours: "", min_daily_hours: "", min_daily_hours_max: "", min_days_credited: "", payout_type: "fixed", payout_value: "", is_active: true, is_deduction: false, priority: 10, applies_to_employee_type: "", applies_scope: "all", applies_to_department_id: "", applies_to_position_id: "", }); setEditing(null); }; // basic validation const isFormValid = () => { if (!form.name || !form.name.trim()) return false; const pv = form.payout_value === "" ? 0 : Number(form.payout_value); if (Number.isNaN(pv) || pv < 0) return false; if (form.payout_type === "percentage" && pv > 100) return false; const pr = Number(form.priority); if (Number.isNaN(pr) || !Number.isInteger(pr)) return false; if ( form.applies_scope === "department" && (!form.applies_to_department_id || form.applies_to_department_id === "") ) return false; if ( form.applies_scope === "position" && (!form.applies_to_position_id || form.applies_to_position_id === "") ) return false; // if provided, min_daily_hours_max must be numeric and >= 0 and <= 24 if (form.min_daily_hours_max !== "" && form.min_daily_hours_max !== null) { const v = Number(form.min_daily_hours_max); if (Number.isNaN(v) || v < 0 || v > 24) return false; } // min_daily_hours if provided must be numeric and >=0 <=24 if (form.min_daily_hours !== "" && form.min_daily_hours !== null) { const v2 = Number(form.min_daily_hours); if (Number.isNaN(v2) || v2 < 0 || v2 > 24) return false; } return true; }; const handleSave = async () => { if (!isFormValid()) { Swal.fire( "Invalid", "Please check required fields and numeric inputs (hours/percent).", "warning", ); return; } setSaving(true); Swal.fire({ title: "Saving...", allowOutsideClick: false, didOpen: () => Swal.showLoading(), }); // Determine applies_to_department_id / applies_to_position_id based on scope let applies_to_department_id = null; let applies_to_position_id = null; if (form.applies_scope === "department") { applies_to_department_id = form.applies_to_department_id || null; } else if (form.applies_scope === "position") { applies_to_position_id = form.applies_to_position_id || null; } const payload = { reward_rule_id: editing ? editing : null, name: (form.name || "").trim(), description: (form.description || "").trim() || null, min_total_hours: form.min_total_hours === "" ? null : Number(form.min_total_hours), min_daily_hours: form.min_daily_hours === "" ? null : Number(form.min_daily_hours), min_daily_hours_max: form.min_daily_hours_max === "" ? null : Number(form.min_daily_hours_max), // <-- NEW min_days_credited: form.min_days_credited === "" ? null : Number(form.min_days_credited), payout_type: form.payout_type, payout_value: Number(form.payout_value || 0), is_active: form.is_active ? 1 : 0, is_deduction: form.is_deduction ? 1 : 0, // <-- NEW priority: Number(form.priority || 10), applies_to_employee_type: (form.applies_to_employee_type || "").toString().trim() === "" ? null : (form.applies_to_employee_type || "").toString().trim(), // NEW fields that your DB expects (we set only according to scope) applies_to_department_id: applies_to_department_id, applies_to_position_id: applies_to_position_id, }; try { let res; if (editing) { res = await axios.put( `${BASE_URL}/api/rewards/update_reward_rule`, payload, { headers: { "Content-Type": "application/json" }, }, ); } else { res = await axios.post( `${BASE_URL}/api/rewards/create_reward_rule`, payload, { headers: { "Content-Type": "application/json" }, }, ); } if (res.data?.success) { Swal.close(); Swal.fire( "Saved", editing ? "Rule updated." : "Rule created.", "success", ); resetForm(); await fetchRules(); } else { Swal.fire( "Failed", res.data?.message || "Server rejected request.", "error", ); } } catch (err) { console.error("save error", err); Swal.fire("Error", "Network or server error while saving.", "error"); } finally { setSaving(false); } }; const handleDelete = async (id) => { const ok = await confirmHoldToDelete({ title: "Delete rule?", text: "This will permanently remove the rule. Proceed?", }); if (!ok.isConfirmed) return; try { const res = await axios.delete( `${BASE_URL}/api/rewards/delete_reward_rule`, { data: { reward_rule_id: id } }, ); if (res.data?.success) { Swal.fire("Deleted", "Rule removed.", "success"); if (editing === id) resetForm(); await fetchRules(); } else { Swal.fire( "Failed", res.data?.message || "Server error while deleting.", "error", ); } } catch (err) { console.error("delete error", err); Swal.fire("Error", "Network error while deleting.", "error"); } }; const startEdit = (r) => { setEditing(r.reward_rule_id); // determine scope from the returned row values let scope = "all"; if (r.applies_to_department_id) scope = "department"; else if (r.applies_to_position_id) scope = "position"; setForm({ reward_rule_id: r.reward_rule_id, name: r.name ?? "", description: r.description ?? "", min_total_hours: r.min_total_hours === null ? "" : String(r.min_total_hours), min_daily_hours: r.min_daily_hours === null ? "" : String(r.min_daily_hours), min_daily_hours_max: r.min_daily_hours_max === null ? "" : String(r.min_daily_hours_max), // <-- NEW min_days_credited: r.min_days_credited === null ? "" : String(r.min_days_credited), payout_type: r.payout_type ?? "fixed", payout_value: r.payout_value === null ? "" : String(r.payout_value), is_active: Boolean(r.is_active), is_deduction: Boolean(r.is_deduction), // <-- NEW priority: r.priority ?? 10, applies_to_employee_type: r.applies_to_employee_type ?? "", applies_scope: scope, applies_to_department_id: r.applies_to_department_id ?? "", applies_to_position_id: r.applies_to_position_id ?? "", }); // ensure user sees the form area window.requestAnimationFrame(() => { const el = document.querySelector(".reward-rules-modal-top"); if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); }); }; const payoutHint = () => { if (form.payout_type === "fixed") return "Fixed amount in PHP (e.g. 500)."; if (form.payout_type === "per_hour") return "Amount per hour (PHP/hr)."; return "Percentage of basic salary — enter 5 for 5% (max 100)."; }; const formatDisplayAmount = (r) => { const pv = Number(r.payout_value || 0); if (r.payout_type === "fixed") return `₱${pv.toLocaleString("en-PH", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; if (r.payout_type === "per_hour") return `₱${pv.toLocaleString("en-PH", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}/hr`; return `${pv}%`; }; // ---- Helper: resolve names and scope badges ---- const getDeptName = (deptId) => { if (!deptId) return null; const d = departments.find( (x) => String(x.department_id) === String(deptId) || String(x.id) === String(deptId), ); return d ? (d.department_name ?? d.name ?? deptId) : deptId; }; const getPosName = (posId) => { if (!posId) return null; const p = positions.find( (x) => String(x.position_id) === String(posId) || String(x.id) === String(posId), ); return p ? (p.position_name ?? p.name ?? posId) : posId; }; const getScopeForRule = (r) => { if (r.applies_to_department_id) return "department"; if (r.applies_to_position_id) return "position"; return "all"; }; const renderScopeBadges = (r) => { const scope = getScopeForRule(r); const deptName = getDeptName(r.applies_to_department_id); const posName = getPosName(r.applies_to_position_id); // if scope === all -> show All badge (green) and show dept/pos as disabled (red) if (scope === "all") { return (
Applies to: All
Dept: not applied
Pos: not applied
); } if (scope === "department") { return (
Applies to: not all
Dept: {deptName}
Pos: not applied
); } // position return (
Applies to: not all
Dept: not applied
Pos: {posName}
); }; // ---- end helpers ---- return ( { resetForm(); onClose && onClose(); }} maxWidth="md" fullWidth >
Reward Rules
{/* FORM - tailwind styled inputs */}
setForm({ ...form, name: e.target.value })} placeholder="e.g. Midmonth Attendance Reward" />

Short, descriptive name (required).

How the payout is calculated.

setForm({ ...form, payout_value: e.target.value }) } placeholder={ form.payout_type === "percentage" ? "e.g. 5" : "e.g. 500" } />

{payoutHint()}

setForm({ ...form, priority: e.target.value })} />

Lower number = higher priority.

setForm({ ...form, min_daily_hours: e.target.value }) } placeholder="e.g. 8.00 — leave blank to ignore" />

Require each working day to have ≥ this many hours.

{/* NEW: Deduction toggle + min_daily_hours_max */}

When enabled, this rule will be considered a payroll-level deduction. Use Min daily hours and optionally the lower bound below to express a range (e.g. 0.00 < hours < 4.00).

{/*
setForm({ ...form, min_daily_hours_max: e.target.value })} placeholder="e.g. 0.00 — leave blank for 0.00" />

Optional lower bound for the per-day range used by deduction rules. If left blank the lower bound is assumed 0.0.

*/}