diff --git a/src/App.jsx b/src/App.jsx index 99d55a675..1bc6aa36c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -15,6 +15,7 @@ import InactivityHandler from "./helper/inactivityhandler"; import Examination from "./Modules/Examination/examination"; import Database from "./Modules/Database/database"; import ProgrammeCurriculumRoutes from "./Modules/Program_curriculum/programmCurriculum"; +import InstituteWorks from "./Modules/InstituteWorks"; import NotFoundPage from "./components/NotFoundPage"; const theme = createTheme({ @@ -82,6 +83,14 @@ export default function App() { } /> } /> } /> + + + + } + /> } /> diff --git a/src/Modules/InstituteWorks/AdminApprovalQueueView.jsx b/src/Modules/InstituteWorks/AdminApprovalQueueView.jsx new file mode 100644 index 000000000..faf53f4cc --- /dev/null +++ b/src/Modules/InstituteWorks/AdminApprovalQueueView.jsx @@ -0,0 +1,150 @@ +import { useEffect, useMemo, useState } from "react"; +import { notifications } from "@mantine/notifications"; +import AdminApprovalQueueTable from "./components/AdminApprovalQueueTable"; +import AdminApprovalActionModal from "./components/AdminApprovalActionModal"; +import { + getApiErrorMessage, + getDesignations, + getEngineerProcessedRequests, + submitAdminApproval, +} from "./api"; + +const DEAN_HOD_ROLES = [ + "dean (p&d)", + "deanpnd", + "dean_s", + "dean academic", + "dean (r&d)", + "dean_rspc", + "hod (cse)", + "hod (design)", + "hod (ece)", + "hod (me)", + "hod (ns)", + "hod (liberal arts)", + "hod", +]; + +function isDeanHodOption(value) { + const designation = String(value || "").split("|", 1)[0].trim().toLowerCase(); + return DEAN_HOD_ROLES.some((role) => designation.includes(role)); +} + +function hasProposal(row) { + return row?.estimated_budget != null || row?.estimatedBudget != null; +} + +function AdminApprovalQueueView() { + const [rows, setRows] = useState([]); + const [designationOptions, setDesignationOptions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [opened, setOpened] = useState(false); + const [selectedFileId, setSelectedFileId] = useState(null); + const [action, setAction] = useState("approve"); + const [designation, setDesignation] = useState(""); + const [remarks, setRemarks] = useState(""); + const [file, setFile] = useState(null); + + const load = async () => { + setIsLoading(true); + try { + const [queueRows, designationsData] = await Promise.all([ + getEngineerProcessedRequests(), + getDesignations(), + ]); + setRows(queueRows.filter(hasProposal)); + + const options = (designationsData?.holdsDesignations || []).map( + (item) => ({ + value: `${item.designation?.name || ""}|${item.username || ""}`, + label: `${item.designation?.name || "Unknown"} (${item.username || "-"})`, + }), + ); + setDesignationOptions(options.filter((item) => isDeanHodOption(item.value))); + } catch (error) { + notifications.show({ + color: "red", + message: getApiErrorMessage(error, "Unable to fetch admin queue."), + }); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const ready = useMemo( + () => Boolean(selectedFileId && action && designation), + [selectedFileId, action, designation], + ); + + const openActionModal = (fileId) => { + setSelectedFileId(fileId); + setAction("approve"); + setDesignation(""); + setRemarks(""); + setFile(null); + setOpened(true); + }; + + const submit = async (event) => { + event.preventDefault(); + if (!ready) return; + + setIsSaving(true); + try { + await submitAdminApproval({ + fileid: selectedFileId, + action, + designation, + remarks, + file, + }); + notifications.show({ + color: "green", + message: "Admin action submitted.", + }); + setOpened(false); + await load(); + } catch (error) { + notifications.show({ + color: "red", + message: getApiErrorMessage(error, "Unable to submit admin action."), + }); + } finally { + setIsSaving(false); + } + }; + + return ( + <> + + setOpened(false)} + onSubmit={submit} + action={action} + setAction={setAction} + designationOptions={designationOptions} + designation={designation} + setDesignation={setDesignation} + remarks={remarks} + setRemarks={setRemarks} + file={file} + setFile={setFile} + isSaving={isSaving} + isReady={ready} + /> + + ); +} + +export default AdminApprovalQueueView; diff --git a/src/Modules/InstituteWorks/BillAuditView.jsx b/src/Modules/InstituteWorks/BillAuditView.jsx new file mode 100644 index 000000000..57cea6782 --- /dev/null +++ b/src/Modules/InstituteWorks/BillAuditView.jsx @@ -0,0 +1,106 @@ +import { useEffect, useMemo, useState } from "react"; +import { notifications } from "@mantine/notifications"; +import { useSelector } from "react-redux"; +import BillAuditTable from "./components/BillAuditTable"; +import BillAuditModal from "./components/BillAuditModal"; +import { + getApiErrorMessage, + getAuditDocuments, + submitAuditDocument, +} from "./api"; + +function BillAuditView() { + const role = useSelector((state) => state.user.role); + const [rows, setRows] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [opened, setOpened] = useState(false); + const [selectedFileId, setSelectedFileId] = useState(null); + const [selectedBill, setSelectedBill] = useState(null); + const [remarks, setRemarks] = useState(""); + const [attachment, setAttachment] = useState(null); + + const load = async () => { + setIsLoading(true); + try { + const auditRows = await getAuditDocuments(role); + setRows(auditRows); + } catch (error) { + notifications.show({ + color: "red", + message: getApiErrorMessage(error, "Unable to fetch audit documents."), + }); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + load(); + }, [role]); + + const ready = useMemo( + () => Boolean(selectedFileId), + [selectedFileId], + ); + + const openAudit = (billRow) => { + setSelectedFileId(billRow?.file_id || null); + setSelectedBill(billRow || null); + setRemarks(""); + setAttachment(null); + setOpened(true); + }; + + const submit = async (event) => { + event.preventDefault(); + if (!ready) return; + + setIsSaving(true); + try { + await submitAuditDocument({ + fileid: selectedFileId, + remarks, + attachment, + }); + notifications.show({ + color: "green", + message: "Bill audited and forwarded.", + }); + setOpened(false); + await load(); + } catch (error) { + notifications.show({ + color: "red", + message: getApiErrorMessage(error, "Unable to audit and forward this bill."), + }); + } finally { + setIsSaving(false); + } + }; + + return ( + <> + + setOpened(false)} + onSubmit={submit} + selectedBill={selectedBill} + remarks={remarks} + setRemarks={setRemarks} + attachment={attachment} + setAttachment={setAttachment} + isSaving={isSaving} + isReady={ready} + /> + + ); +} + +export default BillAuditView; diff --git a/src/Modules/InstituteWorks/BillGenerationView.jsx b/src/Modules/InstituteWorks/BillGenerationView.jsx new file mode 100644 index 000000000..24176b178 --- /dev/null +++ b/src/Modules/InstituteWorks/BillGenerationView.jsx @@ -0,0 +1,143 @@ +import { useEffect, useMemo, useState } from "react"; +import { notifications } from "@mantine/notifications"; +import { useSelector } from "react-redux"; +import BillGenerationTable from "./components/BillGenerationTable"; +import BillGenerationModal from "./components/BillGenerationModal"; +import { getApiErrorMessage, getIssuedWork, markBillGenerated } from "./api"; + +function BillGenerationView() { + const role = useSelector((state) => state.user.role); + const [rows, setRows] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [workingId, setWorkingId] = useState(null); + const [opened, setOpened] = useState(false); + const [selectedRequestId, setSelectedRequestId] = useState(null); + const [vendorId, setVendorId] = useState(""); + const [billItems, setBillItems] = useState([ + { name: "", description: "", quantity: "", price: "" }, + ]); + + const isBillItemValid = (item) => + Boolean(item.name.trim()) && Number(item.quantity) > 0 && Number(item.price) >= 0; + + const billTotal = useMemo( + () => + billItems.reduce((sum, item) => { + const quantity = Number(item.quantity) || 0; + const price = Number(item.price) || 0; + return sum + quantity * price; + }, 0), + [billItems], + ); + + const load = async () => { + setIsLoading(true); + try { + const data = await getIssuedWork(role, { + work_completed: 1, + bill_generated: 0, + }); + setRows(data); + } catch (error) { + notifications.show({ + color: "red", + message: getApiErrorMessage(error, "Unable to fetch issued work list."), + }); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + load(); + }, [role]); + + const openGenerate = (id) => { + setSelectedRequestId(id); + setVendorId(""); + setBillItems([{ name: "", description: "", quantity: "", price: "" }]); + setOpened(true); + }; + + const updateBillItem = (index, field, value) => { + setBillItems((current) => + current.map((item, itemIndex) => + itemIndex === index ? { ...item, [field]: value } : item, + ), + ); + }; + + const addBillItem = () => { + setBillItems((current) => [ + ...current, + { name: "", description: "", quantity: "", price: "" }, + ]); + }; + + const removeBillItem = (index) => { + setBillItems((current) => current.filter((_, itemIndex) => itemIndex !== index)); + }; + + const submitGenerate = async (event) => { + event.preventDefault(); + if (!selectedRequestId || !billItems.some(isBillItemValid)) return; + + const normalizedBillItems = billItems + .filter(isBillItemValid) + .map((item) => ({ + name: item.name.trim(), + description: item.description.trim(), + quantity: Number(item.quantity), + price: Number(item.price), + })); + + setWorkingId(selectedRequestId); + try { + await markBillGenerated({ + id: selectedRequestId, + vendor_id: vendorId.trim(), + bill_items: normalizedBillItems, + }); + notifications.show({ + color: "green", + message: `Request #${selectedRequestId} moved to Generated Bills.`, + }); + setOpened(false); + await load(); + } catch (error) { + notifications.show({ + color: "red", + message: getApiErrorMessage(error, "Unable to mark bill generated."), + }); + } finally { + setWorkingId(null); + } + }; + + return ( + <> + + setOpened(false)} + onSubmit={submitGenerate} + requestId={selectedRequestId} + vendorId={vendorId} + setVendorId={setVendorId} + billItems={billItems} + updateBillItem={updateBillItem} + addBillItem={addBillItem} + removeBillItem={removeBillItem} + billTotal={billTotal} + /> + + ); +} + +export default BillGenerationView; diff --git a/src/Modules/InstituteWorks/BillProcessingView.jsx b/src/Modules/InstituteWorks/BillProcessingView.jsx new file mode 100644 index 000000000..96adff27c --- /dev/null +++ b/src/Modules/InstituteWorks/BillProcessingView.jsx @@ -0,0 +1,58 @@ +import { useEffect, useMemo, useState } from "react"; +import { notifications } from "@mantine/notifications"; +import BillProcessingTable from "./components/BillProcessingTable"; +import { + getApiErrorMessage, + getDesignations, + getGeneratedBills, + downloadBillPdf, +} from "./api"; + +function BillProcessingView() { + const [rows, setRows] = useState([]); + const [designationOptions, setDesignationOptions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const load = async () => { + setIsLoading(true); + try { + const [billRows, designationsData] = await Promise.all([ + getGeneratedBills(), + getDesignations(), + ]); + setRows(billRows); + + const options = (designationsData?.holdsDesignations || []).map( + (item) => ({ + value: `${item.designation?.name || ""}|${item.username || ""}`, + label: `${item.designation?.name || "Unknown"} (${item.username || "-"})`, + }), + ); + setDesignationOptions(options); + } catch (error) { + notifications.show({ + color: "red", + message: getApiErrorMessage(error, "Unable to fetch generated bills."), + }); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + return ( + <> + + + ); +} + +export default BillProcessingView; diff --git a/src/Modules/InstituteWorks/BillSettlementView.jsx b/src/Modules/InstituteWorks/BillSettlementView.jsx new file mode 100644 index 000000000..f255dc0dc --- /dev/null +++ b/src/Modules/InstituteWorks/BillSettlementView.jsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from "react"; +import { notifications } from "@mantine/notifications"; +import BillSettlementTable from "./components/BillSettlementTable"; +import { getApiErrorMessage, getSettleBills, settleBill } from "./api"; + +function BillSettlementView() { + const [rows, setRows] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [workingId, setWorkingId] = useState(null); + + const load = async () => { + setIsLoading(true); + try { + const data = await getSettleBills(); + setRows(data); + } catch (error) { + notifications.show({ + color: "red", + message: getApiErrorMessage(error, "Unable to fetch bills for settlement."), + }); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const handleSettle = async (id) => { + setWorkingId(id); + try { + await settleBill(id); + notifications.show({ + color: "green", + message: `Bill settled for request #${id}.`, + }); + await load(); + } catch (error) { + notifications.show({ + color: "red", + message: getApiErrorMessage(error, "Unable to settle this bill."), + }); + } finally { + setWorkingId(null); + } + }; + + return ( + + ); +} + +export default BillSettlementView; diff --git a/src/Modules/InstituteWorks/BudgetManagementView.jsx b/src/Modules/InstituteWorks/BudgetManagementView.jsx new file mode 100644 index 000000000..2b043b1d8 --- /dev/null +++ b/src/Modules/InstituteWorks/BudgetManagementView.jsx @@ -0,0 +1,125 @@ +import { useEffect, useMemo, useState } from "react"; +import { notifications } from "@mantine/notifications"; +import BudgetAddForm from "./components/BudgetAddForm"; +import BudgetListTable from "./components/BudgetListTable"; +import BudgetEditForm from "./components/BudgetEditForm"; +import SectionStack from "./components/SectionStack"; +import { addBudget, editBudget, getBudgets } from "./api"; + +function BudgetManagementView() { + const [rows, setRows] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSavingNew, setIsSavingNew] = useState(false); + const [editingId, setEditingId] = useState(null); + + const [newName, setNewName] = useState(""); + const [newBudget, setNewBudget] = useState(0); + + const [editName, setEditName] = useState(""); + const [editBudgetValue, setEditBudgetValue] = useState(0); + + const canCreate = useMemo( + () => Boolean(newName && Number(newBudget) > 0), + [newName, newBudget], + ); + const canEdit = useMemo( + () => Boolean(editingId && editName && Number(editBudgetValue) >= 0), + [editingId, editName, editBudgetValue], + ); + + const load = async () => { + setIsLoading(true); + try { + const data = await getBudgets(); + setRows(data); + } catch { + notifications.show({ color: "red", message: "Unable to fetch budgets." }); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const submitNewBudget = async (event) => { + event.preventDefault(); + if (!canCreate) return; + + setIsSavingNew(true); + try { + await addBudget({ name: newName.trim(), budget: Number(newBudget) }); + notifications.show({ + color: "green", + message: "Budget added successfully.", + }); + setNewName(""); + setNewBudget(0); + await load(); + } catch { + notifications.show({ color: "red", message: "Unable to add budget." }); + } finally { + setIsSavingNew(false); + } + }; + + const startEdit = (row) => { + setEditingId(row.id); + setEditName(row.name || ""); + setEditBudgetValue(Number(row.budgetIssued || 0)); + }; + + const submitEdit = async () => { + if (!canEdit) return; + try { + await editBudget({ + id: editingId, + name: editName.trim(), + budget: Number(editBudgetValue), + }); + notifications.show({ + color: "green", + message: "Budget updated successfully.", + }); + setEditingId(null); + setEditName(""); + setEditBudgetValue(0); + await load(); + } catch { + notifications.show({ color: "red", message: "Unable to update budget." }); + } + }; + + return ( + + + + setEditingId(null)} + canEdit={canEdit} + /> + + ); +} + +export default BudgetManagementView; diff --git a/src/Modules/InstituteWorks/CreateRequestView.jsx b/src/Modules/InstituteWorks/CreateRequestView.jsx new file mode 100644 index 000000000..409b79d74 --- /dev/null +++ b/src/Modules/InstituteWorks/CreateRequestView.jsx @@ -0,0 +1,104 @@ +import { useEffect, useMemo, useState } from "react"; +import { notifications } from "@mantine/notifications"; +import { useSelector } from "react-redux"; +import InfoNotice from "./components/InfoNotice"; +import RequestForm from "./components/RequestForm"; +import { createRequest, getApiErrorMessage, getDesignations } from "./api"; + +function CreateRequestView() { + const role = useSelector((state) => state.user.role); + const [designationOptions, setDesignationOptions] = useState([]); + const [isLoadingDesignations, setIsLoadingDesignations] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [canCreateRequest, setCanCreateRequest] = useState(false); + const [accessMessage, setAccessMessage] = useState(""); + + useEffect(() => { + const loadDesignations = async () => { + setIsLoadingDesignations(true); + try { + const response = await getDesignations(); + const designationData = response?.holdsDesignations || []; + const adminIwdReceivers = designationData.filter( + (item) => item?.designation?.name === "Admin IWD", + ); + const options = adminIwdReceivers.map((item) => ({ + value: `${item.designation?.name || ""}|${item.username || ""}`, + label: `${item.designation?.name || "Unknown"} (${item.username || "-"})`, + })); + setDesignationOptions(options); + setCanCreateRequest(Boolean(response?.canCreateRequest)); + + if (response?.canCreateRequest) { + setAccessMessage(""); + } else { + const currentUserDesignations = + response?.currentUserDesignations || []; + setAccessMessage( + currentUserDesignations.length > 0 + ? `Your current designations (${currentUserDesignations.join(", ")}) are not allowed to create IWD requests.` + : "You do not hold any designation that can create IWD requests.", + ); + } + } catch (error) { + notifications.show({ + color: "red", + message: getApiErrorMessage(error, "Unable to fetch designation options."), + }); + } finally { + setIsLoadingDesignations(false); + } + }; + + loadDesignations(); + }, []); + + const emptyNotice = useMemo( + () => + isLoadingDesignations + ? "Loading designations..." + : "No Admin IWD recipients available.", + [isLoadingDesignations], + ); + + const handleCreateRequest = async (payload) => { + setIsSubmitting(true); + try { + await createRequest({ ...payload, role }); + notifications.show({ + color: "green", + message: "IWD request created successfully.", + }); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to create request."); + notifications.show({ + color: "red", + message, + }); + } finally { + setIsSubmitting(false); + } + }; + + if (!isLoadingDesignations && !canCreateRequest) { + return ( + + ); + } + + if (designationOptions.length === 0) { + return ; + } + + return ( + + ); +} + +export default CreateRequestView; diff --git a/src/Modules/InstituteWorks/CreatedRequestsView.jsx b/src/Modules/InstituteWorks/CreatedRequestsView.jsx new file mode 100644 index 000000000..8b02232b2 --- /dev/null +++ b/src/Modules/InstituteWorks/CreatedRequestsView.jsx @@ -0,0 +1,80 @@ +import { useEffect, useMemo, useState } from "react"; +import { notifications } from "@mantine/notifications"; +import { useSelector } from "react-redux"; +import CreatedRequestsTable from "./components/CreatedRequestsTable"; +import TrackingHistoryModal from "./components/TrackingHistoryModal"; +import { + getCreatedRequests, + getViewFile, +} from "./api"; + +function CreatedRequestsView() { + const role = useSelector((state) => state.user.role); + const [requests, setRequests] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + // View tracking modal state + const [trackingOpened, setTrackingOpened] = useState(false); + const [fileData, setFileData] = useState(null); + const [tracks, setTracks] = useState([]); + const [isTrackingLoading, setIsTrackingLoading] = useState(false); + + const loadRequests = async () => { + setIsLoading(true); + try { + const data = await getCreatedRequests(role); + setRequests(data); + } catch { + notifications.show({ + color: "red", + message: "Unable to fetch created IWD requests.", + }); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (!role) return; + loadRequests(); + }, [role]); + + const openTrackingModal = async (fileId) => { + setFileData(null); + setTracks([]); + setTrackingOpened(true); + setIsTrackingLoading(true); + try { + const data = await getViewFile(fileId); + setFileData(data.file || null); + setTracks(data.tracks || []); + } catch { + notifications.show({ + color: "red", + message: "Unable to fetch file tracking data.", + }); + } finally { + setIsTrackingLoading(false); + } + }; + + return ( + <> + + setTrackingOpened(false)} + isLoading={isTrackingLoading} + fileData={fileData} + tracks={tracks} + /> + + ); +} + +export default CreatedRequestsView; diff --git a/src/Modules/InstituteWorks/DeanDirectorQueueView.jsx b/src/Modules/InstituteWorks/DeanDirectorQueueView.jsx new file mode 100644 index 000000000..eb60ba2a9 --- /dev/null +++ b/src/Modules/InstituteWorks/DeanDirectorQueueView.jsx @@ -0,0 +1,108 @@ +import { useEffect, useMemo, useState } from "react"; +import { notifications } from "@mantine/notifications"; +import { useSelector } from "react-redux"; +import DeanDirectorQueueTable from "./components/DeanDirectorQueueTable"; +import DeanDirectorActionModal from "./components/DeanDirectorActionModal"; +import { + getApiErrorMessage, + getDeanProcessedRequests, + submitDirectorApproval, +} from "./api"; + +function DeanDirectorQueueView() { + const role = useSelector((state) => state.user.role); + const [rows, setRows] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [opened, setOpened] = useState(false); + const [selectedFileId, setSelectedFileId] = useState(null); + const [action, setAction] = useState("approve"); + const [remarks, setRemarks] = useState(""); + const [file, setFile] = useState(null); + + const load = async () => { + setIsLoading(true); + try { + const deanRows = await getDeanProcessedRequests(role); + setRows(deanRows); + } catch (error) { + notifications.show({ + color: "red", + message: getApiErrorMessage(error, "Unable to fetch dean/director queue."), + }); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + load(); + }, [role]); + + const ready = useMemo( + () => Boolean(selectedFileId && action), + [selectedFileId, action], + ); + + const openActionModal = (fileId) => { + setSelectedFileId(fileId); + setAction("approve"); + setRemarks(""); + setFile(null); + setOpened(true); + }; + + const submit = async (event) => { + event.preventDefault(); + if (!ready) return; + + setIsSaving(true); + try { + await submitDirectorApproval({ + fileid: selectedFileId, + action, + remarks, + file, + }); + notifications.show({ + color: "green", + message: "Director action submitted.", + }); + setOpened(false); + await load(); + } catch (error) { + notifications.show({ + color: "red", + message: getApiErrorMessage(error, "Unable to submit director action."), + }); + } finally { + setIsSaving(false); + } + }; + + return ( + <> + + setOpened(false)} + onSubmit={submit} + action={action} + setAction={setAction} + remarks={remarks} + setRemarks={setRemarks} + file={file} + setFile={setFile} + isSaving={isSaving} + isReady={ready} + /> + + ); +} + +export default DeanDirectorQueueView; diff --git a/src/Modules/InstituteWorks/DeanProcessingQueueView.jsx b/src/Modules/InstituteWorks/DeanProcessingQueueView.jsx new file mode 100644 index 000000000..60b719401 --- /dev/null +++ b/src/Modules/InstituteWorks/DeanProcessingQueueView.jsx @@ -0,0 +1,157 @@ +import { useEffect, useMemo, useState } from "react"; +import { notifications } from "@mantine/notifications"; +import { useSelector } from "react-redux"; +import DeanProcessingQueueTable from "./components/DeanProcessingQueueTable"; +import DeanProcessingActionModal from "./components/DeanProcessingActionModal"; +import { + getApiErrorMessage, + getDeanPendingRequests, + getDesignations, + handleDeanProcessRequest, +} from "./api"; + +function isDirectorOption(value) { + const designation = String(value || "").split("|", 1)[0].trim().toLowerCase(); + return designation === "director"; +} + +function isAdminIwdOption(value) { + const designation = String(value || "").split("|", 1)[0].trim().toLowerCase(); + return designation === "admin iwd"; +} + +function readField(item, snakeKey, camelKey) { + return item?.[snakeKey] ?? item?.[camelKey] ?? null; +} + +function isDeanPendingItem(item) { + const admin = Number(item?.iwdAdminApproval ?? item?.processed_by_admin ?? 0); + const dean = Number(item?.deanProcessed ?? item?.processed_by_dean ?? 0); + const director = Number(item?.directorApproval ?? item?.processed_by_director ?? 0); + const budget = readField(item, "estimated_budget", "estimatedBudget"); + return admin === 1 && dean === 0 && director === 0 && budget != null; +} + +function DeanProcessingQueueView() { + const role = useSelector((state) => state.user.role); + const [rows, setRows] = useState([]); + const [designationOptions, setDesignationOptions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [opened, setOpened] = useState(false); + const [selectedFileId, setSelectedFileId] = useState(null); + const [action, setAction] = useState("approve"); + const [designation, setDesignation] = useState(""); + const [remarks, setRemarks] = useState(""); + const [file, setFile] = useState(null); + const [allDesignationOptions, setAllDesignationOptions] = useState([]); + + const load = async () => { + setIsLoading(true); + try { + const [inboxRows, designationsData] = await Promise.all([ + getDeanPendingRequests(role), + getDesignations(), + ]); + setRows(inboxRows.filter(isDeanPendingItem)); + const options = (designationsData?.holdsDesignations || []).map( + (item) => ({ + value: `${item.designation?.name || ""}|${item.username || ""}`, + label: `${item.designation?.name || "Unknown"} (${item.username || "-"})`, + }), + ); + setAllDesignationOptions(options); + } catch (error) { + notifications.show({ + color: "red", + message: getApiErrorMessage(error, "Unable to fetch dean processing queue."), + }); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + load(); + }, [role]); + + useEffect(() => { + const filtered = + action === "reject" + ? allDesignationOptions.filter((item) => isAdminIwdOption(item.value)) + : allDesignationOptions.filter((item) => isDirectorOption(item.value)); + setDesignationOptions(filtered); + setDesignation(""); + }, [action, allDesignationOptions]); + + const ready = useMemo( + () => Boolean(selectedFileId && designation), + [selectedFileId, designation], + ); + + const openActionModal = (fileId) => { + setSelectedFileId(fileId); + setAction("approve"); + setDesignation(""); + setRemarks(""); + setFile(null); + setOpened(true); + }; + + const submit = async (event) => { + event.preventDefault(); + if (!ready) return; + setIsSaving(true); + try { + await handleDeanProcessRequest({ + fileid: selectedFileId, + action, + designation, + remarks, + file, + }); + notifications.show({ + color: "green", + message: "Request processed and forwarded by dean.", + }); + setOpened(false); + await load(); + } catch (error) { + notifications.show({ + color: "red", + message: getApiErrorMessage(error, "Unable to process request."), + }); + } finally { + setIsSaving(false); + } + }; + + return ( + <> + + setOpened(false)} + onSubmit={submit} + action={action} + setAction={setAction} + designationOptions={designationOptions} + designation={designation} + setDesignation={setDesignation} + remarks={remarks} + setRemarks={setRemarks} + file={file} + setFile={setFile} + isSaving={isSaving} + isReady={ready} + /> + + ); +} + +export default DeanProcessingQueueView; diff --git a/src/Modules/InstituteWorks/DirectorApprovedView.jsx b/src/Modules/InstituteWorks/DirectorApprovedView.jsx new file mode 100644 index 000000000..5ae2f3ab0 --- /dev/null +++ b/src/Modules/InstituteWorks/DirectorApprovedView.jsx @@ -0,0 +1,134 @@ +import { useEffect, useMemo, useState } from "react"; +import { notifications } from "@mantine/notifications"; +import DirectorApprovedTable from "./components/DirectorApprovedTable"; +import IssueWorkOrderModal from "./components/IssueWorkOrderModal"; +import { + getApiErrorMessage, + getDirectorApprovedRequests, + issueWorkOrder, +} from "./api"; + +function toIsoDate(value) { + if (!value) return ""; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return ""; + const year = d.getFullYear(); + const month = `${d.getMonth() + 1}`.padStart(2, "0"); + const day = `${d.getDate()}`.padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function isCompletionDateValid(startDate, completionDate) { + if (!startDate || !completionDate) return true; + const start = new Date(startDate); + const end = new Date(completionDate); + start.setHours(0, 0, 0, 0); + end.setHours(0, 0, 0, 0); + return end >= start; +} + +const initialForm = { + request_id: "", + name: "", + alloted_time: "", + start_date: null, + completion_date: null, +}; + +function DirectorApprovedView() { + const [requests, setRequests] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [opened, setOpened] = useState(false); + const [form, setForm] = useState(initialForm); + + const load = async () => { + setIsLoading(true); + try { + const data = await getDirectorApprovedRequests(); + setRequests(data); + } catch (error) { + notifications.show({ + color: "red", + message: getApiErrorMessage(error, "Unable to fetch director-approved requests."), + }); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const readyToSubmit = useMemo( + () => + Boolean( + form.request_id && form.name && form.alloted_time && form.start_date, + ) && isCompletionDateValid(form.start_date, form.completion_date), + [form], + ); + + const openForRequest = (row) => { + setForm({ + request_id: row.id, + name: row.name || "", + alloted_time: "", + start_date: null, + completion_date: null, + }); + setOpened(true); + }; + + const submit = async (event) => { + event.preventDefault(); + if (!readyToSubmit) return; + + setIsSaving(true); + try { + await issueWorkOrder({ + request_id: form.request_id, + name: (form.name || "").trim(), + alloted_time: (form.alloted_time || "").trim(), + start_date: toIsoDate(form.start_date), + completion_date: toIsoDate(form.completion_date), + }); + notifications.show({ + color: "green", + message: "Work order issued successfully.", + }); + setOpened(false); + setForm(initialForm); + await load(); + } catch (error) { + notifications.show({ + color: "red", + message: getApiErrorMessage(error, "Unable to issue work order."), + }); + } finally { + setIsSaving(false); + } + }; + + return ( + <> + + setOpened(false)} + onSubmit={submit} + form={form} + setForm={setForm} + isSaving={isSaving} + isReady={readyToSubmit} + /> + + ); +} + +export default DirectorApprovedView; diff --git a/src/Modules/InstituteWorks/FeedbackView.jsx b/src/Modules/InstituteWorks/FeedbackView.jsx new file mode 100644 index 000000000..18614951a --- /dev/null +++ b/src/Modules/InstituteWorks/FeedbackView.jsx @@ -0,0 +1,366 @@ +import { useEffect, useState } from "react"; +import { notifications } from "@mantine/notifications"; +import { + Button, + Container, + Grid, + Group, + Loader, + Paper, + Rating, + Select, + Stack, + Text, + Textarea, + Title, + Center, + Badge, + ScrollArea, + Table, +} from "@mantine/core"; +import { getRequestsStatus, submitFeedback, reopenRequest, getFeedbackHistory, getApiErrorMessage } from "./api"; + +function FeedbackView() { + const [completedRequests, setCompletedRequests] = useState([]); + const [feedbackHistory, setFeedbackHistory] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [selectedRequestId, setSelectedRequestId] = useState(""); + const [rating, setRating] = useState(0); + const [comments, setComments] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Reopen modal state + const [selectedReopenRequestId, setSelectedReopenRequestId] = useState(""); + const [reopenReason, setReopenReason] = useState(""); + const [isReopening, setIsReopening] = useState(false); + + const load = async () => { + setIsLoading(true); + try { + const [data, feedbackResult] = await Promise.all([ + getRequestsStatus(""), + getFeedbackHistory(1, 10), + ]); + // Filter for completed/settled requests + const completed = (data || []).filter( + (req) => + req.work_completed === 1 || + req.bill_settled === 1 || + req.status === "Final Bill Settled" || + req.status === "Resolved" + ); + setCompletedRequests(completed); + setFeedbackHistory(feedbackResult.items || []); + } catch (error) { + notifications.show({ + color: "red", + message: getApiErrorMessage(error, "Unable to load completed requests."), + }); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const handleSubmitFeedback = async () => { + if (!selectedRequestId || rating === 0) { + notifications.show({ + color: "yellow", + message: "Please select a request and provide a rating.", + }); + return; + } + + setIsSubmitting(true); + try { + await submitFeedback(parseInt(selectedRequestId), rating, comments); + notifications.show({ + color: "green", + message: "Feedback submitted successfully!", + }); + setSelectedRequestId(""); + setRating(0); + setComments(""); + load(); + } catch (error) { + notifications.show({ + color: "red", + message: getApiErrorMessage(error, "Failed to submit feedback."), + }); + } finally { + setIsSubmitting(false); + } + }; + + const handleReopenRequest = async () => { + if (!selectedReopenRequestId) { + notifications.show({ + color: "yellow", + message: "Please select a request to reopen.", + }); + return; + } + + setIsReopening(true); + try { + await reopenRequest(parseInt(selectedReopenRequestId), reopenReason); + notifications.show({ + color: "green", + message: "Request reopened successfully. It will be re-worked.", + }); + setSelectedReopenRequestId(""); + setReopenReason(""); + load(); + } catch (error) { + notifications.show({ + color: "red", + message: getApiErrorMessage(error, "Failed to reopen request."), + }); + } finally { + setIsReopening(false); + } + }; + + if (isLoading && completedRequests.length === 0) { + return ( +
+ +
+ ); + } + + const requestOptions = completedRequests.map((req) => ({ + value: req.id?.toString() || "", + label: `Request #${req.id} - ${req.name} (${req.area})`, + })); + + return ( + + + Feedback & Case Management + + + + {/* Feedback Submission */} + + + + Submit Feedback + + +
+ + Select Completed Request + +