diff --git a/index.html b/index.html index c81ace4b..54661045 100644 --- a/index.html +++ b/index.html @@ -45,6 +45,8 @@
+ +
diff --git a/package-lock.json b/package-lock.json index 58ed2b7c..acc08e07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@fief/fief": "^0.15.0-beta.2", "@fontsource-variable/outfit": "^5.2.8", "@fontsource-variable/public-sans": "^5.2.7", + "@glideapps/glide-data-grid": "^6.0.3", "@hookform/resolvers": "^5.2.2", "@mapbox/mapbox-gl-draw": "^1.4.3", "@mapbox/mapbox-gl-geocoder": "^5.0.3", @@ -50,10 +51,11 @@ "echarts-for-react": "^3.0.2", "js-sha256": "^0.11.0", "jwt-decode": "^4.0.0", - "lodash": "^4.17.21", + "lodash": "^4.18.1", "lucide-react": "^1.17.0", "mapbox-gl": "^3.0.0", "mapbox-gl-style-switcher": "^1.0.11", + "marked": "^4.3.0", "pako": "^2.1.0", "posthog-js": "^1.363.1", "proj4": "^2.15.0", @@ -65,6 +67,7 @@ "react-input-mask": "^2.0.4", "react-map-gl": "^7.1.7", "react-markdown": "^10.1.0", + "react-responsive-carousel": "^3.2.23", "react-router": "^7.13.1", "shadcn": "^4.10.0", "tailwind-merge": "^3.6.0", @@ -474,6 +477,47 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-proposal-export-namespace-from": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.18.9.tgz", + "integrity": "sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-export-namespace-from instead.", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.9", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-namespace-from": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", + "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-flow": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz", @@ -2034,6 +2078,24 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@glideapps/glide-data-grid": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@glideapps/glide-data-grid/-/glide-data-grid-6.0.3.tgz", + "integrity": "sha512-YXKggiNOaEemf0jP0jORq2EQKz+zXms+6mGzZc+q0mLMjmgzzoGLOQC1uYcynXSj1R61bd27JcPFsoH+Gj37Vg==", + "license": "MIT", + "dependencies": { + "@linaria/react": "^4.5.3", + "canvas-hypertxt": "^1.0.3", + "react-number-format": "^5.0.0" + }, + "peerDependencies": { + "lodash": "^4.17.19", + "marked": "^4.0.10", + "react": "^16.12.0 || 17.x || 18.x", + "react-dom": "^16.12.0 || 17.x || 18.x", + "react-responsive-carousel": "^3.2.7" + } + }, "node_modules/@hey-api/codegen-core": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.2.0.tgz", @@ -2522,6 +2584,91 @@ "jsep": "^0.4.0||^1.0.0" } }, + "node_modules/@linaria/core": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/@linaria/core/-/core-4.5.4.tgz", + "integrity": "sha512-vMs/5iU0stxjfbBCxobIgY+wSQx4G8ukNwrhjPVD+6bF9QrTwi5rl0mKaCMxaGMjnfsLRiiM3i+hnWLIEYLdSg==", + "license": "MIT", + "dependencies": { + "@linaria/logger": "^4.5.0", + "@linaria/tags": "^4.5.4", + "@linaria/utils": "^4.5.3" + }, + "engines": { + "node": "^12.16.0 || >=13.7.0" + } + }, + "node_modules/@linaria/logger": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@linaria/logger/-/logger-4.5.0.tgz", + "integrity": "sha512-XdQLk242Cpcsc9a3Cz1ktOE5ysTo2TpxdeFQEPwMm8Z/+F/S6ZxBDdHYJL09srXWz3hkJr3oS2FPuMZNH1HIxw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "picocolors": "^1.0.0" + }, + "engines": { + "node": "^12.16.0 || >=13.7.0" + } + }, + "node_modules/@linaria/react": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/@linaria/react/-/react-4.5.4.tgz", + "integrity": "sha512-/dhCVCsfdGPfQCPV0q5yy+DDlFXepvfXrw/os2fC+Xo1v9J/9gyiaBBWHzcumauvNNFj8aN6vRkj89fMujPHew==", + "license": "MIT", + "dependencies": { + "@emotion/is-prop-valid": "^1.2.0", + "@linaria/core": "^4.5.4", + "@linaria/tags": "^4.5.4", + "@linaria/utils": "^4.5.3", + "minimatch": "^9.0.3", + "react-html-attributes": "^1.4.6", + "ts-invariant": "^0.10.3" + }, + "engines": { + "node": "^12.16.0 || >=13.7.0" + }, + "peerDependencies": { + "react": ">=16" + } + }, + "node_modules/@linaria/tags": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/@linaria/tags/-/tags-4.5.4.tgz", + "integrity": "sha512-HPxLB6HlJWLi6o8+8lTLegOmDnbMbuzEE+zzunaPZEGSoIIYx8HAv5VbY/sG/zNyxDElk6laiAwEVWN8h5/zxg==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.22.9", + "@linaria/logger": "^4.5.0", + "@linaria/utils": "^4.5.3" + }, + "engines": { + "node": "^12.16.0 || >=13.7.0" + } + }, + "node_modules/@linaria/utils": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/@linaria/utils/-/utils-4.5.3.tgz", + "integrity": "sha512-tSpxA3Zn0DKJ2n/YBnYAgiDY+MNvkmzAHrD8R9PKrpGaZ+wz1jQEmE1vGn1cqh8dJyWK0NzPAA8sf1cqa+RmAg==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.22.9", + "@babel/generator": "^7.22.9", + "@babel/plugin-proposal-export-namespace-from": "^7.18.9", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-transform-modules-commonjs": "^7.22.5", + "@babel/template": "^7.22.5", + "@babel/traverse": "^7.22.8", + "@babel/types": "^7.22.5", + "@linaria/logger": "^4.5.0", + "babel-merge": "^3.0.0", + "find-up": "^5.0.0", + "minimatch": "^9.0.3" + }, + "engines": { + "node": "^12.16.0 || >=13.7.0" + } + }, "node_modules/@mapbox/fusspot": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@mapbox/fusspot/-/fusspot-0.4.0.tgz", @@ -12480,6 +12627,29 @@ "node": ">=10" } }, + "node_modules/babel-merge": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/babel-merge/-/babel-merge-3.0.0.tgz", + "integrity": "sha512-eBOBtHnzt9xvnjpYNI5HmaPp/b2vMveE5XggzqHnQeHJ8mFIBrBv6WZEVIj5jJ2uwTItkqKo9gWzEEcBxEq0yw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "deepmerge": "^2.2.1", + "object.omit": "^3.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-merge/node_modules/deepmerge": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", + "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -13101,6 +13271,12 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-hypertxt": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/canvas-hypertxt/-/canvas-hypertxt-1.0.3.tgz", + "integrity": "sha512-+VsMpRr64jYgKq2IeFUNel3vCZH/IzS+iXSHxmUV3IUH5dXlC9xHz4AwtPZisDxZ5MWcuK0V+TXgPKFPiZnxzg==", + "license": "MIT" + }, "node_modules/cardinal": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/cardinal/-/cardinal-2.1.1.tgz", @@ -13321,6 +13497,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -17808,6 +17990,12 @@ "integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==", "license": "ISC" }, + "node_modules/html-element-attributes": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/html-element-attributes/-/html-element-attributes-1.3.1.tgz", + "integrity": "sha512-UrRKgp5sQmRnDy4TEwAUsu14XBUlzKB8U3hjIYDjcZ3Hbp86Jtftzxfgrv6E/ii/h78tsaZwAnAE8HwnHr0dPA==", + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -23550,6 +23738,42 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object.omit": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object.omit/-/object.omit-3.0.0.tgz", + "integrity": "sha512-EO+BCv6LJfu+gBIF3ggLicFebFLN5zqzz/WWJlMFfkMyGth+oBkhxzDl0wx2W4GkLzuQs/FsSkXZb2IMWQqmBQ==", + "license": "MIT", + "dependencies": { + "is-extendable": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object.omit/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object.omit/node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object.values": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", @@ -25431,6 +25655,18 @@ "loose-envify": "^1.1.0" } }, + "node_modules/react-easy-swipe": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/react-easy-swipe/-/react-easy-swipe-0.0.21.tgz", + "integrity": "sha512-OeR2jAxdoqUMHIn/nS9fgreI5hSpgGoL5ezdal4+oO7YSSgJR8ga+PkYGJrSrJ9MKlPcQjMQXnketrD7WNmNsg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.5.8" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/react-error-boundary": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.1.tgz", @@ -25469,6 +25705,15 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-html-attributes": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/react-html-attributes/-/react-html-attributes-1.4.6.tgz", + "integrity": "sha512-uS3MmThNKFH2EZUQQw4k5pIcU7XIr208UE5dktrj/GOH1CMagqxDl4DCLpt3o2l9x+IB5nVYBeN3Cr4IutBXAg==", + "license": "MIT", + "dependencies": { + "html-element-attributes": "^1.0.0" + } + }, "node_modules/react-input-mask": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/react-input-mask/-/react-input-mask-2.0.4.tgz", @@ -25540,6 +25785,16 @@ "react": ">=18" } }, + "node_modules/react-number-format": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.5.tgz", + "integrity": "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==", + "license": "MIT", + "peerDependencies": { + "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-reconciler": { "version": "0.29.2", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", @@ -25622,6 +25877,17 @@ } } }, + "node_modules/react-responsive-carousel": { + "version": "3.2.23", + "resolved": "https://registry.npmjs.org/react-responsive-carousel/-/react-responsive-carousel-3.2.23.tgz", + "integrity": "sha512-pqJLsBaKHWJhw/ItODgbVoziR2z4lpcJg+YwmRlSk4rKH32VE633mAtZZ9kDXjy4wFO+pgUZmDKPsPe1fPmHCg==", + "license": "MIT", + "dependencies": { + "classnames": "^2.2.5", + "prop-types": "^15.5.8", + "react-easy-swipe": "^0.0.21" + } + }, "node_modules/react-router": { "version": "7.14.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", @@ -28701,6 +28967,18 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-invariant": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.10.3.tgz", + "integrity": "sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ts-morph": { "version": "26.0.0", "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", diff --git a/package.json b/package.json index 8e383daf..d4b81df0 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@fief/fief": "^0.15.0-beta.2", "@fontsource-variable/outfit": "^5.2.8", "@fontsource-variable/public-sans": "^5.2.7", + "@glideapps/glide-data-grid": "^6.0.3", "@hookform/resolvers": "^5.2.2", "@mapbox/mapbox-gl-draw": "^1.4.3", "@mapbox/mapbox-gl-geocoder": "^5.0.3", @@ -76,10 +77,11 @@ "echarts-for-react": "^3.0.2", "js-sha256": "^0.11.0", "jwt-decode": "^4.0.0", - "lodash": "^4.17.21", + "lodash": "^4.18.1", "lucide-react": "^1.17.0", "mapbox-gl": "^3.0.0", "mapbox-gl-style-switcher": "^1.0.11", + "marked": "^4.3.0", "pako": "^2.1.0", "posthog-js": "^1.363.1", "proj4": "^2.15.0", @@ -91,6 +93,7 @@ "react-input-mask": "^2.0.4", "react-map-gl": "^7.1.7", "react-markdown": "^10.1.0", + "react-responsive-carousel": "^3.2.23", "react-router": "^7.13.1", "shadcn": "^4.10.0", "tailwind-merge": "^3.6.0", diff --git a/src/App.tsx b/src/App.tsx index 41137055..07496244 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,8 @@ import { AppShell } from '@/components/AppShell' import { ThemedTitleV2 } from '@/components/layout/title' import { Callback } from '@/components/Auth' import { Home } from '@/pages/home' -// import { TypographyPage } from '@/pages/example/TypographyPage' +import { TypographyPage } from '@/pages/example/TypographyPage' +import { DataGridPage } from '@/pages/example/DataGridPage' import { ContentPage } from '@/pages/content' import { OcotilloRoutes, ST2Routes } from '@/routes' import { settings } from '@/settings' @@ -62,8 +63,9 @@ const App: React.FC = () => ( path="/ogcapi" element={} /> - {/* TEMPORARY: example pages — hidden until editing-tools is merged */} - {/* } /> */} + {/* TEMPORARY: example specimen pages */} + } /> + } /> } /> } /> diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index e2d59f0b..dacb5d09 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -62,7 +62,7 @@ import { import { ColorModeContext } from '@/contexts' import SearchBar from '@/components/SearchBar' import { ReportBugButton } from '@/components/Button' -import { AmpRole, PRIMARY_NAV, RESOURCE_NAV, type NavItem } from '@/config/navigation' +import { AmpRole, PRIMARY_NAV, RESOURCE_NAV, SHOW_EXAMPLE_NAV, type NavItem } from '@/config/navigation' import { useAccessCapabilities } from '@/hooks' import { useSearch } from '@/providers/search-provider' import { SupportPanelContext } from '@/components/SupportPanelContext' @@ -435,8 +435,8 @@ function AppSidebar() { canSeeNavItem={canSeeNavItem} /> ))} - {/* ── TEMPORARY: Example section — hidden until editing-tools is ready ── */} - {/* */} + {/* Example demos — toggle SHOW_EXAMPLE_NAV in config/navigation.ts */} + {SHOW_EXAMPLE_NAV ? : null} @@ -506,6 +506,14 @@ function ExampleNavItem() { Typography + + + Data Grid + + diff --git a/src/components/Button/WellPDFActions.tsx b/src/components/Button/WellPDFActions.tsx new file mode 100644 index 00000000..ddd61c17 --- /dev/null +++ b/src/components/Button/WellPDFActions.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react' +import { BaseRecord, useGo, useNotification } from '@refinedev/core' +import { useParams } from 'react-router' +import { DownloadIcon, EyeIcon } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { WellPDF } from '@/components' +import { buildPdfFilename, SensorDeploymentRow } from '@/utils' +import { pdf } from '@react-pdf/renderer' +import { IContact, IObservation, ISample, IWell } from '@/interfaces/ocotillo' +import { IPdfOptions } from '@/interfaces' +import { PDF_SINGLE_PAGE_OPTION } from '@/config' +import { useAccessCapabilities } from '@/hooks' + +type WellPDFActionsButtonProps = { + isPreviewLoading: boolean + isDownloadLoading: boolean + well: IWell + observations: readonly Partial>[] + assets: BaseRecord[] + contacts: IContact[] + sample?: Partial + sensorDeployments?: SensorDeploymentRow[] + options?: IPdfOptions + hydrographImage?: string | null +} + +export const WellPDFActionsButton = ({ + isPreviewLoading, + isDownloadLoading, + well, + observations, + assets, + contacts, + sample, + sensorDeployments = [], + options, + hydrographImage, +}: WellPDFActionsButtonProps) => { + const go = useGo() + const { id } = useParams() + const { open: notify } = useNotification() + const { + isLoading: isPermissionsLoading, + canManageAmp, + canViewConfidential, + } = useAccessCapabilities() + + const [isGenerating, setIsGenerating] = useState(false) + + const previewDisabled = + isPreviewLoading || isPermissionsLoading || !canManageAmp + + const downloadDisabled = + isDownloadLoading || + isPermissionsLoading || + !canManageAmp || + isGenerating + + const handlePreview = () => { + go({ to: `/ocotillo/well/pdf-preview/${id}`, type: 'push' }) + } + + const handleDownload = async (opts: IPdfOptions) => { + if (!well?.id) return + + try { + setIsGenerating(true) + const filename = buildPdfFilename(well) + + const blob = await pdf( + + ).toBlob() + + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename.endsWith('.pdf') ? filename : `${filename}.pdf` + a.click() + URL.revokeObjectURL(url) + + notify?.({ + message: 'PDF generated successfully', + type: 'success', + description: a.download, + }) + } catch (error) { + console.error(error) + notify?.({ + message: 'PDF Generation Failed', + type: 'error', + }) + } finally { + setIsGenerating(false) + } + } + + return ( +
+ +
+ + + + + + {isGenerating ? 'Generating…' : 'Download PDF'} + + +
+ ) +} diff --git a/src/components/Button/WellPDFDownload.tsx b/src/components/Button/WellPDFDownload.tsx index c419af29..7916ceec 100644 --- a/src/components/Button/WellPDFDownload.tsx +++ b/src/components/Button/WellPDFDownload.tsx @@ -4,8 +4,8 @@ import { IContact, IObservation, ISample, IWell } from '@/interfaces/ocotillo' import { WellPDF } from '@/components' import { buildPdfFilename, SensorDeploymentRow } from '@/utils' import { pdf } from '@react-pdf/renderer' -import { Button } from '@mui/material' -import { Download } from '@mui/icons-material' +import { DownloadIcon } from 'lucide-react' +import { Button } from '@/components/ui/button' import { IPdfOptions } from '@/interfaces' import { PDF_SINGLE_PAGE_OPTION } from '@/config' import { useAccessCapabilities } from '@/hooks' @@ -91,13 +91,13 @@ export const WellPDFDownloadButton = ({ return ( ) } diff --git a/src/components/Button/WellPDFPreview.tsx b/src/components/Button/WellPDFPreview.tsx index 5887507f..ec8fde2a 100644 --- a/src/components/Button/WellPDFPreview.tsx +++ b/src/components/Button/WellPDFPreview.tsx @@ -1,7 +1,7 @@ import { useGo } from '@refinedev/core' import { useParams } from 'react-router' -import { Button } from '@mui/material' -import { Visibility } from '@mui/icons-material' +import { EyeIcon } from 'lucide-react' +import { Button } from '@/components/ui/button' import { useAccessCapabilities } from '@/hooks' export const WellPDFPreviewButton = ({ isLoading }: { isLoading: boolean }) => { @@ -18,12 +18,12 @@ export const WellPDFPreviewButton = ({ isLoading }: { isLoading: boolean }) => { return ( ) diff --git a/src/components/Button/index.ts b/src/components/Button/index.ts index 36b3c1d7..ff3bb34b 100644 --- a/src/components/Button/index.ts +++ b/src/components/Button/index.ts @@ -1,3 +1,4 @@ export * from './ReportBugButton' export * from './WellPDFPreview' export * from './WellPDFDownload' +export * from './WellPDFActions' diff --git a/src/components/ListPage.tsx b/src/components/ListPage.tsx index bd1571c1..485c558b 100644 --- a/src/components/ListPage.tsx +++ b/src/components/ListPage.tsx @@ -27,6 +27,10 @@ import { } from '@refinedev/core' import { Box, Chip, InputBase, Stack, Typography } from '@mui/material' import SearchIcon from '@mui/icons-material/Search' +import { + ocotilloCardHeaderProps, + ocotilloPageTitleTypographySx, +} from '@/components/OcotilloPageHeader' /** * Standard layout for Ocotillo list pages: title, optional description, search @@ -304,7 +308,11 @@ export const ListPage: React.FC = ({ title={ title ? ( - + {title} {description && ( @@ -328,17 +336,7 @@ export const ListPage: React.FC = ({ padding: 0, }, }} - headerProps={{ - sx: { - flexDirection: { xs: 'column', md: 'row' }, - alignItems: { xs: 'flex-start', md: 'center' }, - '.MuiCardHeader-action': { - alignSelf: { xs: 'flex-end', md: 'flex-start' }, - mt: { xs: 1, md: 0.5 }, - mr: 0, - }, - }, - }} + headerProps={ocotilloCardHeaderProps} contentProps={{ sx: { pt: 1 } }} > {children} diff --git a/src/components/OcotilloPageHeader.tsx b/src/components/OcotilloPageHeader.tsx new file mode 100644 index 00000000..db796c39 --- /dev/null +++ b/src/components/OcotilloPageHeader.tsx @@ -0,0 +1,74 @@ +import type { ReactNode } from 'react' +import { Box, Skeleton, SxProps, Theme, Typography } from '@mui/material' + +/** Shared CardHeader layout for Ocotillo list and show pages. */ +export const ocotilloCardHeaderProps: { sx: SxProps } = { + sx: { + flexDirection: { xs: 'column', md: 'row' }, + alignItems: 'flex-start', + gap: { xs: 1.5, md: 3 }, + '.MuiCardHeader-content': { + alignSelf: 'flex-start', + }, + '.MuiCardHeader-action': { + alignSelf: { xs: 'flex-end', md: 'flex-start' }, + mr: 0, + pt: { xs: 0.5, md: 1 }, + }, + }, +} + +export const ocotilloPageTitleRowSx = { + display: 'flex', + alignItems: 'center', + gap: 1.5, + flexWrap: 'wrap', +} as const + +export const ocotilloPageTitleTypographySx = { + lineHeight: 1.1, + fontWeight: 700, +} as const + +/** Title row: h3 plus optional trailing content (chips, tags, etc.). */ +export function OcotilloPageTitle({ + title, + isLoading = false, + loadingWidth = 120, + children, +}: { + title: ReactNode + isLoading?: boolean + loadingWidth?: number + children?: ReactNode +}) { + return ( + + + {isLoading ? ( + + ) : ( + title + )} + + {children} + + ) +} + +/** Wrapper for header action buttons (Preview PDF, Edit, Create, etc.). */ +export function OcotilloHeaderButtons({ + children, + className, +}: { + children: ReactNode + className?: string +}) { + return ( +
{children}
+ ) +} diff --git a/src/components/WellEdit/WellEditPanel.tsx b/src/components/WellEdit/WellEditPanel.tsx new file mode 100644 index 00000000..66314205 --- /dev/null +++ b/src/components/WellEdit/WellEditPanel.tsx @@ -0,0 +1,362 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useCustomMutation, useList, useNotification } from '@refinedev/core' +import { useQueryClient } from '@tanstack/react-query' +import { Loader2, XIcon } from 'lucide-react' +import { Button, buttonVariants } from '@/components/ui/button' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + EditPanel, + EditPanelField, + EditPanelSection, +} from '@/components/editing' +import { invalidateWellDetails, wellDetailsQueryKey } from '@/hooks' +import type { IWellDetails } from '@/interfaces/ocotillo' +import type { IGroup } from '@/interfaces/ocotillo/IGroup' +import { Skeleton } from '@/components/ui/skeleton' + +interface WellEditPanelProps { + wellId: string | number + wellName?: string | null + assignedGroups: IGroup[] + isAssignedGroupsLoading?: boolean + onClose: () => void +} + +function ProjectChip({ + name, + onRemove, + disabled, +}: { + name: string + onRemove: () => void + disabled?: boolean +}) { + return ( + + {name} + + + ) +} + +function ProjectsSectionSkeleton() { + return ( + <> +
+ + + +
+
+ + +
+ + ) +} + +function sortGroupsByName(groups: IGroup[]) { + return [...groups].sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + ) +} + +function groupsHaveSameIds(a: IGroup[], b: IGroup[]) { + if (a.length !== b.length) { + return false + } + + const ids = new Set(a.map((group) => group.id)) + return b.every((group) => ids.has(group.id)) +} + +function syncDraftFromQueryCache( + queryClient: ReturnType, + wellId: string | number +) { + const data = queryClient.getQueryData( + wellDetailsQueryKey(wellId) + ) + return sortGroupsByName(data?.well?.groups ?? []) +} + +export function WellEditPanel({ + wellId, + wellName, + assignedGroups, + isAssignedGroupsLoading = false, + onClose, +}: WellEditPanelProps) { + const queryClient = useQueryClient() + const { open: notify } = useNotification() + const { mutateAsync: mutateGroupThing, mutation } = useCustomMutation() + + const isSaving = mutation.isPending + + const [selectKey, setSelectKey] = useState(0) + const [draftGroups, setDraftGroups] = useState([]) + const [initialGroups, setInitialGroups] = useState([]) + const [discardDialogOpen, setDiscardDialogOpen] = useState(false) + const wasLoadingRef = useRef(true) + + const panelTitle = wellName ? `Edit: ${wellName}` : 'Edit' + + const { result: allGroupsResult, query: groupsQuery } = useList({ + resource: 'group', + dataProviderName: 'ocotillo', + pagination: { pageSize: 200 }, + filters: [ + { field: 'group_type', operator: 'ne', value: 'Geographic Area' }, + { field: 'group_type', operator: 'ne', value: 'Historical' }, + ], + }) + const isGroupsLoading = groupsQuery.isLoading + const isLoading = isAssignedGroupsLoading || isGroupsLoading + + useEffect(() => { + wasLoadingRef.current = true + }, [wellId]) + + useEffect(() => { + if (isLoading) { + wasLoadingRef.current = true + return + } + + if (!wasLoadingRef.current) { + return + } + + const sorted = sortGroupsByName(assignedGroups) + setDraftGroups(sorted) + setInitialGroups(sorted) + wasLoadingRef.current = false + }, [assignedGroups, isLoading, wellId]) + + const isDirty = useMemo( + () => !groupsHaveSameIds(draftGroups, initialGroups), + [draftGroups, initialGroups] + ) + + const availableGroups = useMemo(() => { + const groups = allGroupsResult?.data ?? [] + const assignedIds = new Set(draftGroups.map((group) => group.id)) + return groups + .filter((group) => !assignedIds.has(group.id)) + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + ) + }, [allGroupsResult?.data, draftGroups]) + + const handleAddProject = (group: IGroup) => { + setDraftGroups((previous) => sortGroupsByName([...previous, group])) + setSelectKey((key) => key + 1) + } + + const handleRemoveProject = (group: IGroup) => { + setDraftGroups((previous) => + previous.filter((item) => item.id !== group.id) + ) + } + + const resyncDraftFromServer = async () => { + await invalidateWellDetails(queryClient, wellId) + const sorted = syncDraftFromQueryCache(queryClient, wellId) + setDraftGroups(sorted) + setInitialGroups(sorted) + } + + const handleSave = async () => { + if (!isDirty || isSaving) { + return + } + + const initialIds = new Set(initialGroups.map((group) => group.id)) + const draftIds = new Set(draftGroups.map((group) => group.id)) + const toAdd = draftGroups.filter((group) => !initialIds.has(group.id)) + const toRemove = initialGroups.filter((group) => !draftIds.has(group.id)) + + try { + await Promise.all([ + ...toRemove.map((group) => + mutateGroupThing({ + url: `group/${group.id}/things/${wellId}`, + method: 'delete', + values: {}, + dataProviderName: 'ocotillo', + }) + ), + ...toAdd.map((group) => + mutateGroupThing({ + url: `group/${group.id}/things/${wellId}`, + method: 'post', + values: {}, + dataProviderName: 'ocotillo', + }) + ), + ]) + + await invalidateWellDetails(queryClient, wellId) + onClose() + } catch { + notify?.({ + type: 'error', + message: 'Could not save project changes. Please try again.', + }) + await resyncDraftFromServer() + } + } + + const handleRequestClose = () => { + if (isSaving) { + return + } + + if (isDirty) { + setDiscardDialogOpen(true) + return + } + + onClose() + } + + const handleDiscardChanges = () => { + onClose() + } + + return ( + <> + + + + + } + > + + {isLoading ? ( + + ) : ( + <> +
+ {draftGroups.length > 0 ? ( +
+ {draftGroups.map((group) => ( + handleRemoveProject(group)} + disabled={isSaving} + /> + ))} +
+ ) : ( +

+ No projects assigned yet. +

+ )} +
+ + + + + + )} +
+
+ + + + + Discard unsaved changes? + + Project changes you have not saved will be lost. + + + + Keep editing + + Discard + + + + + + ) +} diff --git a/src/components/WellShow/WellShowTitle.tsx b/src/components/WellShow/WellShowTitle.tsx index 3a59c618..62ecf1bd 100644 --- a/src/components/WellShow/WellShowTitle.tsx +++ b/src/components/WellShow/WellShowTitle.tsx @@ -1,14 +1,7 @@ -import { Box, Skeleton, Typography } from '@mui/material' import { WellStatusChips } from '@/components' +import { OcotilloPageTitle } from '@/components/OcotilloPageHeader' import { IWell } from '@/interfaces/ocotillo' -const BoxSx = { - display: 'flex', - alignItems: 'center', - gap: 1.5, - flexWrap: 'wrap', -} - export const WellShowTitle = ({ well, isLoading, @@ -17,16 +10,11 @@ export const WellShowTitle = ({ isLoading: boolean }) => { return ( - - - {isLoading ? ( - - ) : ( - (well?.name ?? '') - )} - - + - + ) } diff --git a/src/components/WellStatusChips.tsx b/src/components/WellStatusChips.tsx index ec69a406..c716ab3f 100644 --- a/src/components/WellStatusChips.tsx +++ b/src/components/WellStatusChips.tsx @@ -4,6 +4,21 @@ import { IWell } from '@/interfaces/ocotillo' const loadingChipWidths = [100, 110, 100] +const statusChipSx = { + fontFamily: 'monospace', + fontWeight: 300, + lineHeight: 1, + fontSize: '0.75rem', + height: 22, + '& .MuiChip-label': { + px: 1.5, + py: 0, + lineHeight: 1, + display: 'flex', + alignItems: 'center', + }, +} + export const WellStatusChips = ({ well, isLoading, @@ -15,11 +30,10 @@ export const WellStatusChips = ({ return ( {loadingChipWidths.map((width, i) => ( {topChipValues.map((p, i) => ( void + children: React.ReactNode + footer?: React.ReactNode + widthClassName?: string +}) { + return ( +
+
+ {title} + +
+ +
{children}
+ + {footer ? ( +
+ {footer} +
+ ) : null} +
+ ) +} diff --git a/src/components/editing/EditPanelField.tsx b/src/components/editing/EditPanelField.tsx new file mode 100644 index 00000000..76c9c80f --- /dev/null +++ b/src/components/editing/EditPanelField.tsx @@ -0,0 +1,24 @@ +import { Label } from '@/components/ui/label' +import { cn } from '@/lib/utils' + +export function EditPanelField({ + label, + required, + span, + children, +}: { + label: string + required?: boolean + span?: 'full' + children: React.ReactNode +}) { + return ( +
+ + {children} +
+ ) +} diff --git a/src/components/editing/EditPanelLayout.tsx b/src/components/editing/EditPanelLayout.tsx new file mode 100644 index 00000000..4cb8acad --- /dev/null +++ b/src/components/editing/EditPanelLayout.tsx @@ -0,0 +1,135 @@ +import { cn } from '@/lib/utils' +import { useIsMobile } from '@/hooks/use-mobile' +import { useEditPanelWidth } from './useEditPanelWidth' + +/** Height of the main content area below the app shell header (h-14). */ +const PANEL_VIEWPORT_HEIGHT = 'h-[calc(100svh-3.5rem)]' + +function EditPanelResizeHandle({ + panelWidth, + onResizeStart, +}: { + panelWidth: number + onResizeStart: (event: React.MouseEvent) => void +}) { + return ( +
+
+
+ ) +} + +export function EditPanelLayout({ + open, + panel, + children, + panelWidthClassName = 'w-[400px]', + className, + /** + * sticky: main page scrolls in AppShell; panel pins to the right (well show). + * split: main column scrolls inside a fixed viewport (Data Grid create panel). + */ + pinPanel = 'split', + /** Allow drag-to-resize panel width (sticky mode only). */ + resizable = pinPanel === 'sticky', +}: { + open: boolean + panel: React.ReactNode + children: React.ReactNode + panelWidthClassName?: string + className?: string + pinPanel?: 'sticky' | 'split' + resizable?: boolean +}) { + const isMobile = useIsMobile() + const resizeEnabled = + open && resizable && pinPanel === 'sticky' && !isMobile + const { panelWidth, isResizing, handleResizeStart } = + useEditPanelWidth(resizeEnabled) + + if (pinPanel === 'sticky') { + return ( +
+
+ {children} +
+ + {isMobile ? ( + open ? ( +
+ {panel} +
+ ) : null + ) : ( +
+ {open ? ( +
+ {resizeEnabled ? ( + + ) : null} + {panel} +
+ ) : null} +
+ )} +
+ ) + } + + return ( +
+
+ {children} +
+ +
+ {open ?
{panel}
: null} +
+
+ ) +} diff --git a/src/components/editing/EditPanelSection.tsx b/src/components/editing/EditPanelSection.tsx new file mode 100644 index 00000000..15e9716e --- /dev/null +++ b/src/components/editing/EditPanelSection.tsx @@ -0,0 +1,37 @@ +import { useState } from 'react' +import { ChevronRight } from 'lucide-react' +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible' +import { cn } from '@/lib/utils' + +export function EditPanelSection({ + title, + defaultOpen = true, + children, +}: { + title: string + defaultOpen?: boolean + children: React.ReactNode +}) { + const [open, setOpen] = useState(defaultOpen) + + return ( + + + + {title} + + +
{children}
+
+
+ ) +} diff --git a/src/components/editing/index.ts b/src/components/editing/index.ts new file mode 100644 index 00000000..76116e3b --- /dev/null +++ b/src/components/editing/index.ts @@ -0,0 +1,4 @@ +export { EditPanel } from './EditPanel' +export { EditPanelField } from './EditPanelField' +export { EditPanelLayout } from './EditPanelLayout' +export { EditPanelSection } from './EditPanelSection' diff --git a/src/components/editing/useEditPanelWidth.ts b/src/components/editing/useEditPanelWidth.ts new file mode 100644 index 00000000..f67d293f --- /dev/null +++ b/src/components/editing/useEditPanelWidth.ts @@ -0,0 +1,90 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +const STORAGE_KEY = 'ocotillo-edit-panel-width' +export const EDIT_PANEL_DEFAULT_WIDTH = 400 +export const EDIT_PANEL_MIN_WIDTH = 320 +export const EDIT_PANEL_MAX_WIDTH = 720 + +function clampWidth(width: number) { + const maxWidth = Math.min( + EDIT_PANEL_MAX_WIDTH, + Math.floor(window.innerWidth * 0.55) + ) + return Math.min(maxWidth, Math.max(EDIT_PANEL_MIN_WIDTH, width)) +} + +function readStoredWidth() { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored != null) { + const parsed = Number(stored) + if (Number.isFinite(parsed)) { + return clampWidth(parsed) + } + } + } catch { + // ignore localStorage errors + } + return EDIT_PANEL_DEFAULT_WIDTH +} + +export function useEditPanelWidth(enabled: boolean) { + const [panelWidth, setPanelWidth] = useState(readStoredWidth) + const [isResizing, setIsResizing] = useState(false) + const panelWidthRef = useRef(panelWidth) + + useEffect(() => { + panelWidthRef.current = panelWidth + }, [panelWidth]) + + useEffect(() => { + if (!enabled) return + + const handleResize = () => { + setPanelWidth((current) => clampWidth(current)) + } + + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, [enabled]) + + const handleResizeStart = useCallback( + (event: React.MouseEvent) => { + event.preventDefault() + + const startX = event.clientX + const startWidth = panelWidthRef.current + + setIsResizing(true) + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + + const onMouseMove = (moveEvent: MouseEvent) => { + const nextWidth = clampWidth(startWidth + (startX - moveEvent.clientX)) + panelWidthRef.current = nextWidth + setPanelWidth(nextWidth) + } + + const onMouseUp = () => { + setIsResizing(false) + document.body.style.cursor = '' + document.body.style.userSelect = '' + + try { + localStorage.setItem(STORAGE_KEY, String(panelWidthRef.current)) + } catch { + // ignore localStorage errors + } + + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + } + + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + }, + [] + ) + + return { panelWidth, isResizing, handleResizeStart } +} diff --git a/src/components/index.ts b/src/components/index.ts index 45ba8e86..5c9b65d5 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -18,6 +18,7 @@ export * from './Hydrographs/OcotilloHydrographCorrectionWorkbench' export * from './HydrographPngExporter' export * from './LegendComponent' export * from './ListPage' +export * from './OcotilloPageHeader' export * from './MapComponent' export * from './MapPopupComponent' export * from './SkeletonFormField' diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..0821cca2 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,151 @@ +"use client" + +import * as React from "react" +import { AlertDialog as AlertDialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} diff --git a/src/config/navigation.ts b/src/config/navigation.ts index 82239e78..c4283622 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -54,6 +54,9 @@ export type NavItem = { children?: NavItem[] } +/** Toggle Example nav (typography, data grid demos). Set true to restore. */ +export const SHOW_EXAMPLE_NAV = false + /** * Top bar: views and tools. * Items without `roles` are visible to every authenticated user. diff --git a/src/hooks/index.ts b/src/hooks/index.ts index d64567f0..a3abc778 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -19,3 +19,5 @@ export * from './useThingLayers' export * from './useUSGSSiteInfo' export * from './useViewportBbox' export * from './useSearchModalState' +export * from './useSidebarPanelSync' +export * from './useWellDetails' diff --git a/src/hooks/use-mobile.ts b/src/hooks/use-mobile.ts index 2b0fe1df..4296a56b 100644 --- a/src/hooks/use-mobile.ts +++ b/src/hooks/use-mobile.ts @@ -1,18 +1,33 @@ -import * as React from "react" +import * as React from 'react' const MOBILE_BREAKPOINT = 768 +function readIsMobileViewport() { + if (typeof window === 'undefined') { + return false + } + + return window.innerWidth < MOBILE_BREAKPOINT +} + export function useIsMobile() { const [isMobile, setIsMobile] = React.useState(undefined) React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + const update = () => { + setIsMobile(readIsMobileViewport()) } - mql.addEventListener("change", onChange) - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - return () => mql.removeEventListener("change", onChange) + + update() + + if (typeof window.matchMedia !== 'function') { + window.addEventListener('resize', update) + return () => window.removeEventListener('resize', update) + } + + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + mql.addEventListener('change', update) + return () => mql.removeEventListener('change', update) }, []) return !!isMobile diff --git a/src/hooks/useSidebarPanelSync.ts b/src/hooks/useSidebarPanelSync.ts new file mode 100644 index 00000000..b3df9f6c --- /dev/null +++ b/src/hooks/useSidebarPanelSync.ts @@ -0,0 +1,35 @@ +import { useCallback, useRef, useState } from 'react' +import { useSidebar } from '@/components/ui/use-sidebar' + +/** + * Keeps a side panel in sync with the app sidebar: collapse the sidebar when + * the panel opens, and restore the previous sidebar state when it closes. + */ +export function useSidebarPanelSync() { + const [isPanelOpen, setIsPanelOpen] = useState(false) + const { open: sidebarOpen, setOpen: setSidebarOpen } = useSidebar() + const sidebarWasOpen = useRef(false) + + const openPanel = useCallback(() => { + sidebarWasOpen.current = sidebarOpen + setSidebarOpen(false) + setIsPanelOpen(true) + }, [sidebarOpen, setSidebarOpen]) + + const closePanel = useCallback(() => { + setIsPanelOpen(false) + if (sidebarWasOpen.current) { + setSidebarOpen(true) + } + }, [setSidebarOpen]) + + const togglePanel = useCallback(() => { + if (isPanelOpen) { + closePanel() + } else { + openPanel() + } + }, [closePanel, isPanelOpen, openPanel]) + + return { isPanelOpen, openPanel, closePanel, togglePanel } +} diff --git a/src/hooks/useWellDetails.ts b/src/hooks/useWellDetails.ts new file mode 100644 index 00000000..b429f721 --- /dev/null +++ b/src/hooks/useWellDetails.ts @@ -0,0 +1,83 @@ +import { useCallback, useMemo } from 'react' +import { useDataProvider } from '@refinedev/core' +import { + QueryClient, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import type { IWellDetails } from '@/interfaces/ocotillo' + +export const WELL_DETAILS_QUERY_ROOT = 'well-details' as const + +export type WellDetailsId = string | number | undefined + +const WELL_DETAILS_STALE_TIME_MS = 5 * 60 * 1000 +const WELL_DETAILS_GC_TIME_MS = 10 * 60 * 1000 + +export function wellDetailsQueryKey(id: WellDetailsId) { + return [ + WELL_DETAILS_QUERY_ROOT, + id === undefined || id === null || id === '' ? undefined : String(id), + ] as const +} + +export function invalidateWellDetails( + queryClient: QueryClient, + id: WellDetailsId +) { + if (id === undefined || id === null || id === '') { + return Promise.resolve() + } + + return queryClient.refetchQueries({ + queryKey: wellDetailsQueryKey(id), + }) +} + +export function useWellDetails(id: WellDetailsId) { + const dataProvider = useDataProvider() + const queryClient = useQueryClient() + const ocotilloDataProvider = useMemo( + () => dataProvider('ocotillo'), + [dataProvider] + ) + + const query = useQuery({ + queryKey: wellDetailsQueryKey(id), + enabled: Boolean(id), + staleTime: WELL_DETAILS_STALE_TIME_MS, + gcTime: WELL_DETAILS_GC_TIME_MS, + queryFn: async () => { + const response = await ocotilloDataProvider.custom!({ + url: `thing/water-well/${id}/details`, + method: 'get', + }) + + const data = response.data as IWellDetails + const label = `[ocotillo] GET thing/water-well/${id}/details` + try { + const plain = JSON.parse(JSON.stringify(data)) as IWellDetails + console.log(label, plain) + } catch { + console.log(label, data) + } + console.log( + `${label} (full JSON, scroll or copy this if the object above will not expand)\n${JSON.stringify(data, null, 2)}` + ) + return data + }, + }) + + const invalidate = useCallback(() => { + return invalidateWellDetails(queryClient, id) + }, [queryClient, id]) + + return { + query, + data: query.data, + well: query.data?.well, + isLoading: query.isLoading, + isPending: query.isPending, + invalidateWellDetails: invalidate, + } +} diff --git a/src/pages/example/DataGridPage.tsx b/src/pages/example/DataGridPage.tsx new file mode 100644 index 00000000..039db866 --- /dev/null +++ b/src/pages/example/DataGridPage.tsx @@ -0,0 +1,721 @@ +// TEMPORARY — Glide Data Grid specimen page. Delete when design work is settled. +import { useCallback, useContext, useEffect, useRef, useState } from 'react' +import '@glideapps/glide-data-grid/dist/index.css' +import { + DataEditor, + EditableGridCell, + GridCell, + GridCellKind, + GridColumn, + Item, +} from '@glideapps/glide-data-grid' +import { useList, useNavigation } from '@refinedev/core' +import { ColorModeContext } from '@/contexts' +import type { IWell } from '@/interfaces/ocotillo' +import { displayWellSiteName, formatAppDate } from '@/utils' +import { getContactDisplayName } from '@/utils/contactDisplayName' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Separator } from '@/components/ui/separator' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Checkbox } from '@/components/ui/checkbox' +import { Textarea } from '@/components/ui/textarea' +import { + EditPanel, + EditPanelField, + EditPanelLayout, + EditPanelSection, +} from '@/components/editing' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { + Download, + ExternalLink, + Filter, + Rows3, + Upload, + X, +} from 'lucide-react' + +const COLUMNS: GridColumn[] = [ + { title: 'Well ID', id: 'name', width: 140 }, + { title: 'Site Name', id: 'site_name', width: 200 }, + { title: 'Monitoring', id: 'monitoring_status', width: 160 }, + { title: 'Well Status', id: 'well_status', width: 150 }, + { title: 'Type', id: 'thing_type', width: 130 }, + { title: 'Release Status', id: 'release_status', width: 130 }, + { title: 'Well Depth (ft)', id: 'well_depth', width: 130 }, + { title: 'First Visit', id: 'first_visit_date', width: 120 }, + { title: 'Aquifers', id: 'aquifers', width: 240 }, + { title: 'Contacts', id: 'contacts', width: 240 }, + { title: 'Created', id: 'created_at', width: 120 }, +] + +function getCellValue(well: IWell, colId: string): string { + switch (colId) { + case 'name': return well.name ?? '' + case 'site_name': return displayWellSiteName(well) + case 'monitoring_status': return well.monitoring_status ?? '' + case 'well_status': return well.well_status ?? '' + case 'thing_type': return well.thing_type ?? '' + case 'release_status': return well.release_status ?? '' + case 'well_depth': return well.well_depth != null ? String(well.well_depth) : '' + case 'first_visit_date': return formatAppDate(well.first_visit_date) + case 'aquifers': return well.aquifers?.map(a => a.aquifer_system).join(', ') ?? '' + case 'contacts': return well.contacts?.map(c => getContactDisplayName(c)).join(', ') ?? '' + case 'created_at': return formatAppDate(well.created_at) + default: return '' + } +} + +function CreateWellPanel({ onClose }: { onClose: () => void }) { + return ( + + + + + } + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +