TerraTechSummaryDialog.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. "use client";
  2. import { useEffect, useState } from "react";
  3. import {
  4. ColumnDef,
  5. flexRender,
  6. getCoreRowModel,
  7. getPaginationRowModel,
  8. getSortedRowModel,
  9. useReactTable,
  10. SortingState,
  11. } from "@tanstack/react-table";
  12. import * as XLSX from "xlsx";
  13. import {
  14. Dialog,
  15. DialogContent,
  16. DialogHeader,
  17. DialogTitle,
  18. DialogPrimitive,
  19. } from "@/components/ui/dialog";
  20. import { X } from "lucide-react";
  21. import {
  22. Table,
  23. TableBody,
  24. TableCell,
  25. TableHead,
  26. TableHeader,
  27. TableRow,
  28. } from "@/components/ui/table";
  29. import { Badge } from "@/components/ui/badge";
  30. import { Button } from "@/components/ui/button";
  31. import {
  32. getTerraTechFacilitySummary,
  33. TerraTechSummaryRow,
  34. } from "@/app/actions/imports";
  35. import {
  36. Fuel,
  37. AlertTriangle,
  38. Download,
  39. ChevronLeft,
  40. ChevronRight,
  41. } from "lucide-react";
  42. interface TerraTechSummaryDialogProps {
  43. open: boolean;
  44. onOpenChange: (open: boolean) => void;
  45. importId: number;
  46. }
  47. interface SummaryData {
  48. importId: number;
  49. importName: string;
  50. layoutName: string;
  51. rows: TerraTechSummaryRow[];
  52. }
  53. function formatNumber(
  54. value: number | string | null | undefined,
  55. decimals: number = 2,
  56. ): string {
  57. if (value === null || value === undefined) return "-";
  58. const num = typeof value === "string" ? parseFloat(value) : value;
  59. if (isNaN(num)) return "-";
  60. return num.toLocaleString("en-US", {
  61. minimumFractionDigits: decimals,
  62. maximumFractionDigits: decimals,
  63. });
  64. }
  65. function formatPercent(value: number | string | null | undefined): string {
  66. if (value === null || value === undefined) return "-";
  67. const num = typeof value === "string" ? parseFloat(value) : value;
  68. if (isNaN(num)) return "-";
  69. return `${num.toFixed(1)}%`;
  70. }
  71. const columns: ColumnDef<TerraTechSummaryRow>[] = [
  72. {
  73. accessorKey: "wellName",
  74. header: "Well Name",
  75. cell: ({ row }) => (
  76. <div className="font-medium">{row.getValue("wellName") || "-"}</div>
  77. ),
  78. },
  79. {
  80. accessorKey: "corpId",
  81. header: "CorpID",
  82. cell: ({ row }) => row.getValue("corpId") || "-",
  83. },
  84. {
  85. accessorKey: "facilityId",
  86. header: "Facility ID",
  87. cell: ({ row }) => {
  88. const value = row.getValue("facilityId") as string | null;
  89. return value || <span className="text-amber-500">-</span>;
  90. },
  91. },
  92. {
  93. accessorKey: "gas",
  94. header: "Gas",
  95. cell: ({ row }) => (
  96. <div className="text-right">{formatNumber(row.getValue("gas"))}</div>
  97. ),
  98. },
  99. {
  100. accessorKey: "oil",
  101. header: "Oil",
  102. cell: ({ row }) => (
  103. <div className="text-right">{formatNumber(row.getValue("oil"))}</div>
  104. ),
  105. },
  106. {
  107. accessorKey: "water",
  108. header: "Water",
  109. cell: ({ row }) => (
  110. <div className="text-right">{formatNumber(row.getValue("water"))}</div>
  111. ),
  112. },
  113. {
  114. accessorKey: "state",
  115. header: "State",
  116. cell: ({ row }) => row.getValue("state") || "-",
  117. },
  118. {
  119. accessorKey: "county",
  120. header: "County",
  121. cell: ({ row }) => {
  122. const value = row.getValue("county") as string | null;
  123. return value || <span className="text-amber-500">-</span>;
  124. },
  125. },
  126. {
  127. accessorKey: "daysQ1",
  128. header: "Days Q1",
  129. cell: ({ row }) => (
  130. <div className="text-right">
  131. {formatNumber(row.getValue("daysQ1"), 0)}
  132. </div>
  133. ),
  134. },
  135. {
  136. accessorKey: "daysQ2",
  137. header: "Days Q2",
  138. cell: ({ row }) => (
  139. <div className="text-right">
  140. {formatNumber(row.getValue("daysQ2"), 0)}
  141. </div>
  142. ),
  143. },
  144. {
  145. accessorKey: "daysQ3",
  146. header: "Days Q3",
  147. cell: ({ row }) => (
  148. <div className="text-right">
  149. {formatNumber(row.getValue("daysQ3"), 0)}
  150. </div>
  151. ),
  152. },
  153. {
  154. accessorKey: "daysQ4",
  155. header: "Days Q4",
  156. cell: ({ row }) => (
  157. <div className="text-right">
  158. {formatNumber(row.getValue("daysQ4"), 0)}
  159. </div>
  160. ),
  161. },
  162. {
  163. accessorKey: "daysQ1Pct",
  164. header: "Q1 %",
  165. cell: ({ row }) => (
  166. <div className="text-right">
  167. {formatPercent(row.getValue("daysQ1Pct"))}
  168. </div>
  169. ),
  170. },
  171. {
  172. accessorKey: "daysQ2Pct",
  173. header: "Q2 %",
  174. cell: ({ row }) => (
  175. <div className="text-right">
  176. {formatPercent(row.getValue("daysQ2Pct"))}
  177. </div>
  178. ),
  179. },
  180. {
  181. accessorKey: "daysQ3Pct",
  182. header: "Q3 %",
  183. cell: ({ row }) => (
  184. <div className="text-right">
  185. {formatPercent(row.getValue("daysQ3Pct"))}
  186. </div>
  187. ),
  188. },
  189. {
  190. accessorKey: "daysQ4Pct",
  191. header: "Q4 %",
  192. cell: ({ row }) => (
  193. <div className="text-right">
  194. {formatPercent(row.getValue("daysQ4Pct"))}
  195. </div>
  196. ),
  197. },
  198. {
  199. accessorKey: "gasCorr",
  200. header: "Gas-corr",
  201. cell: ({ row }) => (
  202. <div className="text-right">{formatNumber(row.getValue("gasCorr"))}</div>
  203. ),
  204. },
  205. {
  206. accessorKey: "oilCorr",
  207. header: "Oil-corr",
  208. cell: ({ row }) => (
  209. <div className="text-right">{formatNumber(row.getValue("oilCorr"))}</div>
  210. ),
  211. },
  212. {
  213. accessorKey: "waterCorr",
  214. header: "Water-corr",
  215. cell: ({ row }) => (
  216. <div className="text-right">
  217. {formatNumber(row.getValue("waterCorr"))}
  218. </div>
  219. ),
  220. },
  221. {
  222. accessorKey: "isMissingFacilityId",
  223. header: "Missing Facility ID",
  224. cell: ({ row }) => {
  225. const value = row.getValue("isMissingFacilityId");
  226. return value ? (
  227. <Badge variant="destructive" className="text-xs">
  228. Yes
  229. </Badge>
  230. ) : null;
  231. },
  232. },
  233. {
  234. accessorKey: "isMissingCounty",
  235. header: "Missing County",
  236. cell: ({ row }) => {
  237. const value = row.getValue("isMissingCounty");
  238. return value ? (
  239. <Badge variant="destructive" className="text-xs">
  240. Yes
  241. </Badge>
  242. ) : null;
  243. },
  244. },
  245. ];
  246. function SummaryTable({
  247. data,
  248. importName,
  249. }: {
  250. data: TerraTechSummaryRow[];
  251. importName: string;
  252. }) {
  253. const [sorting, setSorting] = useState<SortingState>([
  254. { id: "wellName", desc: false },
  255. ]);
  256. const table = useReactTable({
  257. data,
  258. columns,
  259. state: {
  260. sorting,
  261. },
  262. onSortingChange: setSorting,
  263. getCoreRowModel: getCoreRowModel(),
  264. getPaginationRowModel: getPaginationRowModel(),
  265. getSortedRowModel: getSortedRowModel(),
  266. initialState: {
  267. pagination: {
  268. pageSize: 30,
  269. },
  270. },
  271. });
  272. function exportToExcel() {
  273. // Prepare data for Excel export
  274. const exportData = data.map((row) => ({
  275. "Well Name": row.wellName || "",
  276. CorpID: row.corpId || "",
  277. "Facility ID": row.facilityId || "",
  278. Gas: row.gas,
  279. Oil: row.oil,
  280. Water: row.water,
  281. State: row.state || "",
  282. County: row.county || "",
  283. "Days Q1": row.daysQ1,
  284. "Days Q2": row.daysQ2,
  285. "Days Q3": row.daysQ3,
  286. "Days Q4": row.daysQ4,
  287. "Q1 %": row.daysQ1Pct,
  288. "Q2 %": row.daysQ2Pct,
  289. "Q3 %": row.daysQ3Pct,
  290. "Q4 %": row.daysQ4Pct,
  291. "Gas-corr": row.gasCorr,
  292. "Oil-corr": row.oilCorr,
  293. "Water-corr": row.waterCorr,
  294. "Missing Facility ID": row.isMissingFacilityId || "",
  295. "Missing County": row.isMissingCounty || "",
  296. }));
  297. const worksheet = XLSX.utils.json_to_sheet(exportData);
  298. const workbook = XLSX.utils.book_new();
  299. XLSX.utils.book_append_sheet(workbook, worksheet, "Facility Summary");
  300. // Generate filename with import name and date
  301. const date = new Date().toISOString().split("T")[0];
  302. const filename = `${importName.replace(/[^a-z0-9]/gi, "_")}_Summary_${date}.xlsx`;
  303. XLSX.writeFile(workbook, filename);
  304. }
  305. return (
  306. <div className="space-y-4">
  307. <div className="flex justify-between items-center">
  308. <div className="text-sm text-muted-foreground">
  309. {data.length} records
  310. </div>
  311. <Button
  312. variant="outline"
  313. size="sm"
  314. onClick={exportToExcel}
  315. className="bg-green-50 hover:bg-green-100 text-green-700 border-green-300"
  316. >
  317. <Download className="h-4 w-4 mr-2" />
  318. Export to Excel
  319. </Button>
  320. </div>
  321. <div className="rounded-md border bg-white dark:bg-gray-800 dark:border-gray-700 overflow-auto flex flex-col max-h-[60vh]">
  322. <div className="overflow-x-auto">
  323. <Table>
  324. <TableHeader className="sticky top-0 bg-white dark:bg-gray-800 z-10">
  325. {table.getHeaderGroups().map((headerGroup) => (
  326. <TableRow key={headerGroup.id} className="dark:border-gray-700">
  327. {headerGroup.headers.map((header) => (
  328. <TableHead
  329. key={header.id}
  330. onClick={header.column.getToggleSortingHandler()}
  331. className={
  332. header.column.getCanSort()
  333. ? "cursor-pointer select-none whitespace-nowrap bg-gray-50 dark:bg-gray-900"
  334. : "whitespace-nowrap bg-gray-50 dark:bg-gray-900"
  335. }
  336. >
  337. <div className="flex items-center gap-1">
  338. {flexRender(
  339. header.column.columnDef.header,
  340. header.getContext(),
  341. )}
  342. {header.column.getIsSorted() && (
  343. <span>
  344. {header.column.getIsSorted() === "asc" ? "↑" : "↓"}
  345. </span>
  346. )}
  347. </div>
  348. </TableHead>
  349. ))}
  350. </TableRow>
  351. ))}
  352. </TableHeader>
  353. <TableBody>
  354. {table.getRowModel().rows?.length ? (
  355. table.getRowModel().rows.map((row) => (
  356. <TableRow
  357. key={row.id}
  358. data-state={row.getIsSelected() && "selected"}
  359. className="dark:border-gray-700"
  360. >
  361. {row.getVisibleCells().map((cell) => (
  362. <TableCell key={cell.id} className="whitespace-nowrap">
  363. {flexRender(
  364. cell.column.columnDef.cell,
  365. cell.getContext(),
  366. )}
  367. </TableCell>
  368. ))}
  369. </TableRow>
  370. ))
  371. ) : (
  372. <TableRow>
  373. <TableCell
  374. colSpan={columns.length}
  375. className="h-24 text-center"
  376. >
  377. No summary data available for this import.
  378. </TableCell>
  379. </TableRow>
  380. )}
  381. </TableBody>
  382. </Table>
  383. </div>
  384. </div>
  385. <div className="flex items-center justify-between px-4 pb-4">
  386. <div className="text-sm text-muted-foreground">
  387. Showing {table.getRowModel().rows.length} of {data.length} records
  388. </div>
  389. <div className="flex items-center space-x-2">
  390. <Button
  391. variant="outline"
  392. size="sm"
  393. onClick={() => table.previousPage()}
  394. disabled={!table.getCanPreviousPage()}
  395. >
  396. <ChevronLeft className="h-4 w-4 mr-1" />
  397. Previous
  398. </Button>
  399. <div className="text-sm font-medium">
  400. Page {table.getState().pagination.pageIndex + 1} of{" "}
  401. {table.getPageCount()}
  402. </div>
  403. <Button
  404. variant="outline"
  405. size="sm"
  406. onClick={() => table.nextPage()}
  407. disabled={!table.getCanNextPage()}
  408. >
  409. Next
  410. <ChevronRight className="h-4 w-4 ml-1" />
  411. </Button>
  412. </div>
  413. </div>
  414. </div>
  415. );
  416. }
  417. export function TerraTechSummaryDialog({
  418. open,
  419. onOpenChange,
  420. importId,
  421. }: TerraTechSummaryDialogProps) {
  422. const [loading, setLoading] = useState(false);
  423. const [error, setError] = useState<string | null>(null);
  424. const [summaryData, setSummaryData] = useState<SummaryData | null>(null);
  425. useEffect(() => {
  426. if (open && importId) {
  427. loadSummary();
  428. }
  429. // eslint-disable-next-line react-hooks/exhaustive-deps
  430. }, [open, importId]);
  431. async function loadSummary() {
  432. setLoading(true);
  433. setError(null);
  434. try {
  435. const result = await getTerraTechFacilitySummary(importId);
  436. if (result.success && result.data) {
  437. setSummaryData(result.data);
  438. } else {
  439. setError(result.error || "Failed to load summary");
  440. }
  441. } catch {
  442. setError("An unexpected error occurred");
  443. } finally {
  444. setLoading(false);
  445. }
  446. }
  447. return (
  448. <Dialog open={open} onOpenChange={onOpenChange}>
  449. <DialogContent className="max-w-[95vw] max-h-[90vh] w-full overflow-y-auto dark:bg-gray-800 dark:border-gray-700">
  450. <DialogPrimitive.Close className="absolute right-4 top-4 rounded-full p-2 opacity-70 transition-opacity hover:opacity-100 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-ring">
  451. <X className="h-5 w-5" />
  452. <span className="sr-only">Close</span>
  453. </DialogPrimitive.Close>
  454. <DialogHeader>
  455. <DialogTitle className="flex items-center gap-2 text-gray-900 dark:text-white">
  456. <div className="bg-amber-500 w-8 h-8 rounded-full flex items-center justify-center text-white">
  457. <Fuel className="w-4 h-4" />
  458. </div>
  459. Facility Summary
  460. {summaryData && (
  461. <Badge variant="outline" className="ml-2">
  462. {summaryData.importName}
  463. </Badge>
  464. )}
  465. </DialogTitle>
  466. </DialogHeader>
  467. {loading && (
  468. <div className="flex justify-center py-12">
  469. <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-500"></div>
  470. </div>
  471. )}
  472. {error && (
  473. <div className="flex flex-col items-center justify-center py-12 text-red-500">
  474. <AlertTriangle className="h-8 w-8 mb-2" />
  475. <p>{error}</p>
  476. </div>
  477. )}
  478. {!loading && !error && summaryData && (
  479. <SummaryTable
  480. data={summaryData.rows}
  481. importName={summaryData.importName}
  482. />
  483. )}
  484. </DialogContent>
  485. </Dialog>
  486. );
  487. }