diff --git a/package-lock.json b/package-lock.json index 1e485d9..6b8e824 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@headlessui/react": "^2.2.7", "@heroicons/react": "^2.0.18", "@hookform/resolvers": "^3.9.1", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-dropdown-menu": "^2.0.5", @@ -2734,6 +2735,43 @@ "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", "license": "MIT" }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -2787,6 +2825,66 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", diff --git a/package.json b/package.json index 8459da7..51f4e13 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@headlessui/react": "^2.2.7", "@heroicons/react": "^2.0.18", "@hookform/resolvers": "^3.9.1", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-dropdown-menu": "^2.0.5", diff --git a/src/app/components/building-passport/RecommendationContainer.tsx b/src/app/components/building-passport/RecommendationContainer.tsx index af9dc11..37d0901 100644 --- a/src/app/components/building-passport/RecommendationContainer.tsx +++ b/src/app/components/building-passport/RecommendationContainer.tsx @@ -320,7 +320,7 @@ export default function RecommendationContainer({ console.warn("Multiple funding packages found, using the first one."); } - const [totalFunding, setTotalFunding] = useState(funding[0].projectFunding) + const [totalFunding, setTotalFunding] = useState(funding[0]?.projectFunding || 0) const currentEpcRating = propertyMeta.currentEpcRating; const currentSapPoints = propertyMeta.currentSapPoints; diff --git a/src/app/components/building-passport/Toolbar.tsx b/src/app/components/building-passport/Toolbar.tsx index 57ad05d..2468f41 100644 --- a/src/app/components/building-passport/Toolbar.tsx +++ b/src/app/components/building-passport/Toolbar.tsx @@ -16,10 +16,12 @@ import { NavigationMenuLink, } from "@/app/shadcn_components/ui/navigation-menu"; import { cva } from "class-variance-authority"; +import { getUploadedFile } from "@/app/db/surveyDB/schema/surveyDB"; interface ToolbarProps { propertyId: string; portfolioId: string; + conditionReport: getUploadedFile; } const navigationMenuTriggerStyle = cva( @@ -53,7 +55,7 @@ const navigationMenuTriggerStyle = cva( ].join(" ") ); -export function Toolbar({ propertyId, portfolioId }: ToolbarProps) { +export function Toolbar({ propertyId, portfolioId, conditionReport }: ToolbarProps) { function handleClickSettings() { console.log("Settings were clicked, implement me"); } @@ -61,7 +63,7 @@ export function Toolbar({ propertyId, portfolioId }: ToolbarProps) { const preAssessmentReportButton = ( Data @@ -123,7 +125,7 @@ export function Toolbar({ propertyId, portfolioId }: ToolbarProps) { {solarAnalysisButton} {recommendationsButton} {documentsButton} - {energyAssessmentsReportButton} + {/* {energyAssessmentsReportButton} */} setFundingModalIsOpen(false)} - scheme={funding.scheme} - fundingPackageMeasures={funding.fundingPackageMeasures} + scheme={funding?.scheme} + fundingPackageMeasures={funding?.fundingPackageMeasures || []} /> ); diff --git a/src/app/db/surveyDB/schema/surveyDB.ts b/src/app/db/surveyDB/schema/surveyDB.ts index f8b89da..2f7c92d 100644 --- a/src/app/db/surveyDB/schema/surveyDB.ts +++ b/src/app/db/surveyDB/schema/surveyDB.ts @@ -12,7 +12,7 @@ export const DB_REPORT_TYPES = [ export const docTypeEnum = pgEnum("reporttype", DB_REPORT_TYPES); -export const uploaded_files = pgTable("uploaded_files", { +export const uploadedFiles = pgTable("uploaded_files", { id: uuid("id").primaryKey().defaultRandom(), s3JsonUri: text("s3_json_uri"), @@ -26,6 +26,6 @@ export const uploaded_files = pgTable("uploaded_files", { uprn: text("uprn").notNull(), }); -export type getUploadedFile = typeof uploaded_files.$inferSelect +export type getUploadedFile = typeof uploadedFiles.$inferSelect export type getUploadedFiles = getUploadedFile[]; diff --git a/src/app/portfolio/[slug]/(portfolio)/summary/page.tsx b/src/app/portfolio/[slug]/(portfolio)/summary/page.tsx index f73f867..ced33f4 100644 --- a/src/app/portfolio/[slug]/(portfolio)/summary/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/summary/page.tsx @@ -17,8 +17,6 @@ export default async function PortfolioSummary( // Get user id from the session const scenarios = await getNonDefaultPortfolioScenarios(portfolioId); - console.log(data) - return (

Summary

diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/ConditionReport.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/ConditionReport.tsx new file mode 100644 index 0000000..ddd0afa --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/ConditionReport.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { + Card, + CardContent, + CardHeader, +} from "@/app/shadcn_components/ui/card"; +import { + CheckCircle, + XCircle, + HelpCircle, +} from "lucide-react"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/app/shadcn_components/ui/accordion"; +import clsx from "clsx"; + +function ChecklistItem({ + label, + passed, + note, + alert = false, + roomsWithIssues = [], +}: { + label: string; + passed?: boolean; + note?: string; + alert?: boolean; + roomsWithIssues?: [string, any][]; +}) { + const icon = passed === true ? ( + + ) : passed === false ? ( + + ) : ( + + ); + + return ( +
+
+ {icon} + + {label} + {note && ( + + ({note}) + + )} + +
+ + {roomsWithIssues.length > 0 && ( + + {roomsWithIssues.map(([roomName, room]: any) => ( + + {formatRoomName(roomName)} + +
+ {room.room_info?.overall_condition_of_the_room && ( +
+ Condition: {room.room_info.overall_condition_of_the_room} +
+ )} + {room.room_info?.does_the_room_have_any_defects && ( +
+ Defects: {room.room_info.does_the_room_have_any_defects} +
+ )} + {room.room_info?.ventilation_info + ?.are_there_any_visible_or_reported_signs_of_damp_mould_or_excessive_condensation_within_the_room !== + undefined && ( +
+ Damp/Mould:{" "} + {room.room_info.ventilation_info + .are_there_any_visible_or_reported_signs_of_damp_mould_or_excessive_condensation_within_the_room + ? "Yes" + : "No"} +
+ )} + {room.room_info?.windows_info?.condition_of_the_windows && ( +
+ Window Condition:{" "} + {room.room_info.windows_info.condition_of_the_windows} +
+ )} +
+
+
+ ))} +
+ )} +
+ ); +} + +function formatRoomName(name: string) { + return name + .replaceAll("_", " ") + .replace(/\b\w/g, (l) => l.toUpperCase()) + .replace("Room Info", "Room"); +} + +function getRecommendedOccupants(bedrooms: number): number { + if (bedrooms <= 0) return 0; + if (bedrooms === 1) return 2; + if (bedrooms === 2) return 4; + if (bedrooms === 3) return 6; + return 7; // 4 or more +} + +export default function ConditionReport({ + conditionReport, + totalFloorArea +}: { + conditionReport: { + rooms: Record; + [key: string]: any; + }, + totalFloorArea: number; +}) { + + // Documentation on decent home standards can be found here: + // https://assets.publishing.service.gov.uk/media/5a7968b740f0b63d72fc5926/138355.pdf + + const rooms = conditionReport.rooms; + + const allRoomData = [ + ...Object.entries(rooms).filter(([k, v]) => v?.room_info), + ...(rooms.bedrooms || []).map((b: any, i: number) => ["Bedroom " + (i + 1), b]), + ...(rooms.bathrooms || []).map((b: any, i: number) => ["Bathroom " + (i + 1), b]), + ]; + + const hasDampIssues = allRoomData.some( + ([, room]: any) => + room.room_info?.ventilation_info + ?.are_there_any_visible_or_reported_signs_of_damp_mould_or_excessive_condensation_within_the_room + ); + + const hasDefects = allRoomData.some( + ([, room]: any) => room.room_info?.does_the_room_have_any_defects === "Yes" + ); + + const windowsOk = allRoomData.every(([, room]: any) => { + const wi = room.room_info?.windows_info; + return wi?.does_the_room_have_any_windows + ? wi?.condition_of_the_windows === "Good condition" + : true; + }); + + const heatingWorking = + conditionReport.heating_system?.general_condition + ?.is_the_heating_system_in_working_order === true; + + const kitchenOk = rooms.kitchen?.room_info?.overall_condition_of_the_room === "Good"; + + const bathroomsOk = Array.isArray(rooms.bathrooms) && + rooms.bathrooms.length > 0 && + rooms.bathrooms.every( + (b: any) => + b?.room_info?.overall_condition_of_the_room === "Good" + ) + + const roomsWithDefects = allRoomData.filter( + ([, room]: any) => room.room_info?.does_the_room_have_any_defects === "Yes" + ); + + const roomsWithDamp = allRoomData.filter( + ([, room]: any) => + room.room_info?.ventilation_info + ?.are_there_any_visible_or_reported_signs_of_damp_mould_or_excessive_condensation_within_the_room + ); + + const roomsWithBadWindows = allRoomData.filter( + ([, room]: any) => { + const wi = room.room_info?.windows_info; + return wi?.does_the_room_have_any_windows && wi.condition_of_the_windows !== "Good condition"; + } + ); + + // Check if the property has adequate space + // We've seen a case where the number of adult occupants + child occupants is greater than the total_number_of_occupants so we + // take the biggest of the two + const totalOccupants = conditionReport.occupant_info?.total_number_of_occupants ?? 0; + const totalAdults = conditionReport.occupant_info?.no_of_adult_occupants ?? 0; + const totalChildren = conditionReport.occupant_info?.no_of_child_occupants ?? 0; + const numberOfBedrooms = Array.isArray(rooms.bedrooms) + ? rooms.bedrooms.length + : 0; + + const occupantsToUse = Math.max(totalAdults + totalChildren, totalOccupants); + + const maxOccupants = getRecommendedOccupants(numberOfBedrooms); + const areaPerPerson = totalFloorArea / occupantsToUse; + const hasSufficientSpace = (occupantsToUse <= maxOccupants) && (areaPerPerson >= 20); + + return ( +
+ + + Decent Homes Checklist + + + 0} + roomsWithIssues={roomsWithDamp} + /> + + + + + + + + + + +
+ ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/pre-assessment-report/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx similarity index 86% rename from src/app/portfolio/[slug]/building-passport/[propertyId]/pre-assessment-report/page.tsx rename to src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx index 5bd18f7..ddce32a 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/pre-assessment-report/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx @@ -20,16 +20,10 @@ import { getPropertyMeta, getSpatialData, getNonIntrusiveSurvey, + getDocument, + getEnergyAssessmentFromS3 } from "../utils"; - -function AddressCard({ address }: { address: string | null }) { - // In the future, we might want to use react-wrap-balancer for some of this text - return ( -
-
{address}
-
- ); -} +import ConditionReport from "@/app/portfolio/[slug]/building-passport/[propertyId]/assessment/ConditionReport"; interface PropertyDetailsCardProps { conditionReportData: PropertyDetailsEpc; @@ -141,6 +135,15 @@ export default async function PreAssessmentReport( conditionReportData, propertyMeta.propertyType ); + const conditionReportMeta = await getDocument( + { uprn: String(propertyMeta.uprn), documentType: "ECO_CONDITION_REPORT" } + ); + let conditionReport = { rooms: {} }; + if (conditionReportMeta && conditionReportMeta.s3JsonUri) { + conditionReport = await getEnergyAssessmentFromS3(conditionReportMeta.s3JsonUri); + } + + // console.log("conditionReport", conditionReport.rooms.utility) const nonIntrusiveSurvey = await getNonIntrusiveSurvey(propertyMeta.uprn); @@ -148,10 +151,15 @@ export default async function PreAssessmentReport( const heatingDemand = formatHeatDemandFeatures(conditionReportData); + // If total floor area is missing, we have a problem + if (conditionReportData.totalFloorArea == null) { + console.error("Total floor area is missing"); + return null; + } + return (
-
Pre Assessment Report
-
+
Last updated: {formatDateTime(propertyMeta.updatedAt)}
@@ -169,6 +177,10 @@ export default async function PreAssessmentReport(
+ { + Object.keys(conditionReportMeta).length > 0 && + } + {nonIntrusiveSurvey && (
Non-Intrusive Survey
diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx index 6811e87..1ded30a 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx @@ -2,16 +2,15 @@ import { getPropertyMeta } from "@/app/portfolio/[slug]/building-passport/[prope import { eq } from "drizzle-orm"; import { DocumentsTable } from "./DocumentsTable"; import { surveyDB } from "@/app/db/surveyDB/connection"; -import { uploaded_files } from "@/app/db/surveyDB/schema/surveyDB"; +import { uploadedFiles } from "@/app/db/surveyDB/schema/surveyDB"; import { type getUploadedFiles } from "@/app/db/surveyDB/schema/surveyDB"; -import { EmptyObject } from "react-hook-form"; async function getDocuments( uprn: number ): Promise< getUploadedFiles> { - const result = surveyDB.query.uploaded_files.findMany({ - where: eq(uploaded_files.uprn, String(uprn)), + const result = surveyDB.query.uploadedFiles.findMany({ + where: eq(uploadedFiles.uprn, String(uprn)), }); return result; diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx index 02686b0..4a313f1 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx @@ -1,5 +1,5 @@ import { Toolbar } from "@/app/components/building-passport/Toolbar"; -import { getPropertyMeta } from "./utils"; +import { getPropertyMeta, getDocument } from "./utils"; import BackToPortfolioButton from "@/app/components/building-passport/BackToPortfolioButton"; import { ExclamationCircleIcon } from "@heroicons/react/24/outline"; @@ -30,6 +30,11 @@ export default async function DashboardLayout( // The layout is a server component by default so we can fetch meta data here const propertyMeta = await getPropertyMeta(params.propertyId); + // We check if we have an uploaded condition report and if so, we show the condition tab. Otherwise, we + // don't show it + const conditionReport = await getDocument( + { uprn: String(propertyMeta.uprn), documentType: "ECO_CONDITION_REPORT" } + ); if (!propertyId && propertyId !== "0") { throw Error("Invalid propertyId"); @@ -53,7 +58,7 @@ export default async function DashboardLayout(

{propertyMeta.postcode}

- +
{propertyMeta.detailsEpc.estimated && } {children} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts b/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts index d737e33..d872408 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts @@ -1,3 +1,4 @@ +import S3 from "aws-sdk/clients/s3"; import { Recommendation, planRecommendations, @@ -5,6 +6,7 @@ import { Plan, } from "@/app/db/schema/recommendations"; import { db } from "@/app/db/db"; +import { surveyDB } from "@/app/db/surveyDB/connection"; import { Feature, GeneralFeature, @@ -17,7 +19,7 @@ import { nonInstrusiveSurvey, } from "@/app/db/schema/property"; import { getRating } from "@/app/utils"; -import { eq, desc } from "drizzle-orm"; +import { eq, desc, and } from "drizzle-orm"; import { energyAssessment, EnergyAssessment, @@ -26,9 +28,38 @@ import { } from "@/app/db/schema/energy_assessments"; import { fundingPackage, - FundingPackage, FundingPackageWithMeasures } from "@/app/db/schema/funding"; +import { getUploadedFile, uploadedFiles, DB_REPORT_TYPES } from "@/app/db/surveyDB/schema/surveyDB"; + + +export async function getEnergyAssessmentFromS3(s3Uri: string): Promise { + const url = new URL(s3Uri); + + const bucketMatch = url.hostname.match(/^(.+)\.s3/); + const bucket = bucketMatch?.[1]; + const key = url.pathname.startsWith("/") ? url.pathname.slice(1) : url.pathname; + + if (!bucket || !key) { + throw new Error("Could not extract bucket or key from URI"); + } + + const s3 = new S3({ + region: "eu-west-2", + accessKeyId: process.env.RETROFIT_ENERGY_ASSESSMENTS_AWS_ACCESS_KEY, + secretAccessKey: process.env.ENERGY_ASSESSMENTS_AWS_SECRET, + }); + + const result = await s3 + .getObject({ + Bucket: bucket, + Key: key, + }) + .promise(); + + const body = result.Body?.toString("utf-8"); + return body ? JSON.parse(body) : null; +} type RecommendationList = { recommendation: Recommendation; @@ -49,6 +80,21 @@ export async function getPlanFunding(planId: string): Promise { + // We get the latest entry for the given UPRN and document type, by s3JsonUploadTimestamp + const data = await surveyDB.query.uploadedFiles.findFirst({ + where: and(eq(uploadedFiles.uprn, String(uprn)), eq(uploadedFiles.docType, documentType)), + orderBy: (uploadedFiles, { desc }) => [desc(uploadedFiles.s3JsonUploadTimestamp)] + }); + // We may not have an uploaded document so we return an empty array + if (!data) { + return {} as getUploadedFile; + } + return data; +} + export async function getEnergyAssessment( uprn: number ): Promise { diff --git a/src/app/shadcn_components/ui/accordion.tsx b/src/app/shadcn_components/ui/accordion.tsx new file mode 100644 index 0000000..7511978 --- /dev/null +++ b/src/app/shadcn_components/ui/accordion.tsx @@ -0,0 +1,58 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from '@/lib/utils' + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/tailwind.config.js b/tailwind.config.js index f0f0811..b35a54e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -12,193 +12,227 @@ module.exports = { "./node_modules/@tremor/**/*.{js,ts,jsx,tsx}", ], theme: { - transparent: "transparent", - current: "currentColor", - container: { - center: true, - padding: "2rem", - screens: { - "2xl": "1400px", - }, - }, - extend: { - backgroundImage: { - "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", - "gradient-conic": - "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", - }, - colors: { - // Tremor light mode - tremor: { - brand: { - faint: colors.blue[50], - muted: colors.blue[200], - subtle: colors.blue[400], - DEFAULT: colors.blue[500], - emphasis: colors.blue[700], - inverted: colors.white, - }, - background: { - muted: colors.gray[50], - subtle: colors.gray[100], - DEFAULT: colors.white, - emphasis: colors.gray[700], - }, - border: { - DEFAULT: colors.gray[200], - }, - ring: { - DEFAULT: colors.gray[200], - }, - content: { - subtle: colors.gray[400], - DEFAULT: colors.gray[500], - emphasis: colors.gray[700], - strong: colors.gray[900], - inverted: colors.white, - }, - }, - // Tremor dark mode - // dark mode - "dark-tremor": { - brand: { - faint: "#0B1229", - muted: colors.blue[950], - subtle: colors.blue[800], - DEFAULT: colors.blue[500], - emphasis: colors.blue[400], - inverted: colors.blue[950], - }, - background: { - muted: "#131A2B", - subtle: colors.gray[800], - DEFAULT: colors.gray[900], - emphasis: colors.gray[300], - }, - border: { - DEFAULT: colors.gray[800], - }, - ring: { - DEFAULT: colors.gray[800], - }, - content: { - subtle: colors.gray[600], - DEFAULT: colors.gray[500], - emphasis: colors.gray[200], - strong: colors.gray[50], - inverted: colors.gray[950], - }, - }, - epc_a: "#117d58", - epc_b: "#2da55c", - epc_c: "#8dbd40", - epc_d: "#f7cd14", - epc_e: "#f3a96a", - epc_f: "#ef8026", - epc_g: "#e41e3b", - brandblue: "#14163d", - hoverblue: "#3e4073", - brandtan: "#d3b488", - hovertan: "#947750", - brandgold: "#f1bb06", - hovergold: "#c79d12", - brandbrown: "#c4a47c", - brandmidblue: "#3943b7", - brandlightblue: "#00a9f4", - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - primary: { - DEFAULT: "hsl(var(--primary))", - foreground: "hsl(var(--primary-foreground))", - }, - secondary: { - DEFAULT: "hsl(var(--secondary))", - foreground: "hsl(var(--secondary-foreground))", - }, - destructive: { - DEFAULT: "hsl(var(--destructive))", - foreground: "hsl(var(--destructive-foreground))", - }, - muted: { - DEFAULT: "hsl(var(--muted))", - foreground: "hsl(var(--muted-foreground))", - }, - accent: { - DEFAULT: "hsl(var(--accent))", - foreground: "hsl(var(--accent-foreground))", - }, - popover: { - DEFAULT: "hsl(var(--popover))", - foreground: "hsl(var(--popover-foreground))", - }, - card: { - DEFAULT: "hsl(var(--card))", - foreground: "hsl(var(--card-foreground))", - }, - }, - textColor: { - brandblue: "#14163d", - hoverblue: "#3e4073", - brandtan: "#d3b488", - hovertan: "#947750", - brandbrown: "#c4a47c", - brandmidblue: "#3943b7", - brandlightblue: "#00a9f4", - }, - borderRadius: { - lg: `var(--radius)`, - md: `calc(var(--radius) - 2px)`, - sm: "calc(var(--radius) - 4px)", - }, - fontFamily: { - sans: ["var(--font-sans)", ...fontFamily.sans], - }, - keyframes: { - "accordion-down": { - from: { height: 0 }, - to: { height: "var(--radix-accordion-content-height)" }, - }, - "accordion-up": { - from: { height: "var(--radix-accordion-content-height)" }, - to: { height: 0 }, - }, - }, - animation: { - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", - }, - maxWidth: { - "8xl": "90rem", - }, - boxShadow: { - // light - "tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)", - "tremor-card": - "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", - "tremor-dropdown": - "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", - // dark - "dark-tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)", - "dark-tremor-card": - "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", - "dark-tremor-dropdown": - "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", - }, - borderRadius: { - "tremor-small": "0.375rem", - "tremor-default": "0.5rem", - "tremor-full": "9999px", - }, - fontSize: { - "tremor-label": ["0.75rem", { lineHeight: "1rem" }], - "tremor-default": ["0.875rem", { lineHeight: "1.25rem" }], - "tremor-title": ["1.125rem", { lineHeight: "1.75rem" }], - "tremor-metric": ["1.875rem", { lineHeight: "2.25rem" }], - }, - }, + transparent: 'transparent', + current: 'currentColor', + container: { + center: true, + padding: '2rem', + screens: { + '2xl': '1400px' + } + }, + extend: { + backgroundImage: { + 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', + 'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))' + }, + colors: { + tremor: { + brand: { + faint: 'colors.blue[50]', + muted: 'colors.blue[200]', + subtle: 'colors.blue[400]', + DEFAULT: 'colors.blue[500]', + emphasis: 'colors.blue[700]', + inverted: 'colors.white' + }, + background: { + muted: 'colors.gray[50]', + subtle: 'colors.gray[100]', + DEFAULT: 'colors.white', + emphasis: 'colors.gray[700]' + }, + border: { + DEFAULT: 'colors.gray[200]' + }, + ring: { + DEFAULT: 'colors.gray[200]' + }, + content: { + subtle: 'colors.gray[400]', + DEFAULT: 'colors.gray[500]', + emphasis: 'colors.gray[700]', + strong: 'colors.gray[900]', + inverted: 'colors.white' + } + }, + 'dark-tremor': { + brand: { + faint: '#0B1229', + muted: 'colors.blue[950]', + subtle: 'colors.blue[800]', + DEFAULT: 'colors.blue[500]', + emphasis: 'colors.blue[400]', + inverted: 'colors.blue[950]' + }, + background: { + muted: '#131A2B', + subtle: 'colors.gray[800]', + DEFAULT: 'colors.gray[900]', + emphasis: 'colors.gray[300]' + }, + border: { + DEFAULT: 'colors.gray[800]' + }, + ring: { + DEFAULT: 'colors.gray[800]' + }, + content: { + subtle: 'colors.gray[600]', + DEFAULT: 'colors.gray[500]', + emphasis: 'colors.gray[200]', + strong: 'colors.gray[50]', + inverted: 'colors.gray[950]' + } + }, + epc_a: '#117d58', + epc_b: '#2da55c', + epc_c: '#8dbd40', + epc_d: '#f7cd14', + epc_e: '#f3a96a', + epc_f: '#ef8026', + epc_g: '#e41e3b', + brandblue: '#14163d', + hoverblue: '#3e4073', + brandtan: '#d3b488', + hovertan: '#947750', + brandgold: '#f1bb06', + hovergold: '#c79d12', + brandbrown: '#c4a47c', + brandmidblue: '#3943b7', + brandlightblue: '#00a9f4', + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))' + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))' + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))' + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))' + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))' + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + } + }, + textColor: { + brandblue: '#14163d', + hoverblue: '#3e4073', + brandtan: '#d3b488', + hovertan: '#947750', + brandbrown: '#c4a47c', + brandmidblue: '#3943b7', + brandlightblue: '#00a9f4' + }, + borderRadius: { + 'tremor-small': '0.375rem', + 'tremor-default': '0.5rem', + 'tremor-full': '9999px' + }, + fontFamily: { + sans: [ + 'var(--font-sans)', + ...fontFamily.sans + ] + }, + keyframes: { + 'accordion-down': { + from: { + height: 0 + }, + to: { + height: 'var(--radix-accordion-content-height)' + } + }, + 'accordion-up': { + from: { + height: 'var(--radix-accordion-content-height)' + }, + to: { + height: 0 + } + }, + 'accordion-down': { + from: { + height: '0' + }, + to: { + height: 'var(--radix-accordion-content-height)' + } + }, + 'accordion-up': { + from: { + height: 'var(--radix-accordion-content-height)' + }, + to: { + height: '0' + } + } + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out' + }, + maxWidth: { + '8xl': '90rem' + }, + boxShadow: { + 'tremor-input': '0 1px 2px 0 rgb(0 0 0 / 0.05)', + 'tremor-card': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', + 'tremor-dropdown': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', + 'dark-tremor-input': '0 1px 2px 0 rgb(0 0 0 / 0.05)', + 'dark-tremor-card': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', + 'dark-tremor-dropdown': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)' + }, + fontSize: { + 'tremor-label': [ + '0.75rem', + { + lineHeight: '1rem' + } + ], + 'tremor-default': [ + '0.875rem', + { + lineHeight: '1.25rem' + } + ], + 'tremor-title': [ + '1.125rem', + { + lineHeight: '1.75rem' + } + ], + 'tremor-metric': [ + '1.875rem', + { + lineHeight: '2.25rem' + } + ] + } + } }, variants: { extend: { @@ -234,28 +268,90 @@ module.exports = { /^(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, }, // This enables the EPC colours for tremor. They're listed from EPC G -> A - ...[ - "[#e41e3b]", - "[#ef8026]", - "[#f3a96a]", - "[#f7cd14]", - "[#8dbd40]", - "[#2da55c]", - "[#117d58]", - ].flatMap((customColor) => [ - `bg-${customColor}`, - `border-${customColor}`, - `hover:bg-${customColor}`, - `hover:border-${customColor}`, - `hover:text-${customColor}`, - `fill-${customColor}`, - `ring-${customColor}`, - `stroke-${customColor}`, - `text-${customColor}`, - `ui-selected:bg-${customColor}`, - `ui-selected:border-${customColor}`, - `ui-selected:text-${customColor}`, - ]), + "bg-[#e41e3b]", + "border-[#e41e3b]", + "hover:bg-[#e41e3b]", + "hover:border-[#e41e3b]", + "hover:text-[#e41e3b]", + "fill-[#e41e3b]", + "ring-[#e41e3b]", + "stroke-[#e41e3b]", + "text-[#e41e3b]", + "ui-selected:bg-[#e41e3b]", + "ui-selected:border-[#e41e3b]", + "ui-selected:text-[#e41e3b]", + "bg-[#ef8026]", + "border-[#ef8026]", + "hover:bg-[#ef8026]", + "hover:border-[#ef8026]", + "hover:text-[#ef8026]", + "fill-[#ef8026]", + "ring-[#ef8026]", + "stroke-[#ef8026]", + "text-[#ef8026]", + "ui-selected:bg-[#ef8026]", + "ui-selected:border-[#ef8026]", + "ui-selected:text-[#ef8026]", + "bg-[#f3a96a]", + "border-[#f3a96a]", + "hover:bg-[#f3a96a]", + "hover:border-[#f3a96a]", + "hover:text-[#f3a96a]", + "fill-[#f3a96a]", + "ring-[#f3a96a]", + "stroke-[#f3a96a]", + "text-[#f3a96a]", + "ui-selected:bg-[#f3a96a]", + "ui-selected:border-[#f3a96a]", + "ui-selected:text-[#f3a96a]", + "bg-[#f7cd14]", + "border-[#f7cd14]", + "hover:bg-[#f7cd14]", + "hover:border-[#f7cd14]", + "hover:text-[#f7cd14]", + "fill-[#f7cd14]", + "ring-[#f7cd14]", + "stroke-[#f7cd14]", + "text-[#f7cd14]", + "ui-selected:bg-[#f7cd14]", + "ui-selected:border-[#f7cd14]", + "ui-selected:text-[#f7cd14]", + "bg-[#8dbd40]", + "border-[#8dbd40]", + "hover:bg-[#8dbd40]", + "hover:border-[#8dbd40]", + "hover:text-[#8dbd40]", + "fill-[#8dbd40]", + "ring-[#8dbd40]", + "stroke-[#8dbd40]", + "text-[#8dbd40]", + "ui-selected:bg-[#8dbd40]", + "ui-selected:border-[#8dbd40]", + "ui-selected:text-[#8dbd40]", + "bg-[#2da55c]", + "border-[#2da55c]", + "hover:bg-[#2da55c]", + "hover:border-[#2da55c]", + "hover:text-[#2da55c]", + "fill-[#2da55c]", + "ring-[#2da55c]", + "stroke-[#2da55c]", + "text-[#2da55c]", + "ui-selected:bg-[#2da55c]", + "ui-selected:border-[#2da55c]", + "ui-selected:text-[#2da55c]", + "bg-[#117d58]", + "border-[#117d58]", + "hover:bg-[#117d58]", + "hover:border-[#117d58]", + "hover:text-[#117d58]", + "fill-[#117d58]", + "ring-[#117d58]", + "stroke-[#117d58]", + "text-[#117d58]", + "ui-selected:bg-[#117d58]", + "ui-selected:border-[#117d58]", + "ui-selected:text-[#117d58]", ], plugins: [ function ({ addVariant }) {