mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
added missing files
This commit is contained in:
parent
c9adaab234
commit
6339614720
5 changed files with 617 additions and 0 deletions
235
src/app/actions/recommendations.ts
Normal file
235
src/app/actions/recommendations.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
"use server";
|
||||
import { db } from "@/app/db/db";
|
||||
import {
|
||||
recommendation,
|
||||
planRecommendations,
|
||||
plan,
|
||||
} from "@/app/db/schema/recommendations";
|
||||
import { eq, inArray, and } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
// Maps specific recommendation types to their parent category.
|
||||
// Mirrors the categorisation in RecommendationContainer.
|
||||
const typeToCategoryMap: Record<string, string> = {
|
||||
internal_wall_insulation: "wall_insulation",
|
||||
external_wall_insulation: "wall_insulation",
|
||||
cavity_wall_insulation: "wall_insulation",
|
||||
loft_insulation: "roof_insulation",
|
||||
room_roof_insulation: "roof_insulation",
|
||||
flat_roof_insulation: "roof_insulation",
|
||||
suspended_floor_insulation: "floor_insulation",
|
||||
solid_floor_insulation: "floor_insulation",
|
||||
exposed_floor_insulation: "floor_insulation",
|
||||
};
|
||||
|
||||
function getCategoryTypes(type: string): string[] {
|
||||
const category = typeToCategoryMap[type] ?? type;
|
||||
const types = Object.entries(typeToCategoryMap)
|
||||
.filter(([, cat]) => cat === category)
|
||||
.map(([t]) => t);
|
||||
if (!types.includes(category)) types.push(category);
|
||||
return types;
|
||||
}
|
||||
|
||||
// Per-measure contingency rates (fractional, e.g. 0.26 = 26%)
|
||||
const CONTINGENCIES: Record<string, number> = {
|
||||
cavity_wall_insulation: 0.1,
|
||||
internal_wall_insulation: 0.26,
|
||||
external_wall_insulation: 0.26,
|
||||
loft_insulation: 0.1,
|
||||
solar_pv: 0.15,
|
||||
air_source_heat_pump: 0.25,
|
||||
flat_roof_insulation: 0.26,
|
||||
suspended_floor_insulation: 0.2,
|
||||
solid_floor_insulation: 0.26,
|
||||
low_energy_lighting: 0.26,
|
||||
high_heat_retention_storage_heaters: 0.1,
|
||||
windows_glazing: 0.15,
|
||||
boiler_upgrade: 0.26,
|
||||
time_and_temperature_zone_control: 0.1,
|
||||
roomstat_programmer_trvs: 0.1,
|
||||
room_roof_insulation: 0.26,
|
||||
heater_removal: 0.1,
|
||||
sealing_open_fireplace: 0.1,
|
||||
mechanical_ventilation: 0.26,
|
||||
sloping_ceiling_insulation: 0.26,
|
||||
};
|
||||
|
||||
// Local SAP → EPC letter mapping (mirrors sapToEpc in @/app/utils)
|
||||
function sapToEpcLetter(sapPoints: number): string {
|
||||
if (sapPoints >= 92) return "A";
|
||||
if (sapPoints >= 81) return "B";
|
||||
if (sapPoints >= 69) return "C";
|
||||
if (sapPoints >= 55) return "D";
|
||||
if (sapPoints >= 39) return "E";
|
||||
if (sapPoints >= 21) return "F";
|
||||
return "G";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a recommendation as the default for its category within a plan.
|
||||
* Clears the default flag from all other recommendations in the same category,
|
||||
* then sets it on the selected recommendation.
|
||||
*/
|
||||
export async function setDefaultRecommendation(
|
||||
planId: string,
|
||||
selectedRecId: string,
|
||||
slug: string,
|
||||
propertyId: string,
|
||||
options?: { skipRevalidate?: boolean },
|
||||
) {
|
||||
const planRecs = await db
|
||||
.select({ recId: planRecommendations.recommendationId })
|
||||
.from(planRecommendations)
|
||||
.where(eq(planRecommendations.planId, BigInt(planId)));
|
||||
|
||||
const recIds = planRecs.map((r) => r.recId);
|
||||
if (recIds.length === 0) return;
|
||||
|
||||
const [selectedRec] = await db
|
||||
.select({ type: recommendation.type })
|
||||
.from(recommendation)
|
||||
.where(eq(recommendation.id, BigInt(selectedRecId)));
|
||||
|
||||
if (!selectedRec) return;
|
||||
|
||||
const categoryTypes = getCategoryTypes(selectedRec.type);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(recommendation)
|
||||
.set({ default: false })
|
||||
.where(
|
||||
and(
|
||||
inArray(recommendation.id, recIds),
|
||||
inArray(recommendation.type, categoryTypes),
|
||||
),
|
||||
);
|
||||
await tx
|
||||
.update(recommendation)
|
||||
.set({ default: true })
|
||||
.where(eq(recommendation.id, BigInt(selectedRecId)));
|
||||
});
|
||||
|
||||
if (!options?.skipRevalidate) {
|
||||
revalidatePath(
|
||||
`/portfolio/${slug}/building-passport/${propertyId}/plans/${planId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the default flag from every recommendation in a category for this plan.
|
||||
* Used to remove a measure entirely from the plan.
|
||||
*/
|
||||
export async function clearCategoryDefault(
|
||||
planId: string,
|
||||
categoryType: string,
|
||||
slug: string,
|
||||
propertyId: string,
|
||||
options?: { skipRevalidate?: boolean },
|
||||
) {
|
||||
const planRecs = await db
|
||||
.select({ recId: planRecommendations.recommendationId })
|
||||
.from(planRecommendations)
|
||||
.where(eq(planRecommendations.planId, BigInt(planId)));
|
||||
|
||||
const recIds = planRecs.map((r) => r.recId);
|
||||
if (recIds.length === 0) return;
|
||||
|
||||
const categoryTypes = getCategoryTypes(categoryType);
|
||||
|
||||
await db
|
||||
.update(recommendation)
|
||||
.set({ default: false })
|
||||
.where(
|
||||
and(
|
||||
inArray(recommendation.id, recIds),
|
||||
inArray(recommendation.type, categoryTypes),
|
||||
),
|
||||
);
|
||||
|
||||
if (!options?.skipRevalidate) {
|
||||
revalidatePath(
|
||||
`/portfolio/${slug}/building-passport/${propertyId}/plans/${planId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculates and persists plan-level metrics based on the current set of
|
||||
* default recommendations. Contingency is calculated per-measure using
|
||||
* measure-specific rates.
|
||||
*/
|
||||
export async function updatePlanMetrics(
|
||||
planId: string,
|
||||
currentSapPoints: number,
|
||||
slug: string,
|
||||
propertyId: string,
|
||||
) {
|
||||
const planRecs = await db
|
||||
.select({ recId: planRecommendations.recommendationId })
|
||||
.from(planRecommendations)
|
||||
.where(eq(planRecommendations.planId, BigInt(planId)));
|
||||
|
||||
const recIds = planRecs.map((r) => r.recId);
|
||||
|
||||
const defaultRecs =
|
||||
recIds.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(recommendation)
|
||||
.where(
|
||||
and(
|
||||
inArray(recommendation.id, recIds),
|
||||
eq(recommendation.default, true),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
|
||||
const costOfWorks = defaultRecs.reduce(
|
||||
(s, r) => s + (r.estimatedCost ?? 0),
|
||||
0,
|
||||
);
|
||||
const contingencyCost = defaultRecs.reduce((s, r) => {
|
||||
const rate = CONTINGENCIES[r.type] ?? 0.125;
|
||||
return s + (r.estimatedCost ?? 0) * rate;
|
||||
}, 0);
|
||||
const co2Savings = defaultRecs.reduce(
|
||||
(s, r) => s + (r.co2EquivalentSavings ?? 0),
|
||||
0,
|
||||
);
|
||||
const energyBillSavings = defaultRecs.reduce(
|
||||
(s, r) => s + (r.energyCostSavings ?? 0),
|
||||
0,
|
||||
);
|
||||
const sapPointsGain = defaultRecs.reduce(
|
||||
(s, r) => s + (r.sapPoints ?? 0),
|
||||
0,
|
||||
);
|
||||
const postSapPoints = currentSapPoints + sapPointsGain;
|
||||
const postEpcRating = sapToEpcLetter(postSapPoints) as
|
||||
| "A"
|
||||
| "B"
|
||||
| "C"
|
||||
| "D"
|
||||
| "E"
|
||||
| "F"
|
||||
| "G";
|
||||
|
||||
await db
|
||||
.update(plan)
|
||||
.set({
|
||||
costOfWorks,
|
||||
contingencyCost,
|
||||
co2Savings,
|
||||
energyBillSavings,
|
||||
postSapPoints,
|
||||
postEpcRating,
|
||||
})
|
||||
.where(eq(plan.id, BigInt(planId)));
|
||||
|
||||
revalidatePath(
|
||||
`/portfolio/${slug}/building-passport/${propertyId}/plans/${planId}`,
|
||||
);
|
||||
}
|
||||
110
src/app/components/building-passport/AlternativesDrawer.tsx
Normal file
110
src/app/components/building-passport/AlternativesDrawer.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
import { formatNumber } from "@/app/utils";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/app/shadcn_components/ui/drawer";
|
||||
import { CheckIcon } from "@heroicons/react/24/outline";
|
||||
import type { AugmentedRec } from "./recommendation-types";
|
||||
|
||||
interface AlternativesDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
categoryLabel: string;
|
||||
recs: AugmentedRec[];
|
||||
selected: AugmentedRec | null;
|
||||
displaySelected: AugmentedRec | null;
|
||||
onSelect: (rec: AugmentedRec) => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
export default function AlternativesDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
categoryLabel,
|
||||
recs,
|
||||
displaySelected,
|
||||
onSelect,
|
||||
isPending,
|
||||
}: AlternativesDrawerProps) {
|
||||
const alternatives = recs.filter((r) => !r.alreadyInstalled);
|
||||
|
||||
return (
|
||||
<Drawer open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DrawerContent className="max-h-[80vh]">
|
||||
<DrawerHeader className="px-6 pt-4 pb-3 border-b border-gray-100">
|
||||
<DrawerTitle className="font-manrope text-xl font-bold text-brandblue">
|
||||
{categoryLabel}
|
||||
</DrawerTitle>
|
||||
<p className="text-sm text-gray-400 mt-0.5">
|
||||
{alternatives.length} option
|
||||
{alternatives.length !== 1 ? "s" : ""} available — select to update
|
||||
the plan
|
||||
</p>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="overflow-y-auto px-6 py-4 space-y-2 pb-10">
|
||||
{alternatives.map((rec) => {
|
||||
const isSelected = displaySelected?.id === rec.id;
|
||||
return (
|
||||
<button
|
||||
key={String(rec.id)}
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
onSelect(rec);
|
||||
onClose();
|
||||
}}
|
||||
className={`w-full text-left p-5 rounded-xl border transition-colors ${
|
||||
isSelected
|
||||
? "border-brandblue bg-brandblue/5"
|
||||
: "border-gray-100 bg-white hover:bg-gray-50"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||
<div
|
||||
className={`mt-0.5 w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-colors ${
|
||||
isSelected
|
||||
? "border-brandblue bg-brandblue"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<CheckIcon className="w-3 h-3 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-brandblue text-sm leading-snug">
|
||||
{rec.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-1.5">
|
||||
{rec.sapPoints !== null && rec.sapPoints > 0 && (
|
||||
<span className="text-xs text-gray-400 font-medium">
|
||||
SAP +{rec.sapPoints.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
{rec.energyCostSavings !== null &&
|
||||
rec.energyCostSavings > 0 && (
|
||||
<span className="text-xs text-gray-400 font-medium">
|
||||
£{formatNumber(rec.energyCostSavings)}/yr saved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="font-manrope font-bold text-brandblue">
|
||||
£{formatNumber(rec.estimatedCost ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
167
src/app/components/building-passport/StickyImpactBar.tsx
Normal file
167
src/app/components/building-passport/StickyImpactBar.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
function getEpcHex(letter: string | null | undefined): string {
|
||||
switch (letter?.toUpperCase()) {
|
||||
case "A": return "#117d58";
|
||||
case "B": return "#2da55c";
|
||||
case "C": return "#8dbd40";
|
||||
case "D": return "#f7cd14";
|
||||
case "E": return "#f3a96a";
|
||||
case "F": return "#ef8026";
|
||||
case "G": return "#e41e3b";
|
||||
default: return "#9ca3af";
|
||||
}
|
||||
}
|
||||
|
||||
interface StickyImpactBarProps {
|
||||
hasPendingChanges: boolean;
|
||||
pendingCount: number;
|
||||
savedSap: number;
|
||||
savedEpc: string;
|
||||
projectedSap: number;
|
||||
projectedEpc: string;
|
||||
projectedCo2: number;
|
||||
projectedHeatDemand: number;
|
||||
projectedCost: number;
|
||||
projectedContingency: number;
|
||||
onSaveAll: () => void;
|
||||
onDiscard: () => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString("en-GB", { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
export default function StickyImpactBar({
|
||||
hasPendingChanges,
|
||||
pendingCount,
|
||||
savedSap,
|
||||
savedEpc,
|
||||
projectedSap,
|
||||
projectedEpc,
|
||||
projectedCo2,
|
||||
projectedHeatDemand,
|
||||
projectedCost,
|
||||
projectedContingency,
|
||||
onSaveAll,
|
||||
onDiscard,
|
||||
isPending,
|
||||
}: StickyImpactBarProps) {
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-0 left-0 right-0 z-40 border-t border-gray-200 bg-white/95 backdrop-blur-md shadow-[0_-8px_30px_rgba(0,0,0,0.08)] transition-transform duration-300 ${
|
||||
hasPendingChanges ? "translate-y-0" : "translate-y-full"
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-[1400px] mx-auto px-6 py-4 flex items-center gap-4 flex-wrap">
|
||||
{/* Left: pending count badge */}
|
||||
<span className="text-xs font-bold px-3 py-1.5 rounded-full bg-amber-100 text-amber-700 shrink-0">
|
||||
{pendingCount} unsaved {pendingCount === 1 ? "change" : "changes"}
|
||||
</span>
|
||||
|
||||
{/* Center: metric chips */}
|
||||
<div className="flex items-center gap-3 flex-1 flex-wrap">
|
||||
{/* SAP */}
|
||||
<div className="px-3 py-2 bg-gray-50 rounded-lg border border-gray-100 flex items-center gap-2">
|
||||
<div>
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-gray-400 mb-0.5">SAP</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-black text-gray-400 font-manrope">
|
||||
{savedSap.toFixed(1)}
|
||||
</span>
|
||||
<ArrowRightIcon className="w-3 h-3 text-gray-300" />
|
||||
<span className="text-sm font-black text-brandblue font-manrope">
|
||||
{projectedSap.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EPC */}
|
||||
<div className="px-3 py-2 bg-gray-50 rounded-lg border border-gray-100 flex items-center gap-2">
|
||||
<div>
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-gray-400 mb-0.5">EPC</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="text-sm font-black font-manrope"
|
||||
style={{ color: getEpcHex(savedEpc) }}
|
||||
>
|
||||
{savedEpc}
|
||||
</span>
|
||||
<ArrowRightIcon className="w-3 h-3 text-gray-300" />
|
||||
<span
|
||||
className="text-sm font-black font-manrope"
|
||||
style={{ color: getEpcHex(projectedEpc) }}
|
||||
>
|
||||
{projectedEpc}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CO₂ */}
|
||||
<div className="px-3 py-2 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-gray-400 mb-0.5">CO₂ saved</p>
|
||||
<p className="text-sm font-black text-brandblue font-manrope">
|
||||
{projectedCo2.toFixed(1)} t/yr
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Heat demand — hidden when zero */}
|
||||
{projectedHeatDemand > 0 && (
|
||||
<div className="px-3 py-2 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-gray-400 mb-0.5">Heat demand</p>
|
||||
<p className="text-sm font-black text-brandblue font-manrope">
|
||||
{projectedHeatDemand.toFixed(0)} kWh/yr
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cost — hidden when zero */}
|
||||
{projectedCost > 0 && (
|
||||
<div className="px-3 py-2 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-gray-400 mb-0.5">Est. Cost</p>
|
||||
<p className="text-sm font-black text-brandblue font-manrope">
|
||||
£{formatNumber(projectedCost)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contingency — hidden when zero */}
|
||||
{projectedContingency > 0 && (
|
||||
<div className="px-3 py-2 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-gray-400 mb-0.5">Contingency</p>
|
||||
<p className="text-sm font-black text-brandbrown font-manrope">
|
||||
~£{formatNumber(projectedContingency)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: action buttons */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isPending && (
|
||||
<ArrowPathIcon className="w-4 h-4 animate-spin text-gray-400" />
|
||||
)}
|
||||
<button
|
||||
disabled={isPending}
|
||||
onClick={onDiscard}
|
||||
className="px-4 py-2 rounded-lg border border-gray-200 text-gray-600 font-bold text-sm hover:bg-gray-50 transition-colors disabled:opacity-40"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
disabled={isPending}
|
||||
onClick={onSaveAll}
|
||||
className="px-4 py-2 rounded-lg bg-brandblue text-white font-bold text-sm hover:bg-blue-700 transition-colors disabled:opacity-40"
|
||||
>
|
||||
Save all changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import { Recommendation } from "@/app/db/schema/recommendations";
|
||||
|
||||
export type AugmentedRec = Recommendation & { alreadyInstalled: boolean };
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
function Skeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={`animate-pulse bg-gray-100 rounded-lg ${className ?? ""}`} />
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlanDetailLoading() {
|
||||
return (
|
||||
<div className="max-w-[1400px] mx-auto py-10 space-y-12">
|
||||
|
||||
{/* Header skeleton */}
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-12 w-2/3 rounded-xl" />
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
</div>
|
||||
|
||||
{/* Executive Summary Bento skeleton */}
|
||||
<section className="grid grid-cols-1 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
||||
|
||||
{/* SAP Improvement card */}
|
||||
<div className="md:col-span-2 lg:col-span-2 bg-white p-7 rounded-2xl border border-gray-100 shadow-sm flex flex-col gap-5">
|
||||
<Skeleton className="h-3 w-28" />
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
<Skeleton className="h-12 w-10 rounded-lg" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-5 mb-2 rounded" />
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
<Skeleton className="h-16 w-14 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-3 w-full rounded-full" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
|
||||
{/* 4 stat tiles */}
|
||||
<div className="md:col-span-2 lg:col-span-4 grid grid-cols-2 gap-4">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="bg-blue-50 p-5 rounded-xl flex flex-col justify-between gap-3 border border-blue-100">
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
<Skeleton className="h-8 w-24 rounded-lg" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Financial + Recommendations skeleton */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-start">
|
||||
|
||||
{/* Left: Financial Overview */}
|
||||
<div className="lg:col-span-4 space-y-6">
|
||||
<Skeleton className="h-3 w-40" />
|
||||
<div className="bg-brandblue/10 p-8 rounded-2xl space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-28 bg-gray-200" />
|
||||
<Skeleton className="h-12 w-40 rounded-lg bg-gray-200" />
|
||||
</div>
|
||||
<div className="pt-6 border-t border-gray-200 flex justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-20 bg-gray-200" />
|
||||
<Skeleton className="h-6 w-24 rounded-lg bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-brandlightblue rounded-xl border border-blue-100 p-5 space-y-2">
|
||||
<Skeleton className="h-3 w-36" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-4/5" />
|
||||
<Skeleton className="h-3 w-3/5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Recommendations */}
|
||||
<div className="lg:col-span-8 space-y-8">
|
||||
<Skeleton className="h-3 w-44" />
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="bg-white rounded-xl border border-gray-100 shadow-sm p-8 space-y-4">
|
||||
<div className="flex items-center gap-5">
|
||||
<Skeleton className="h-14 w-14 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-48 rounded-lg" />
|
||||
<Skeleton className="h-3 w-64" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-12 w-20 rounded-lg" />
|
||||
<Skeleton className="h-12 w-20 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-12 w-full rounded-xl" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue