| [09e02d7] | 1 | import { useState } from "react";
|
|---|
| 2 | import toast from "react-hot-toast";
|
|---|
| 3 | import {
|
|---|
| 4 | ArrowRight,
|
|---|
| 5 | BarChart3,
|
|---|
| 6 | Boxes,
|
|---|
| 7 | CheckCircle2,
|
|---|
| 8 | DoorOpen,
|
|---|
| 9 | Loader2,
|
|---|
| 10 | Sparkles,
|
|---|
| 11 | Table2,
|
|---|
| 12 | TrendingUp,
|
|---|
| 13 | } from "lucide-react";
|
|---|
| 14 | import { getEquipmentDemandReport, getRoomUtilizationReport } from "../api.js";
|
|---|
| 15 |
|
|---|
| 16 | export default function ReportsPage() {
|
|---|
| 17 | const [roomReport, setRoomReport] = useState([]);
|
|---|
| 18 | const [equipmentReport, setEquipmentReport] = useState([]);
|
|---|
| 19 | const [activeReport, setActiveReport] = useState(null);
|
|---|
| 20 | const [loadingRoomReport, setLoadingRoomReport] = useState(false);
|
|---|
| 21 | const [loadingEquipmentReport, setLoadingEquipmentReport] = useState(false);
|
|---|
| 22 |
|
|---|
| 23 | async function loadRoomReport() {
|
|---|
| 24 | setLoadingRoomReport(true);
|
|---|
| 25 |
|
|---|
| 26 | try {
|
|---|
| 27 | const data = await getRoomUtilizationReport();
|
|---|
| 28 | setRoomReport(Array.isArray(data) ? data : []);
|
|---|
| 29 | setActiveReport("rooms");
|
|---|
| 30 | toast.success("Room utilization report loaded");
|
|---|
| 31 | } catch (err) {
|
|---|
| 32 | toast.error(err.message || "Room utilization report could not be loaded.");
|
|---|
| 33 | } finally {
|
|---|
| 34 | setLoadingRoomReport(false);
|
|---|
| 35 | }
|
|---|
| 36 | }
|
|---|
| 37 |
|
|---|
| 38 | async function loadEquipmentReport() {
|
|---|
| 39 | setLoadingEquipmentReport(true);
|
|---|
| 40 |
|
|---|
| 41 | try {
|
|---|
| 42 | const data = await getEquipmentDemandReport();
|
|---|
| 43 | setEquipmentReport(Array.isArray(data) ? data : []);
|
|---|
| 44 | setActiveReport("equipment");
|
|---|
| 45 | toast.success("Equipment demand report loaded");
|
|---|
| 46 | } catch (err) {
|
|---|
| 47 | toast.error(err.message || "Equipment demand report could not be loaded.");
|
|---|
| 48 | } finally {
|
|---|
| 49 | setLoadingEquipmentReport(false);
|
|---|
| 50 | }
|
|---|
| 51 | }
|
|---|
| 52 |
|
|---|
| 53 | return (
|
|---|
| 54 | <section className="reports-page-pro">
|
|---|
| 55 | <div className="reports-hero-pro">
|
|---|
| 56 | <div>
|
|---|
| 57 | <div className="dashboard-eyebrow">
|
|---|
| 58 | <BarChart3 size={16} />
|
|---|
| 59 | Analytics center
|
|---|
| 60 | </div>
|
|---|
| 61 |
|
|---|
| 62 | <h1>Reports & Insights</h1>
|
|---|
| 63 |
|
|---|
| 64 | <p>
|
|---|
| 65 | Review room utilization, equipment demand and operational trends
|
|---|
| 66 | using live data from the project database.
|
|---|
| 67 | </p>
|
|---|
| 68 | </div>
|
|---|
| 69 |
|
|---|
| 70 | <div className="reports-hero-metrics">
|
|---|
| 71 | <div>
|
|---|
| 72 | <strong>2</strong>
|
|---|
| 73 | <span>Advanced reports</span>
|
|---|
| 74 | </div>
|
|---|
| 75 |
|
|---|
| 76 | <div>
|
|---|
| 77 | <strong>Live</strong>
|
|---|
| 78 | <span>PostgreSQL data</span>
|
|---|
| 79 | </div>
|
|---|
| 80 |
|
|---|
| 81 | <div>
|
|---|
| 82 | <strong>SQL</strong>
|
|---|
| 83 | <span>Grouped analytics</span>
|
|---|
| 84 | </div>
|
|---|
| 85 | </div>
|
|---|
| 86 | </div>
|
|---|
| 87 |
|
|---|
| 88 | <div className="reports-command-grid">
|
|---|
| 89 | <ReportCard
|
|---|
| 90 | icon={DoorOpen}
|
|---|
| 91 | tag="Quarterly analysis"
|
|---|
| 92 | title="Room utilization"
|
|---|
| 93 | description="Analyze how rooms are used across quarters, including reservation volume, approval status, approved hours and utilization ranking."
|
|---|
| 94 | points={[
|
|---|
| 95 | "Grouped by quarter, building and room",
|
|---|
| 96 | "Approved, rejected, cancelled and pending counts",
|
|---|
| 97 | "Room ranking by utilization level",
|
|---|
| 98 | ]}
|
|---|
| 99 | loading={loadingRoomReport}
|
|---|
| 100 | onClick={loadRoomReport}
|
|---|
| 101 | />
|
|---|
| 102 |
|
|---|
| 103 | <ReportCard
|
|---|
| 104 | icon={Boxes}
|
|---|
| 105 | tag="Stock risk analysis"
|
|---|
| 106 | title="Equipment demand"
|
|---|
| 107 | description="Track requested equipment quantities and compare demand with registered stock and assigned room equipment."
|
|---|
| 108 | points={[
|
|---|
| 109 | "Demand by equipment type and quarter",
|
|---|
| 110 | "Registered stock versus requested quantity",
|
|---|
| 111 | "Demand ranking and stock risk level",
|
|---|
| 112 | ]}
|
|---|
| 113 | loading={loadingEquipmentReport}
|
|---|
| 114 | onClick={loadEquipmentReport}
|
|---|
| 115 | />
|
|---|
| 116 | </div>
|
|---|
| 117 |
|
|---|
| 118 | {!activeReport && (
|
|---|
| 119 | <div className="reports-empty-panel">
|
|---|
| 120 | <div className="reports-empty-icon">
|
|---|
| 121 | <Table2 size={28} />
|
|---|
| 122 | </div>
|
|---|
| 123 |
|
|---|
| 124 | <div>
|
|---|
| 125 | <h2>Select a report to view results</h2>
|
|---|
| 126 | <p>
|
|---|
| 127 | Choose one of the analytical reports above. Results will be loaded
|
|---|
| 128 | from the live database and displayed in a structured table.
|
|---|
| 129 | </p>
|
|---|
| 130 | </div>
|
|---|
| 131 | </div>
|
|---|
| 132 | )}
|
|---|
| 133 |
|
|---|
| 134 | {activeReport === "rooms" && roomReport.length > 0 && (
|
|---|
| 135 | <PremiumTable
|
|---|
| 136 | title="Room utilization report"
|
|---|
| 137 | subtitle="Quarterly utilization grouped by building, room, reservation status and approved hours."
|
|---|
| 138 | columns={[
|
|---|
| 139 | "Quarter",
|
|---|
| 140 | "Building",
|
|---|
| 141 | "Room",
|
|---|
| 142 | "Type",
|
|---|
| 143 | "Total",
|
|---|
| 144 | "Approved",
|
|---|
| 145 | "Hours",
|
|---|
| 146 | "Rank",
|
|---|
| 147 | "Level",
|
|---|
| 148 | ]}
|
|---|
| 149 | rows={roomReport.map((row) => [
|
|---|
| 150 | row.quarterStart,
|
|---|
| 151 | row.buildingName,
|
|---|
| 152 | row.roomCode,
|
|---|
| 153 | row.type,
|
|---|
| 154 | row.totalRoomReservations,
|
|---|
| 155 | row.approvedReservations,
|
|---|
| 156 | row.approvedHours,
|
|---|
| 157 | row.utilizationRank,
|
|---|
| 158 | row.utilizationLevel,
|
|---|
| 159 | ])}
|
|---|
| 160 | />
|
|---|
| 161 | )}
|
|---|
| 162 |
|
|---|
| 163 | {activeReport === "equipment" && equipmentReport.length > 0 && (
|
|---|
| 164 | <PremiumTable
|
|---|
| 165 | title="Equipment demand report"
|
|---|
| 166 | subtitle="Demand, registered quantity, assigned equipment, stock risk and ranking by equipment type."
|
|---|
| 167 | columns={[
|
|---|
| 168 | "Quarter",
|
|---|
| 169 | "Equipment",
|
|---|
| 170 | "Stock",
|
|---|
| 171 | "Assigned",
|
|---|
| 172 | "Registered",
|
|---|
| 173 | "Requested",
|
|---|
| 174 | "Demand %",
|
|---|
| 175 | "Rank",
|
|---|
| 176 | "Level",
|
|---|
| 177 | ]}
|
|---|
| 178 | rows={equipmentReport.map((row) => [
|
|---|
| 179 | row.quarterStart,
|
|---|
| 180 | row.equipmentName,
|
|---|
| 181 | row.stockQuantity,
|
|---|
| 182 | row.assignedRoomQuantity,
|
|---|
| 183 | row.totalRegisteredQuantity,
|
|---|
| 184 | row.totalRequestedQuantity,
|
|---|
| 185 | row.demandToRegisteredPercent,
|
|---|
| 186 | row.demandRank,
|
|---|
| 187 | row.demandLevel,
|
|---|
| 188 | ])}
|
|---|
| 189 | />
|
|---|
| 190 | )}
|
|---|
| 191 |
|
|---|
| 192 | {activeReport === "rooms" && !loadingRoomReport && roomReport.length === 0 && (
|
|---|
| 193 | <EmptyResult title="No room utilization rows were returned." />
|
|---|
| 194 | )}
|
|---|
| 195 |
|
|---|
| 196 | {activeReport === "equipment" &&
|
|---|
| 197 | !loadingEquipmentReport &&
|
|---|
| 198 | equipmentReport.length === 0 && (
|
|---|
| 199 | <EmptyResult title="No equipment demand rows were returned." />
|
|---|
| 200 | )}
|
|---|
| 201 | </section>
|
|---|
| 202 | );
|
|---|
| 203 | }
|
|---|
| 204 |
|
|---|
| 205 | function ReportCard({
|
|---|
| 206 | icon: Icon,
|
|---|
| 207 | tag,
|
|---|
| 208 | title,
|
|---|
| 209 | description,
|
|---|
| 210 | points,
|
|---|
| 211 | loading,
|
|---|
| 212 | onClick,
|
|---|
| 213 | }) {
|
|---|
| 214 | return (
|
|---|
| 215 | <button
|
|---|
| 216 | className="report-command-card"
|
|---|
| 217 | type="button"
|
|---|
| 218 | onClick={onClick}
|
|---|
| 219 | disabled={loading}
|
|---|
| 220 | >
|
|---|
| 221 | <div className="report-command-top">
|
|---|
| 222 | <div className="report-command-icon">
|
|---|
| 223 | <Icon size={25} />
|
|---|
| 224 | </div>
|
|---|
| 225 |
|
|---|
| 226 | <span>{tag}</span>
|
|---|
| 227 | </div>
|
|---|
| 228 |
|
|---|
| 229 | <h2>{title}</h2>
|
|---|
| 230 |
|
|---|
| 231 | <p>{description}</p>
|
|---|
| 232 |
|
|---|
| 233 | <div className="report-point-list">
|
|---|
| 234 | {points.map((point) => (
|
|---|
| 235 | <div key={point}>
|
|---|
| 236 | <CheckCircle2 size={17} />
|
|---|
| 237 | {point}
|
|---|
| 238 | </div>
|
|---|
| 239 | ))}
|
|---|
| 240 | </div>
|
|---|
| 241 |
|
|---|
| 242 | <div className="report-command-footer">
|
|---|
| 243 | <span>
|
|---|
| 244 | {loading ? (
|
|---|
| 245 | <>
|
|---|
| 246 | Loading report
|
|---|
| 247 | <Loader2 size={17} className="spin-icon" />
|
|---|
| 248 | </>
|
|---|
| 249 | ) : (
|
|---|
| 250 | <>
|
|---|
| 251 | Open report
|
|---|
| 252 | <ArrowRight size={17} />
|
|---|
| 253 | </>
|
|---|
| 254 | )}
|
|---|
| 255 | </span>
|
|---|
| 256 |
|
|---|
| 257 | <TrendingUp size={20} />
|
|---|
| 258 | </div>
|
|---|
| 259 | </button>
|
|---|
| 260 | );
|
|---|
| 261 | }
|
|---|
| 262 |
|
|---|
| 263 | function PremiumTable({ title, subtitle, columns, rows }) {
|
|---|
| 264 | return (
|
|---|
| 265 | <div className="panel table-panel report-output-panel">
|
|---|
| 266 | <div className="panel-header report-table-header">
|
|---|
| 267 | <div>
|
|---|
| 268 | <div className="dashboard-eyebrow">
|
|---|
| 269 | <Sparkles size={15} />
|
|---|
| 270 | Report output
|
|---|
| 271 | </div>
|
|---|
| 272 |
|
|---|
| 273 | <h3>{title}</h3>
|
|---|
| 274 | <p>{subtitle}</p>
|
|---|
| 275 | </div>
|
|---|
| 276 |
|
|---|
| 277 | <div className="report-row-count">
|
|---|
| 278 | <strong>{rows.length}</strong>
|
|---|
| 279 | <span>rows loaded</span>
|
|---|
| 280 | </div>
|
|---|
| 281 | </div>
|
|---|
| 282 |
|
|---|
| 283 | <div className="table-wrapper">
|
|---|
| 284 | <table>
|
|---|
| 285 | <thead>
|
|---|
| 286 | <tr>
|
|---|
| 287 | {columns.map((column) => (
|
|---|
| 288 | <th key={column}>{column}</th>
|
|---|
| 289 | ))}
|
|---|
| 290 | </tr>
|
|---|
| 291 | </thead>
|
|---|
| 292 |
|
|---|
| 293 | <tbody>
|
|---|
| 294 | {rows.map((row, index) => (
|
|---|
| 295 | <tr key={index}>
|
|---|
| 296 | {row.map((cell, cellIndex) => (
|
|---|
| 297 | <td key={cellIndex}>{String(cell ?? "")}</td>
|
|---|
| 298 | ))}
|
|---|
| 299 | </tr>
|
|---|
| 300 | ))}
|
|---|
| 301 | </tbody>
|
|---|
| 302 | </table>
|
|---|
| 303 | </div>
|
|---|
| 304 | </div>
|
|---|
| 305 | );
|
|---|
| 306 | }
|
|---|
| 307 |
|
|---|
| 308 | function EmptyResult({ title }) {
|
|---|
| 309 | return (
|
|---|
| 310 | <div className="reports-empty-panel">
|
|---|
| 311 | <div className="reports-empty-icon">
|
|---|
| 312 | <Table2 size={28} />
|
|---|
| 313 | </div>
|
|---|
| 314 |
|
|---|
| 315 | <div>
|
|---|
| 316 | <h2>{title}</h2>
|
|---|
| 317 | <p>
|
|---|
| 318 | The query executed successfully, but there is no matching data for the
|
|---|
| 319 | current dataset.
|
|---|
| 320 | </p>
|
|---|
| 321 | </div>
|
|---|
| 322 | </div>
|
|---|
| 323 | );
|
|---|
| 324 | } |
|---|