mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-19 17:02:59 +00:00
feat(home): portfolio home redesign — folder rail, starred view, grid/list
Client page driven by useQuery on /api/me/portfolio-home with optimistic mutations (star, move-to-folder, folder create/rename/delete, drag or menu-based reorder). TDD'd view-model in portfolioHomeView.ts (status pill mapping, search over display language, sort incl needs-attention, money/date formatting, optimistic config upsert); groupPortfoliosForHome generalised over id type so the client re-groups with wire-format string ids. List view is a semantic table; replaces CardTiles/Card/AddNewCard (NewPortfolioModal retained). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
633c127620
commit
cfa181f3c7
11 changed files with 1339 additions and 194 deletions
|
|
@ -8,10 +8,9 @@ import {
|
|||
userPortfolioConfig,
|
||||
userPortfolioFolders,
|
||||
} from "@/app/db/schema/user_portfolio_config";
|
||||
import { groupPortfoliosForHome } from "@/app/lib/portfolioUserConfig";
|
||||
|
||||
// Everything the home page renders: the caller's portfolios grouped by their
|
||||
// personal folders, with starred float and a most-recently-starred list.
|
||||
// Everything the home page renders, flat: the client groups via
|
||||
// groupPortfoliosForHome so optimistic mutations can re-group locally.
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(AuthOptions);
|
||||
|
|
@ -40,16 +39,7 @@ export async function GET() {
|
|||
.where(eq(userPortfolioFolders.userId, userId)),
|
||||
]);
|
||||
|
||||
const starredIds = new Set(
|
||||
configs.filter((c) => c.starredAt !== null).map((c) => c.portfolioId),
|
||||
);
|
||||
const grouped = groupPortfoliosForHome({ portfolios, configs, folders });
|
||||
const body = {
|
||||
folders: grouped.folders,
|
||||
unfiled: grouped.unfiled,
|
||||
starred: grouped.starred,
|
||||
starredIds: [...starredIds],
|
||||
};
|
||||
const body = { portfolios, configs, folders };
|
||||
|
||||
// bigint isn't JSON-serialisable; ids cross the wire as strings.
|
||||
return new NextResponse(
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import PlusIcon from "../PlusIcon";
|
||||
import NewPortfolioModal from "./NewPortfolioModal";
|
||||
|
||||
const styles = {
|
||||
wrapper:
|
||||
"group bg-brandblue hover:bg-hoverblue shadow-xl hover:shadow-none cursor-pointer rounded-3xl flex flex-col items-center justify-center aspect-square",
|
||||
header: "relative mt-2 mx-2 w-full",
|
||||
imageWrapper:
|
||||
"relative rounded-2xl overflow-hidden flex justify-center items-center",
|
||||
wrapperAnime: "transition-all duration-500 ease-in-out",
|
||||
image: "object-cover w-8/12 h-8/12 mx-auto fill-white",
|
||||
textWrapper: "w-full flex justify-center items-center pt-6",
|
||||
text: "pb-6 font-medium leading-none text-base tracking-wider text-gray-400",
|
||||
};
|
||||
|
||||
const AddNewCard = () => {
|
||||
const title = "New Portfolio";
|
||||
const [isModalOpen, setModalIsOpen] = useState(false);
|
||||
|
||||
const openModal = () => {
|
||||
setModalIsOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<a onClick={openModal}>
|
||||
<div className="flex justify-center">
|
||||
<div className={[styles.wrapper, styles.wrapperAnime].join(" ")}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.imageWrapper}>
|
||||
<div className="w-1/4">
|
||||
<PlusIcon color="white" />
|
||||
<NewPortfolioModal
|
||||
isOpen={isModalOpen}
|
||||
setIsOpen={setModalIsOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.textWrapper}>
|
||||
<h1 className={styles.text}>{`${title}`}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddNewCard;
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import React from "react";
|
||||
import StatusBadge from "@/app/components/StatusBadge";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PortfolioStatus } from "@/app/db/schema/portfolio";
|
||||
import { formatNumber } from "@/app/utils";
|
||||
import Image from "next/image";
|
||||
|
||||
|
||||
const styles = {
|
||||
wrapper:
|
||||
"group py-2 px-3 active:bg-brandmidblue font-medium leading-none text-base tracking-wider text-gray-400 hover:text-white-300 bg-white hover:bg-hoverblue shadow-2xl hover:shadow-none cursor-pointer aspect-square rounded-3xl flex flex-col items-center justify-center",
|
||||
header: "relative mt-2 w-full border-brandblue",
|
||||
budgetWrapper: "min-h-7 pr-4 flex justify-end w-full text-right max-h-16 my-auto text-gray-700 group-hover:text-white transition-all duration-500 ease-in-out relative",
|
||||
imageWrapper: "rounded-2xl overflow-hidden flex justify-center items-center",
|
||||
wrapperAnime: "transition-all duration-500 ease-in-out",
|
||||
image: "object-cover mx-auto",
|
||||
textWrapper: "pb-3 w-full px-4 flex justify-center items-center max-h-16 my-auto text-gray-700 text-center group-hover:text-white transition-all duration-500 ease-in-out",
|
||||
};
|
||||
|
||||
interface CardProps {
|
||||
id: bigint;
|
||||
title: string;
|
||||
image: string;
|
||||
budget: number | null;
|
||||
status: (typeof PortfolioStatus)[number];
|
||||
};
|
||||
|
||||
const Card = ({ id, title, image, budget, status }: CardProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
function handleClick() {
|
||||
router.push(`/portfolio/${id}`);
|
||||
}
|
||||
|
||||
const budgetFormatted = budget ? "£" + formatNumber(budget) : "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={[styles.wrapper, styles.wrapperAnime].join(" ")}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.budgetWrapper} >{budgetFormatted}</div>
|
||||
<div className={styles.imageWrapper}>
|
||||
<Image
|
||||
src={image}
|
||||
className={styles.image}
|
||||
alt=""
|
||||
width={80}
|
||||
height={80}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.textWrapper}>
|
||||
<h1>{`${title}`}</h1>
|
||||
</div>
|
||||
<div className="mb-4 flex justify-end w-full">
|
||||
<div className="flex justify-center w-full">
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import Card from "./Card";
|
||||
import AddNewCard from "./AddNewCard";
|
||||
import type { Portfolio } from "@/app/db/schema/portfolio";
|
||||
|
||||
type PortfoliosType = Array<Portfolio>;
|
||||
|
||||
export default function CardTiles({
|
||||
Portfolios,
|
||||
}: {
|
||||
Portfolios: PortfoliosType;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="grid grid-cols-1 gap-6 max-w-7xl sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
<AddNewCard />
|
||||
{Portfolios.map((portfolio, index) => {
|
||||
const image_idx = index % 5;
|
||||
return (
|
||||
<Card
|
||||
key={portfolio.id.toString()}
|
||||
id={portfolio.id}
|
||||
title={portfolio.name}
|
||||
image={`house-icon.svg`}
|
||||
budget={portfolio.budget}
|
||||
status={portfolio.status}
|
||||
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
253
src/app/components/home/FolderRail.tsx
Normal file
253
src/app/components/home/FolderRail.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { MoreVertical, Plus, Star } from "lucide-react";
|
||||
import type { FolderWire } from "@/app/lib/portfolioHomeView";
|
||||
|
||||
export type RailView = "all" | "starred" | { folderId: string };
|
||||
|
||||
export type FolderRailProps = {
|
||||
folders: FolderWire[]; // already ordered by position
|
||||
view: RailView;
|
||||
totalCount: number;
|
||||
starredCount: number;
|
||||
attentionTotal: number;
|
||||
countOf: (folderId: string) => number;
|
||||
attentionOf: (folderId: string) => number;
|
||||
onSelect: (view: RailView) => void;
|
||||
onReorder: (orderedIds: string[]) => void;
|
||||
onCreate: (name: string) => void;
|
||||
onRename: (folderId: string, name: string) => void;
|
||||
onDelete: (folderId: string) => void;
|
||||
};
|
||||
|
||||
function AttentionBadge({ count }: { count: number }) {
|
||||
if (!count) return null;
|
||||
return (
|
||||
<span
|
||||
title={`${count} need attention`}
|
||||
className="rounded-full bg-red-100 px-2 py-px text-[11px] font-semibold text-red-800 tabular-nums"
|
||||
>
|
||||
⚠ {count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function railItemClasses(active: boolean) {
|
||||
return `flex w-full min-w-0 items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm ${
|
||||
active ? "bg-[#e4e9f1] font-semibold" : "hover:bg-[#e9edf3]"
|
||||
}`;
|
||||
}
|
||||
|
||||
export default function FolderRail(props: FolderRailProps) {
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const isFolderView = (folderId: string) =>
|
||||
typeof props.view === "object" && props.view.folderId === folderId;
|
||||
|
||||
const commitDrop = () => {
|
||||
if (
|
||||
dragIndex !== null &&
|
||||
dropIndex !== null &&
|
||||
dropIndex !== dragIndex
|
||||
) {
|
||||
const ids = props.folders.map((f) => f.id);
|
||||
const [moved] = ids.splice(dragIndex, 1);
|
||||
ids.splice(dropIndex, 0, moved);
|
||||
props.onReorder(ids);
|
||||
}
|
||||
setDragIndex(null);
|
||||
setDropIndex(null);
|
||||
};
|
||||
|
||||
const nameForm = (
|
||||
defaultValue: string,
|
||||
onSubmit: (name: string) => void,
|
||||
onDone: () => void,
|
||||
) => (
|
||||
<form
|
||||
className="flex-1 px-1"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const name = new FormData(e.currentTarget).get("name");
|
||||
if (typeof name === "string" && name.trim()) onSubmit(name.trim());
|
||||
onDone();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
name="name"
|
||||
defaultValue={defaultValue}
|
||||
autoFocus
|
||||
maxLength={120}
|
||||
aria-label="Folder name"
|
||||
onBlur={onDone}
|
||||
onKeyDown={(e) => e.key === "Escape" && onDone()}
|
||||
className="w-full rounded-md border border-brandmidblue px-2 py-1 text-sm focus:outline-none"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
return (
|
||||
<nav aria-label="Portfolio folders" className="w-full shrink-0 lg:sticky lg:top-5 lg:w-[230px]">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onSelect("starred")}
|
||||
className={railItemClasses(props.view === "starred")}
|
||||
>
|
||||
<Star size={14} className="shrink-0" fill="#f1bb06" strokeWidth={0} aria-hidden />
|
||||
<span className="flex-1 truncate">Starred</span>
|
||||
<span className="text-[13px] text-gray-500 tabular-nums">
|
||||
{props.starredCount}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onSelect("all")}
|
||||
className={railItemClasses(props.view === "all")}
|
||||
>
|
||||
<span className="flex-1 truncate">All portfolios</span>
|
||||
<AttentionBadge count={props.attentionTotal} />
|
||||
<span className="text-[13px] text-gray-500 tabular-nums">
|
||||
{props.totalCount}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr className="my-2.5 border-[#e2e7ee]" />
|
||||
|
||||
<div className="mb-1.5 flex items-center px-2.5 text-xs font-semibold text-gray-500">
|
||||
Your folders
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreating(true)}
|
||||
className="ml-auto flex items-center gap-0.5 rounded-md px-1.5 py-0.5 font-semibold text-brandmidblue hover:bg-[#eceefb]"
|
||||
>
|
||||
<Plus size={13} aria-hidden /> New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{props.folders.map((folder, index) => (
|
||||
<li
|
||||
key={folder.id}
|
||||
draggable={editingId !== folder.id}
|
||||
onDragStart={() => setDragIndex(index)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (dragIndex !== null) setDropIndex(index);
|
||||
}}
|
||||
onDrop={commitDrop}
|
||||
onDragEnd={() => {
|
||||
setDragIndex(null);
|
||||
setDropIndex(null);
|
||||
}}
|
||||
className={`group flex items-center rounded-lg ${
|
||||
dragIndex === index ? "opacity-40" : ""
|
||||
} ${
|
||||
dropIndex === index && dragIndex !== null && dragIndex !== index
|
||||
? dropIndex < dragIndex
|
||||
? "shadow-[0_-2px_0_#3943b7]"
|
||||
: "shadow-[0_2px_0_#3943b7]"
|
||||
: ""
|
||||
} ${editingId === folder.id ? "" : "cursor-grab"}`}
|
||||
>
|
||||
{editingId === folder.id ? (
|
||||
nameForm(
|
||||
folder.name,
|
||||
(name) => props.onRename(folder.id, name),
|
||||
() => setEditingId(null),
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onSelect({ folderId: folder.id })}
|
||||
className={railItemClasses(isFolderView(folder.id))}
|
||||
>
|
||||
<span className="flex-1 truncate">{folder.name}</span>
|
||||
<AttentionBadge count={props.attentionOf(folder.id)} />
|
||||
<span className="text-[13px] text-gray-500 tabular-nums">
|
||||
{props.countOf(folder.id)}
|
||||
</span>
|
||||
</button>
|
||||
<details className="relative shrink-0">
|
||||
<summary
|
||||
aria-label={`Folder options for ${folder.name}`}
|
||||
className="grid cursor-pointer list-none place-items-center rounded-lg p-1 text-gray-500 opacity-0 hover:bg-gray-100 focus-visible:opacity-100 group-hover:opacity-100 [&::-webkit-details-marker]:hidden [details[open]>&]:opacity-100"
|
||||
>
|
||||
<MoreVertical size={15} aria-hidden />
|
||||
</summary>
|
||||
<div className="absolute right-0 top-full z-20 mt-1 min-w-[150px] rounded-lg border border-gray-200 bg-white p-1 shadow-md">
|
||||
{(
|
||||
[
|
||||
["Move up", index > 0, -1],
|
||||
["Move down", index < props.folders.length - 1, 1],
|
||||
] as const
|
||||
).map(([label, enabled, delta]) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
disabled={!enabled}
|
||||
onClick={(e) => {
|
||||
e.currentTarget
|
||||
.closest("details")
|
||||
?.removeAttribute("open");
|
||||
const ids = props.folders.map((f) => f.id);
|
||||
const [moved] = ids.splice(index, 1);
|
||||
ids.splice(index + delta, 0, moved);
|
||||
props.onReorder(ids);
|
||||
}}
|
||||
className="block w-full rounded-md px-2.5 py-1.5 text-left text-sm hover:bg-gray-50 disabled:cursor-default disabled:text-gray-400 disabled:hover:bg-transparent"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<div className="my-1 border-t border-gray-200" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.currentTarget
|
||||
.closest("details")
|
||||
?.removeAttribute("open");
|
||||
setEditingId(folder.id);
|
||||
}}
|
||||
className="block w-full rounded-md px-2.5 py-1.5 text-left text-sm hover:bg-gray-50"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.currentTarget
|
||||
.closest("details")
|
||||
?.removeAttribute("open");
|
||||
props.onDelete(folder.id);
|
||||
}}
|
||||
className="block w-full rounded-md px-2.5 py-1.5 text-left text-sm text-red-700 hover:bg-red-50"
|
||||
>
|
||||
Remove folder
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{creating && (
|
||||
<li className="flex items-center rounded-lg">
|
||||
{nameForm("", props.onCreate, () => setCreating(false))}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<p className="hidden px-2.5 pt-2 text-xs text-gray-500 lg:block">
|
||||
Folders are personal to you — teammates organise their own. Drag to
|
||||
reorder.
|
||||
</p>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
297
src/app/components/home/PortfolioCard.tsx
Normal file
297
src/app/components/home/PortfolioCard.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { MoreVertical, Star } from "lucide-react";
|
||||
import {
|
||||
formatMoney,
|
||||
formatUpdatedAgo,
|
||||
statusDisplay,
|
||||
type FolderWire,
|
||||
type PortfolioWire,
|
||||
type StatusTone,
|
||||
} from "@/app/lib/portfolioHomeView";
|
||||
|
||||
const PILL_TONES: Record<StatusTone, string> = {
|
||||
early: "bg-slate-100 text-slate-600",
|
||||
tender: "bg-indigo-100 text-indigo-800",
|
||||
underway: "bg-blue-100 text-blue-800",
|
||||
ontrack: "bg-green-100 text-green-800",
|
||||
delayed: "bg-amber-100 text-amber-800",
|
||||
atrisk: "bg-red-100 text-red-800",
|
||||
done: "bg-emerald-100 text-emerald-900",
|
||||
review: "bg-yellow-100 text-yellow-800",
|
||||
};
|
||||
|
||||
export type CardActions = {
|
||||
isStarred: (portfolioId: string) => boolean;
|
||||
folderIdOf: (portfolioId: string) => string | null;
|
||||
onToggleStar: (portfolio: PortfolioWire) => void;
|
||||
onMoveToFolder: (portfolio: PortfolioWire, folderId: string | null) => void;
|
||||
folders: FolderWire[];
|
||||
};
|
||||
|
||||
function StatusPill({ status }: { status: string }) {
|
||||
const display = statusDisplay(status);
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 py-0.5 text-xs font-semibold ${PILL_TONES[display.tone]}`}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden />
|
||||
{display.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StarButton({
|
||||
portfolio,
|
||||
actions,
|
||||
}: {
|
||||
portfolio: PortfolioWire;
|
||||
actions: CardActions;
|
||||
}) {
|
||||
const starred = actions.isStarred(portfolio.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={starred}
|
||||
aria-label={`${starred ? "Unstar" : "Star"} ${portfolio.name}`}
|
||||
title={starred ? "Unstar" : "Star"}
|
||||
onClick={() => actions.onToggleStar(portfolio)}
|
||||
className="rounded-lg p-1 text-gray-500 transition-colors hover:bg-gray-100 hover:text-brandblue focus-visible:outline focus-visible:outline-2 focus-visible:outline-brandmidblue"
|
||||
>
|
||||
<Star
|
||||
size={17}
|
||||
className={starred ? "stroke-brandblue" : ""}
|
||||
fill={starred ? "#f1bb06" : "none"}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CardMenu({
|
||||
portfolio,
|
||||
actions,
|
||||
openUpward,
|
||||
}: {
|
||||
portfolio: PortfolioWire;
|
||||
actions: CardActions;
|
||||
openUpward?: boolean;
|
||||
}) {
|
||||
const currentFolderId = actions.folderIdOf(portfolio.id);
|
||||
return (
|
||||
<details className="relative">
|
||||
<summary
|
||||
aria-label={`Actions for ${portfolio.name}`}
|
||||
className="grid cursor-pointer list-none place-items-center rounded-lg p-1 text-gray-500 hover:bg-gray-100 hover:text-brandblue [&::-webkit-details-marker]:hidden"
|
||||
>
|
||||
<MoreVertical size={16} aria-hidden />
|
||||
</summary>
|
||||
<div
|
||||
className={`absolute right-0 z-20 min-w-[160px] rounded-lg border border-gray-200 bg-white p-1 shadow-md ${
|
||||
openUpward ? "bottom-full mb-1" : "top-full mt-1"
|
||||
}`}
|
||||
>
|
||||
<div className="px-2.5 py-1 text-[11px] font-semibold text-gray-500">
|
||||
Move to folder
|
||||
</div>
|
||||
{actions.folders.map((folder) => (
|
||||
<button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.currentTarget.closest("details")?.removeAttribute("open");
|
||||
actions.onMoveToFolder(portfolio, folder.id);
|
||||
}}
|
||||
className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-left text-sm hover:bg-gray-50"
|
||||
>
|
||||
{folder.name}
|
||||
{currentFolderId === folder.id && (
|
||||
<span className="text-brandmidblue" aria-label="current folder">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.currentTarget.closest("details")?.removeAttribute("open");
|
||||
actions.onMoveToFolder(portfolio, null);
|
||||
}}
|
||||
className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-left text-sm hover:bg-gray-50"
|
||||
>
|
||||
No folder
|
||||
{currentFolderId === null && (
|
||||
<span className="text-brandmidblue" aria-label="current folder">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function BudgetLine({ portfolio }: { portfolio: PortfolioWire }) {
|
||||
const { budget, cost } = portfolio;
|
||||
if (!budget) {
|
||||
return <div className="text-[13px] text-gray-500">No budget set</div>;
|
||||
}
|
||||
const pct = cost != null ? Math.round((100 * cost) / budget) : null;
|
||||
const over = cost != null && cost > budget;
|
||||
return (
|
||||
<div className="text-[13px]">
|
||||
<div className="mb-1 flex justify-between text-gray-500">
|
||||
{cost != null ? (
|
||||
<>
|
||||
<span>
|
||||
<span className="font-semibold text-gray-900 tabular-nums">
|
||||
{formatMoney(cost)}
|
||||
</span>{" "}
|
||||
of <span className="tabular-nums">{formatMoney(budget)}</span>{" "}
|
||||
budget
|
||||
</span>
|
||||
<span
|
||||
className={`tabular-nums ${over ? "font-semibold text-red-700" : ""}`}
|
||||
>
|
||||
{pct}%{over ? " over" : ""}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>
|
||||
Budget{" "}
|
||||
<span className="font-semibold text-gray-900 tabular-nums">
|
||||
{formatMoney(budget)}
|
||||
</span>
|
||||
</span>
|
||||
<span>no spend yet</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1 overflow-hidden rounded-full bg-gray-200">
|
||||
<div
|
||||
className={`h-full rounded-full ${over ? "bg-red-700" : "bg-brandmidblue"}`}
|
||||
style={{ width: `${Math.min(100, pct ?? 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortfolioGridCard({
|
||||
portfolio,
|
||||
actions,
|
||||
folderName,
|
||||
}: {
|
||||
portfolio: PortfolioWire;
|
||||
actions: CardActions;
|
||||
folderName?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<article className="flex flex-col gap-2.5 rounded-xl border border-[#e2e7ee] bg-white p-4 transition-colors hover:border-[#c3ccd8]">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<Link
|
||||
href={`/portfolio/${portfolio.id}`}
|
||||
className="text-[17px] font-semibold leading-snug text-gray-900 hover:text-brandmidblue hover:underline hover:underline-offset-2"
|
||||
>
|
||||
{portfolio.name}
|
||||
</Link>
|
||||
<StarButton portfolio={portfolio} actions={actions} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[13px] text-gray-500">
|
||||
<StatusPill status={portfolio.status} />
|
||||
<span className="font-medium text-gray-900">
|
||||
{portfolio.goal === "None" ? "No goal set" : portfolio.goal}
|
||||
</span>
|
||||
{portfolio.numberOfProperties != null && (
|
||||
<span>
|
||||
<span className="tabular-nums">
|
||||
{portfolio.numberOfProperties.toLocaleString()}
|
||||
</span>{" "}
|
||||
properties
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<BudgetLine portfolio={portfolio} />
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>
|
||||
{folderName ? `${folderName} · ` : ""}
|
||||
Updated {formatUpdatedAgo(portfolio.updatedAt, new Date())}
|
||||
</span>
|
||||
<CardMenu portfolio={portfolio} actions={actions} openUpward />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortfolioTable({
|
||||
portfolios,
|
||||
actions,
|
||||
folderNameOf,
|
||||
}: {
|
||||
portfolios: PortfolioWire[];
|
||||
actions: CardActions;
|
||||
folderNameOf: (portfolioId: string) => string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-[#e2e7ee] bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#e2e7ee] text-left text-[11px] font-semibold text-gray-500">
|
||||
<th className="w-10 px-3 py-2" aria-label="Starred" />
|
||||
<th className="px-3 py-2">Portfolio</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
<th className="px-3 py-2">Goal</th>
|
||||
<th className="px-3 py-2 text-right">Properties</th>
|
||||
<th className="px-3 py-2">Budget</th>
|
||||
<th className="px-3 py-2 text-right">Updated</th>
|
||||
<th className="w-10 px-3 py-2" aria-label="Actions" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{portfolios.map((portfolio) => (
|
||||
<tr
|
||||
key={portfolio.id}
|
||||
className="border-b border-[#eef1f6] last:border-b-0 hover:bg-[#f8fafc]"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<StarButton portfolio={portfolio} actions={actions} />
|
||||
</td>
|
||||
<td className="max-w-[280px] truncate px-3 py-2">
|
||||
<Link
|
||||
href={`/portfolio/${portfolio.id}`}
|
||||
className="font-semibold text-gray-900 hover:text-brandmidblue hover:underline hover:underline-offset-2"
|
||||
>
|
||||
{portfolio.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<StatusPill status={portfolio.status} />
|
||||
</td>
|
||||
<td className="max-w-[180px] truncate px-3 py-2 text-gray-700">
|
||||
{portfolio.goal === "None" ? "—" : portfolio.goal}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{portfolio.numberOfProperties?.toLocaleString() ?? "—"}
|
||||
</td>
|
||||
<td className="min-w-[170px] px-3 py-2">
|
||||
<BudgetLine portfolio={portfolio} />
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-right text-gray-500">
|
||||
{folderNameOf(portfolio.id)
|
||||
? `${folderNameOf(portfolio.id)} · `
|
||||
: ""}
|
||||
{formatUpdatedAgo(portfolio.updatedAt, new Date())}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<CardMenu portfolio={portfolio} actions={actions} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
458
src/app/components/home/PortfolioHome.tsx
Normal file
458
src/app/components/home/PortfolioHome.tsx
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { LayoutGrid, List, Plus, Search } from "lucide-react";
|
||||
import {
|
||||
matchesSearch,
|
||||
sortPortfolios,
|
||||
statusDisplay,
|
||||
upsertLocalConfig,
|
||||
type HomeData,
|
||||
type HomeSort,
|
||||
type PortfolioWire,
|
||||
} from "@/app/lib/portfolioHomeView";
|
||||
import FolderRail, { type RailView } from "./FolderRail";
|
||||
import {
|
||||
PortfolioGridCard,
|
||||
PortfolioTable,
|
||||
type CardActions,
|
||||
} from "./PortfolioCard";
|
||||
import NewPortfolioModal from "./NewPortfolioModal";
|
||||
|
||||
const HOME_KEY = ["portfolio-home"];
|
||||
|
||||
async function fetchHome(): Promise<HomeData> {
|
||||
const res = await fetch("/api/me/portfolio-home");
|
||||
if (!res.ok) throw new Error("Failed to load portfolios");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function send(url: string, method: string, body?: unknown) {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: body ? { "content-type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => null);
|
||||
throw new Error(payload?.error ?? "Request failed");
|
||||
}
|
||||
}
|
||||
|
||||
// Shared optimistic-mutation plumbing: apply the local update immediately,
|
||||
// roll back on error, reconcile with the server afterwards.
|
||||
function useOptimisticHomeMutation<V>(
|
||||
mutationFn: (vars: V) => Promise<void>,
|
||||
localUpdate: (home: HomeData, vars: V) => HomeData,
|
||||
announceError: (message: string) => void,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn,
|
||||
onMutate: async (vars: V) => {
|
||||
await queryClient.cancelQueries(HOME_KEY);
|
||||
const previous = queryClient.getQueryData<HomeData>(HOME_KEY);
|
||||
if (previous) {
|
||||
queryClient.setQueryData(HOME_KEY, localUpdate(previous, vars));
|
||||
}
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err: unknown, _vars: V, context?: { previous?: HomeData }) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(HOME_KEY, context.previous);
|
||||
}
|
||||
announceError("Something went wrong — change undone");
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries(HOME_KEY),
|
||||
});
|
||||
}
|
||||
|
||||
export default function PortfolioHome() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, isError, refetch } = useQuery(HOME_KEY, fetchHome);
|
||||
|
||||
const [view, setView] = useState<RailView>("all");
|
||||
const [display, setDisplay] = useState<"grid" | "list">("grid");
|
||||
const [sort, setSort] = useState<HomeSort>("updated");
|
||||
const [query, setQuery] = useState("");
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const [newPortfolioOpen, setNewPortfolioOpen] = useState(false);
|
||||
|
||||
const configMutation = useOptimisticHomeMutation(
|
||||
(vars: {
|
||||
portfolioId: string;
|
||||
change: { starred?: boolean; folderId?: string | null };
|
||||
}) =>
|
||||
send(`/api/me/portfolios/${vars.portfolioId}/config`, "PATCH", vars.change),
|
||||
(home, vars) => ({
|
||||
...home,
|
||||
configs: upsertLocalConfig(home.configs, vars.portfolioId, {
|
||||
...(vars.change.starred !== undefined && {
|
||||
starredAt: vars.change.starred ? new Date().toISOString() : null,
|
||||
}),
|
||||
...(vars.change.folderId !== undefined && {
|
||||
folderId: vars.change.folderId,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
setAnnouncement,
|
||||
);
|
||||
|
||||
const reorderMutation = useOptimisticHomeMutation(
|
||||
(orderedIds: string[]) =>
|
||||
send("/api/me/folders/reorder", "PATCH", { orderedIds }),
|
||||
(home, orderedIds) => ({
|
||||
...home,
|
||||
folders: home.folders.map((f) => ({
|
||||
...f,
|
||||
position: orderedIds.indexOf(f.id),
|
||||
})),
|
||||
}),
|
||||
setAnnouncement,
|
||||
);
|
||||
|
||||
const renameMutation = useOptimisticHomeMutation(
|
||||
(vars: { folderId: string; name: string }) =>
|
||||
send(`/api/me/folders/${vars.folderId}`, "PATCH", { name: vars.name }),
|
||||
(home, vars) => ({
|
||||
...home,
|
||||
folders: home.folders.map((f) =>
|
||||
f.id === vars.folderId ? { ...f, name: vars.name } : f,
|
||||
),
|
||||
}),
|
||||
setAnnouncement,
|
||||
);
|
||||
|
||||
const deleteMutation = useOptimisticHomeMutation(
|
||||
(folderId: string) => send(`/api/me/folders/${folderId}`, "DELETE"),
|
||||
(home, folderId) => ({
|
||||
...home,
|
||||
folders: home.folders.filter((f) => f.id !== folderId),
|
||||
configs: home.configs.map((c) =>
|
||||
c.folderId === folderId ? { ...c, folderId: null } : c,
|
||||
),
|
||||
}),
|
||||
setAnnouncement,
|
||||
);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) => send("/api/me/folders", "POST", { name }),
|
||||
onSettled: () => queryClient.invalidateQueries(HOME_KEY),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-[1320px] gap-7 px-6 py-8" aria-busy>
|
||||
<div className="hidden w-[230px] shrink-0 animate-pulse rounded-xl bg-gray-200/60 lg:block lg:h-64" />
|
||||
<div className="grid flex-1 grid-cols-1 gap-3.5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div key={i} className="h-44 animate-pulse rounded-xl bg-gray-200/60" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[1320px] px-6 py-16 text-center text-gray-600">
|
||||
Couldn’t load your portfolios.{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="font-semibold text-brandmidblue hover:underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- derived render state (all plain derivation, no memoization needed
|
||||
// at this scale) ----
|
||||
const configOf = new Map(data.configs.map((c) => [c.portfolioId, c]));
|
||||
const folderById = new Map(data.folders.map((f) => [f.id, f]));
|
||||
const orderedFolders = [...data.folders].sort(
|
||||
(a, b) => a.position - b.position,
|
||||
);
|
||||
|
||||
const isStarred = (id: string) => configOf.get(id)?.starredAt != null;
|
||||
const folderIdOf = (id: string) => configOf.get(id)?.folderId ?? null;
|
||||
const folderNameOf = (id: string) => {
|
||||
const folderId = folderIdOf(id);
|
||||
return folderId ? (folderById.get(folderId)?.name ?? null) : null;
|
||||
};
|
||||
const needsAttention = (p: PortfolioWire) =>
|
||||
statusDisplay(p.status).needsAttention;
|
||||
|
||||
const inView = (p: PortfolioWire) =>
|
||||
view === "all"
|
||||
? true
|
||||
: view === "starred"
|
||||
? isStarred(p.id)
|
||||
: folderIdOf(p.id) === view.folderId;
|
||||
|
||||
const scope = data.portfolios.filter(inView);
|
||||
const filtered = scope.filter((p) =>
|
||||
matchesSearch({ ...p, folderName: folderNameOf(p.id) }, query),
|
||||
);
|
||||
let rows = sortPortfolios(filtered, sort);
|
||||
if (view === "starred") {
|
||||
rows = [...rows].sort(
|
||||
(a, b) =>
|
||||
new Date(configOf.get(b.id)!.starredAt!).getTime() -
|
||||
new Date(configOf.get(a.id)!.starredAt!).getTime(),
|
||||
);
|
||||
} else {
|
||||
rows = [...rows].sort(
|
||||
(a, b) => Number(isStarred(b.id)) - Number(isStarred(a.id)),
|
||||
);
|
||||
}
|
||||
|
||||
const viewTitle =
|
||||
view === "all"
|
||||
? "All portfolios"
|
||||
: view === "starred"
|
||||
? "Starred"
|
||||
: (folderById.get(view.folderId)?.name ?? "Folder");
|
||||
const attentionCount = rows.filter(needsAttention).length;
|
||||
const propertiesSum = rows.reduce(
|
||||
(sum, p) => sum + (p.numberOfProperties ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const cardActions: CardActions = {
|
||||
isStarred,
|
||||
folderIdOf,
|
||||
folders: orderedFolders,
|
||||
onToggleStar: (p) => {
|
||||
const starred = !isStarred(p.id);
|
||||
configMutation.mutate({ portfolioId: p.id, change: { starred } });
|
||||
setAnnouncement(`${starred ? "Starred" : "Unstarred"} ${p.name}`);
|
||||
},
|
||||
onMoveToFolder: (p, folderId) => {
|
||||
configMutation.mutate({ portfolioId: p.id, change: { folderId } });
|
||||
setAnnouncement(
|
||||
folderId
|
||||
? `Moved ${p.name} to ${folderById.get(folderId)?.name}`
|
||||
: `Removed ${p.name} from its folder`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1320px] px-6 py-7">
|
||||
<div className="mb-6 flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-gray-900">
|
||||
Portfolios
|
||||
</h1>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2.5">
|
||||
<label className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"
|
||||
aria-hidden
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search portfolios"
|
||||
aria-label="Search portfolios by name, status, goal or folder"
|
||||
className="w-60 rounded-lg border border-[#c3ccd8] bg-white py-2 pl-8 pr-3 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as HomeSort)}
|
||||
aria-label="Sort portfolios"
|
||||
className="rounded-lg border border-[#c3ccd8] bg-white px-2.5 py-2 text-sm"
|
||||
>
|
||||
<option value="updated">Recently updated</option>
|
||||
<option value="attention">Needs attention first</option>
|
||||
<option value="name">Name A–Z</option>
|
||||
<option value="props">Most properties</option>
|
||||
</select>
|
||||
<div
|
||||
role="group"
|
||||
aria-label="View"
|
||||
className="flex overflow-hidden rounded-lg border border-[#c3ccd8] bg-white"
|
||||
>
|
||||
{(
|
||||
[
|
||||
["grid", LayoutGrid, "Grid view"],
|
||||
["list", List, "List view"],
|
||||
] as const
|
||||
).map(([value, Icon, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={display === value}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={() => setDisplay(value)}
|
||||
className={`grid place-items-center px-2.5 py-2 ${
|
||||
display === value
|
||||
? "bg-brandblue text-white"
|
||||
: "text-gray-500 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<Icon size={15} aria-hidden />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewPortfolioOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-brandblue px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#232866]"
|
||||
>
|
||||
<Plus size={15} aria-hidden /> New portfolio
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:gap-7">
|
||||
<FolderRail
|
||||
folders={orderedFolders}
|
||||
view={view}
|
||||
totalCount={data.portfolios.length}
|
||||
starredCount={data.portfolios.filter((p) => isStarred(p.id)).length}
|
||||
attentionTotal={data.portfolios.filter(needsAttention).length}
|
||||
countOf={(folderId) =>
|
||||
data.portfolios.filter((p) => folderIdOf(p.id) === folderId).length
|
||||
}
|
||||
attentionOf={(folderId) =>
|
||||
data.portfolios.filter(
|
||||
(p) => folderIdOf(p.id) === folderId && needsAttention(p),
|
||||
).length
|
||||
}
|
||||
onSelect={setView}
|
||||
onReorder={(orderedIds) => {
|
||||
reorderMutation.mutate(orderedIds);
|
||||
setAnnouncement("Folder order saved");
|
||||
}}
|
||||
onCreate={(name) => createMutation.mutate(name)}
|
||||
onRename={(folderId, name) =>
|
||||
renameMutation.mutate({ folderId, name })
|
||||
}
|
||||
onDelete={(folderId) => {
|
||||
deleteMutation.mutate(folderId);
|
||||
setAnnouncement("Folder removed — its portfolios are unfiled");
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="min-w-0 flex-1">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-[17px] font-semibold tracking-tight text-gray-900">
|
||||
{viewTitle}
|
||||
</h2>
|
||||
<div className="text-[13px] text-gray-500">
|
||||
{query.trim() ? (
|
||||
<>
|
||||
Showing <span className="tabular-nums">{rows.length}</span> of{" "}
|
||||
<span className="tabular-nums">{scope.length}</span> for “
|
||||
{query.trim()}”
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="tabular-nums">{rows.length}</span> portfolios
|
||||
{propertiesSum > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
· <span className="tabular-nums">
|
||||
{propertiesSum.toLocaleString()}
|
||||
</span>{" "}
|
||||
properties
|
||||
</>
|
||||
)}
|
||||
{attentionCount > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
·{" "}
|
||||
<b className="font-semibold text-red-700">
|
||||
{attentionCount} need attention
|
||||
</b>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
query.trim() ? (
|
||||
<div className="py-12 text-center text-sm text-gray-500">
|
||||
No portfolios in {viewTitle} match “
|
||||
<b className="text-gray-900">{query.trim()}</b>”.{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery("")}
|
||||
className="font-semibold text-brandmidblue hover:underline"
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-[#c3ccd8] px-5 py-6 text-sm text-gray-500">
|
||||
{view === "starred" ? (
|
||||
<>
|
||||
<b className="font-semibold text-gray-900">
|
||||
Nothing starred yet.
|
||||
</b>{" "}
|
||||
Star a portfolio to keep your day-to-day work one click
|
||||
away.
|
||||
</>
|
||||
) : view === "all" ? (
|
||||
<>
|
||||
<b className="font-semibold text-gray-900">
|
||||
No portfolios yet.
|
||||
</b>{" "}
|
||||
Create your first portfolio to get started.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<b className="font-semibold text-gray-900">
|
||||
This folder is empty.
|
||||
</b>{" "}
|
||||
Move portfolios here with the ⋯ menu on any card.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : display === "grid" ? (
|
||||
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{rows.map((portfolio) => (
|
||||
<PortfolioGridCard
|
||||
key={portfolio.id}
|
||||
portfolio={portfolio}
|
||||
actions={cardActions}
|
||||
folderName={
|
||||
view === "all" || view === "starred"
|
||||
? folderNameOf(portfolio.id)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<PortfolioTable
|
||||
portfolios={rows}
|
||||
actions={cardActions}
|
||||
folderNameOf={
|
||||
view === "all" || view === "starred" ? folderNameOf : () => null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div role="status" aria-live="polite" className="sr-only">
|
||||
{announcement}
|
||||
</div>
|
||||
<NewPortfolioModal
|
||||
isOpen={newPortfolioOpen}
|
||||
setIsOpen={setNewPortfolioOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,30 +1,15 @@
|
|||
import CardTiles from "../components/home/CardTiles";
|
||||
import { getPortfolios } from "./utils";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import PortfolioHome from "../components/home/PortfolioHome";
|
||||
|
||||
const Home = async () => {
|
||||
const user = await getServerSession(AuthOptions);
|
||||
|
||||
if (!user?.user) {
|
||||
console.error("User not found");
|
||||
redirect("/");
|
||||
}
|
||||
const portfolios = await getPortfolios(user.user.dbId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-center">
|
||||
<h1 className="text-3xl font-bold mt-3 mb-5 text-gray-700">
|
||||
{" "}
|
||||
Your Portfolios{" "}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="px-5">
|
||||
<CardTiles Portfolios={portfolios} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return <PortfolioHome />;
|
||||
};
|
||||
export default Home;
|
||||
|
|
|
|||
122
src/app/lib/portfolioHomeView.test.ts
Normal file
122
src/app/lib/portfolioHomeView.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { PortfolioStatus } from "@/app/db/schema/portfolio";
|
||||
import {
|
||||
formatMoney,
|
||||
formatUpdatedAgo,
|
||||
matchesSearch,
|
||||
sortPortfolios,
|
||||
statusDisplay,
|
||||
upsertLocalConfig,
|
||||
} from "./portfolioHomeView";
|
||||
|
||||
describe("statusDisplay", () => {
|
||||
it("maps lifecycle enum values to clean pill labels", () => {
|
||||
expect(statusDisplay("scoping")).toEqual({
|
||||
label: "Scoping",
|
||||
tone: "early",
|
||||
needsAttention: false,
|
||||
});
|
||||
expect(statusDisplay("completion; status: at risk")).toEqual({
|
||||
label: "At risk",
|
||||
tone: "atrisk",
|
||||
needsAttention: true,
|
||||
});
|
||||
expect(statusDisplay("needs review")).toEqual({
|
||||
label: "Needs review",
|
||||
tone: "review",
|
||||
needsAttention: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("covers every status the schema allows", () => {
|
||||
for (const status of PortfolioStatus) {
|
||||
const display = statusDisplay(status);
|
||||
expect(display.label, status).toBeTruthy();
|
||||
expect(display.label).not.toContain(";");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMoney", () => {
|
||||
it("renders millions with one decimal and thousands as whole k", () => {
|
||||
expect(formatMoney(6500000)).toBe("£6.5M");
|
||||
expect(formatMoney(4000000)).toBe("£4M");
|
||||
expect(formatMoney(745000)).toBe("£745k");
|
||||
expect(formatMoney(0)).toBe("£0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchesSearch", () => {
|
||||
const lambeth = {
|
||||
name: "Lambeth Regeneration",
|
||||
status: "completion; status: at risk",
|
||||
goal: "Increasing EPC",
|
||||
folderName: "SHDF Wave 3",
|
||||
};
|
||||
|
||||
it("matches on name, display status, goal, and folder name, case-insensitively", () => {
|
||||
expect(matchesSearch(lambeth, "lambeth")).toBe(true);
|
||||
expect(matchesSearch(lambeth, "at risk")).toBe(true);
|
||||
expect(matchesSearch(lambeth, "epc")).toBe(true);
|
||||
expect(matchesSearch(lambeth, "shdf")).toBe(true);
|
||||
expect(matchesSearch(lambeth, "peabody")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches everything on an empty query and unfoldered portfolios don't match folder terms", () => {
|
||||
expect(matchesSearch(lambeth, " ")).toBe(true);
|
||||
expect(
|
||||
matchesSearch({ ...lambeth, folderName: null }, "shdf"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortPortfolios", () => {
|
||||
const rows = [
|
||||
{ name: "B", status: "scoping", numberOfProperties: 10, updatedAt: new Date("2026-07-01") },
|
||||
{ name: "A", status: "completion; status: delayed", numberOfProperties: 30, updatedAt: new Date("2026-07-06") },
|
||||
{ name: "C", status: "completion; status: at risk", numberOfProperties: 20, updatedAt: new Date("2026-07-03") },
|
||||
];
|
||||
const names = (sorted: typeof rows) => sorted.map((r) => r.name);
|
||||
|
||||
it("orders by the chosen key without mutating the input", () => {
|
||||
expect(names(sortPortfolios(rows, "updated"))).toEqual(["A", "C", "B"]);
|
||||
expect(names(sortPortfolios(rows, "attention"))).toEqual(["C", "A", "B"]);
|
||||
expect(names(sortPortfolios(rows, "name"))).toEqual(["A", "B", "C"]);
|
||||
expect(names(sortPortfolios(rows, "props"))).toEqual(["A", "C", "B"]);
|
||||
expect(names(rows)).toEqual(["B", "A", "C"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertLocalConfig", () => {
|
||||
it("creates a config on first star, like the lazy DB upsert", () => {
|
||||
const next = upsertLocalConfig([], "7", { starredAt: "2026-07-07T12:00:00Z" });
|
||||
|
||||
expect(next).toEqual([
|
||||
{ portfolioId: "7", folderId: null, starredAt: "2026-07-07T12:00:00Z" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("changing one field preserves the other", () => {
|
||||
const configs = [
|
||||
{ portfolioId: "7", folderId: "10", starredAt: "2026-07-01T00:00:00Z" },
|
||||
];
|
||||
|
||||
expect(upsertLocalConfig(configs, "7", { starredAt: null })).toEqual([
|
||||
{ portfolioId: "7", folderId: "10", starredAt: null },
|
||||
]);
|
||||
expect(upsertLocalConfig(configs, "7", { folderId: null })).toEqual([
|
||||
{ portfolioId: "7", folderId: null, starredAt: "2026-07-01T00:00:00Z" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatUpdatedAgo", () => {
|
||||
const now = new Date("2026-07-07T12:00:00Z");
|
||||
|
||||
it("reads naturally at every distance", () => {
|
||||
expect(formatUpdatedAgo("2026-07-07T09:00:00Z", now)).toBe("today");
|
||||
expect(formatUpdatedAgo("2026-07-06T09:00:00Z", now)).toBe("yesterday");
|
||||
expect(formatUpdatedAgo("2026-07-02T12:00:00Z", now)).toBe("5 days ago");
|
||||
expect(formatUpdatedAgo("2026-05-01T00:00:00Z", now)).toBe("2 months ago");
|
||||
});
|
||||
});
|
||||
191
src/app/lib/portfolioHomeView.ts
Normal file
191
src/app/lib/portfolioHomeView.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
// View-model logic for the portfolio home page: pure functions the client
|
||||
// components derive their render state from. Kept free of React and drizzle
|
||||
// so it tests in node and survives refactors on either side.
|
||||
|
||||
export type StatusTone =
|
||||
| "early"
|
||||
| "tender"
|
||||
| "underway"
|
||||
| "ontrack"
|
||||
| "delayed"
|
||||
| "atrisk"
|
||||
| "done"
|
||||
| "review";
|
||||
|
||||
export type StatusDisplay = {
|
||||
label: string;
|
||||
tone: StatusTone;
|
||||
needsAttention: boolean;
|
||||
};
|
||||
|
||||
// The lifecycle enum's raw values ("completion; status: at risk") are storage
|
||||
// strings, not UI copy; this is the single place they become pill text.
|
||||
const STATUS_DISPLAY: Record<string, StatusDisplay> = {
|
||||
scoping: { label: "Scoping", tone: "early", needsAttention: false },
|
||||
survey: { label: "Survey", tone: "early", needsAttention: false },
|
||||
assessment: { label: "Assessment", tone: "early", needsAttention: false },
|
||||
tendering: { label: "Tendering", tone: "tender", needsAttention: false },
|
||||
"project underway": {
|
||||
label: "Underway",
|
||||
tone: "underway",
|
||||
needsAttention: false,
|
||||
},
|
||||
"completion; status: on track": {
|
||||
label: "On track",
|
||||
tone: "ontrack",
|
||||
needsAttention: false,
|
||||
},
|
||||
"completion; status: delayed": {
|
||||
label: "Delayed",
|
||||
tone: "delayed",
|
||||
needsAttention: true,
|
||||
},
|
||||
"completion; status: at risk": {
|
||||
label: "At risk",
|
||||
tone: "atrisk",
|
||||
needsAttention: true,
|
||||
},
|
||||
"completion; status: completed": {
|
||||
label: "Completed",
|
||||
tone: "done",
|
||||
needsAttention: false,
|
||||
},
|
||||
"needs review": { label: "Needs review", tone: "review", needsAttention: true },
|
||||
};
|
||||
|
||||
const UNKNOWN_STATUS: StatusDisplay = {
|
||||
label: "Unknown",
|
||||
tone: "early",
|
||||
needsAttention: false,
|
||||
};
|
||||
|
||||
export function statusDisplay(status: string): StatusDisplay {
|
||||
return STATUS_DISPLAY[status] ?? UNKNOWN_STATUS;
|
||||
}
|
||||
|
||||
export type HomeSort = "updated" | "attention" | "name" | "props";
|
||||
|
||||
// Wire-format rows (ids and dates as strings — see the portfolio-home
|
||||
// route's bigint serialisation).
|
||||
export type LocalConfig = {
|
||||
portfolioId: string;
|
||||
folderId: string | null;
|
||||
starredAt: string | null;
|
||||
};
|
||||
|
||||
export type PortfolioWire = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
goal: string;
|
||||
budget: number | null;
|
||||
cost: number | null;
|
||||
numberOfProperties: number | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type FolderWire = { id: string; name: string; position: number };
|
||||
|
||||
export type HomeData = {
|
||||
portfolios: PortfolioWire[];
|
||||
configs: LocalConfig[];
|
||||
folders: FolderWire[];
|
||||
};
|
||||
|
||||
// Client-side mirror of the lazy DB upsert, used for optimistic cache
|
||||
// updates: no row yet means "all defaults", and a change touches only the
|
||||
// fields it names.
|
||||
export function upsertLocalConfig(
|
||||
configs: LocalConfig[],
|
||||
portfolioId: string,
|
||||
change: Partial<Pick<LocalConfig, "folderId" | "starredAt">>,
|
||||
): LocalConfig[] {
|
||||
const existing = configs.find((c) => c.portfolioId === portfolioId);
|
||||
if (!existing) {
|
||||
return [
|
||||
...configs,
|
||||
{ portfolioId, folderId: null, starredAt: null, ...change },
|
||||
];
|
||||
}
|
||||
return configs.map((c) =>
|
||||
c.portfolioId === portfolioId ? { ...c, ...change } : c,
|
||||
);
|
||||
}
|
||||
|
||||
// Severity for "needs attention first": worst state leads, healthy and
|
||||
// finished work trails.
|
||||
const ATTENTION_RANK: Record<StatusTone, number> = {
|
||||
atrisk: 0,
|
||||
delayed: 1,
|
||||
review: 2,
|
||||
underway: 3,
|
||||
tender: 4,
|
||||
early: 5,
|
||||
ontrack: 6,
|
||||
done: 7,
|
||||
};
|
||||
|
||||
export function sortPortfolios<
|
||||
P extends {
|
||||
name: string;
|
||||
status: string;
|
||||
numberOfProperties: number | null;
|
||||
updatedAt: Date | string;
|
||||
},
|
||||
>(portfolios: P[], sort: HomeSort): P[] {
|
||||
const by: Record<HomeSort, (a: P, b: P) => number> = {
|
||||
updated: (a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
attention: (a, b) =>
|
||||
ATTENTION_RANK[statusDisplay(a.status).tone] -
|
||||
ATTENTION_RANK[statusDisplay(b.status).tone],
|
||||
name: (a, b) => a.name.localeCompare(b.name),
|
||||
props: (a, b) => (b.numberOfProperties ?? 0) - (a.numberOfProperties ?? 0),
|
||||
};
|
||||
return [...portfolios].sort(by[sort]);
|
||||
}
|
||||
|
||||
// Users search in display language ("at risk"), never in enum storage
|
||||
// strings, so matching goes through statusDisplay.
|
||||
export function matchesSearch(
|
||||
portfolio: {
|
||||
name: string;
|
||||
status: string;
|
||||
goal: string;
|
||||
folderName: string | null;
|
||||
},
|
||||
query: string,
|
||||
): boolean {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return [
|
||||
portfolio.name,
|
||||
statusDisplay(portfolio.status).label,
|
||||
portfolio.goal,
|
||||
portfolio.folderName ?? "",
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(q);
|
||||
}
|
||||
|
||||
export function formatUpdatedAgo(updatedAt: string, now: Date): string {
|
||||
const days = Math.floor(
|
||||
(now.getTime() - new Date(updatedAt).getTime()) / 86_400_000,
|
||||
);
|
||||
if (days <= 0) return "today";
|
||||
if (days === 1) return "yesterday";
|
||||
if (days < 28) return `${days} days ago`;
|
||||
const months = Math.round(days / 30);
|
||||
return months === 1 ? "1 month ago" : `${months} months ago`;
|
||||
}
|
||||
|
||||
export function formatMoney(amount: number): string {
|
||||
if (amount >= 1_000_000) {
|
||||
return "£" + (amount / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
|
||||
}
|
||||
if (amount >= 1_000) {
|
||||
return "£" + Math.round(amount / 1_000) + "k";
|
||||
}
|
||||
return "£" + Math.round(amount);
|
||||
}
|
||||
|
|
@ -60,33 +60,34 @@ export function planFolderDeletion({
|
|||
};
|
||||
}
|
||||
|
||||
type ConfigRow = {
|
||||
portfolioId: bigint;
|
||||
folderId: bigint | null;
|
||||
starredAt: Date | null;
|
||||
type ConfigRow<Id> = {
|
||||
portfolioId: Id;
|
||||
folderId: Id | null;
|
||||
starredAt: Date | string | null;
|
||||
};
|
||||
|
||||
type FolderRow = { id: bigint; name: string; position: number };
|
||||
type FolderRow<Id> = { id: Id; name: string; position: number };
|
||||
|
||||
// Assembles the home page's render model from the three per-user reads.
|
||||
// Generic over the portfolio payload so it doesn't care which columns the
|
||||
// page selects.
|
||||
export function groupPortfoliosForHome<P extends { id: bigint }>({
|
||||
// Generic over the portfolio payload and the id type: the server groups with
|
||||
// bigints; the client re-groups optimistically with wire-format string ids.
|
||||
export function groupPortfoliosForHome<Id, P extends { id: Id }>({
|
||||
portfolios,
|
||||
configs,
|
||||
folders,
|
||||
}: {
|
||||
portfolios: P[];
|
||||
configs: ConfigRow[];
|
||||
folders: FolderRow[];
|
||||
configs: Array<ConfigRow<Id>>;
|
||||
folders: Array<FolderRow<Id>>;
|
||||
}): {
|
||||
folders: Array<{ id: bigint; name: string; portfolios: P[] }>;
|
||||
folders: Array<{ id: Id; name: string; portfolios: P[] }>;
|
||||
unfiled: P[];
|
||||
starred: P[];
|
||||
} {
|
||||
const configByPortfolio = new Map(configs.map((c) => [c.portfolioId, c]));
|
||||
const folderOf = (p: P) => configByPortfolio.get(p.id)?.folderId ?? null;
|
||||
const starredAt = (p: P) => configByPortfolio.get(p.id)?.starredAt ?? null;
|
||||
const starredTime = (p: P) => new Date(starredAt(p)!).getTime();
|
||||
|
||||
// Starred float above unstarred; original relative order is otherwise kept.
|
||||
const floatStarred = (group: P[]) =>
|
||||
|
|
@ -104,6 +105,6 @@ export function groupPortfoliosForHome<P extends { id: bigint }>({
|
|||
unfiled: floatStarred(portfolios.filter((p) => folderOf(p) === null)),
|
||||
starred: portfolios
|
||||
.filter((p) => starredAt(p) !== null)
|
||||
.sort((a, b) => starredAt(b)!.getTime() - starredAt(a)!.getTime()),
|
||||
.sort((a, b) => starredTime(b) - starredTime(a)),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue