mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
Merge branch 'main' of https://github.com/Hestia-Homes/assessment-model into bug/portfolio-invitations
This commit is contained in:
commit
7cde991871
9 changed files with 10327 additions and 143 deletions
10
src/app/db/migrations/0211_lovely_sue_storm.sql
Normal file
10
src/app/db/migrations/0211_lovely_sue_storm.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
CREATE TABLE "hubspot_project_data" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"project_id" text NOT NULL,
|
||||
"name" text,
|
||||
"created_at" timestamp (6) with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp (6) with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "hubspot_project_data_project_id_unique" UNIQUE("project_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "hubspot_deal_data" ADD COLUMN "project_id" text;
|
||||
10032
src/app/db/migrations/meta/0211_snapshot.json
Normal file
10032
src/app/db/migrations/meta/0211_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1478,6 +1478,13 @@
|
|||
"when": 1779889030729,
|
||||
"tag": "0210_absent_dark_phoenix",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 211,
|
||||
"version": "7",
|
||||
"when": 1779898075572,
|
||||
"tag": "0211_lovely_sue_storm",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ export const hubspotDealData = pgTable("hubspot_deal_data", {
|
|||
dealname: text("dealname"),
|
||||
dealstage: text("dealstage"),
|
||||
companyId: text("company_id"),
|
||||
projectId: text("project_id"),
|
||||
projectCode: text("project_code"),
|
||||
|
||||
landlordPropertyId: text("landlord_property_id"),
|
||||
|
|
|
|||
21
src/app/db/schema/crm/hubspot_project_table.ts
Normal file
21
src/app/db/schema/crm/hubspot_project_table.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { InferModel } from "drizzle-orm";
|
||||
|
||||
export const hubspotProjectData = pgTable("hubspot_project_data", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
|
||||
projectId: text("project_id").notNull().unique(),
|
||||
name: text("name"),
|
||||
|
||||
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
|
||||
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export type HubspotProjectData = InferModel<typeof hubspotProjectData, "select">;
|
||||
export type NewHubspotProjectData = InferModel<typeof hubspotProjectData, "insert">;
|
||||
|
|
@ -8,7 +8,7 @@ import SurveyedResultsPieChart from "./SurveyedResultsPieChart";
|
|||
import DampMouldRiskPanel from "./DampMouldRiskPanel";
|
||||
import CompletionTrendsChart from "./CompletionTrendsChart";
|
||||
import SurveyIssuesPanel from "./SurveyIssuesPanel";
|
||||
import BatchFilter from "./BatchFilter";
|
||||
import GroupFilter, { type GroupNode } from "./GroupFilter";
|
||||
import { STAGE_COLORS, STAGE_ORDER } from "./types";
|
||||
import type {
|
||||
ProjectData,
|
||||
|
|
@ -313,10 +313,10 @@ interface AnalyticsViewProps {
|
|||
) => void;
|
||||
majorConditionDeals: ClassifiedDeal[];
|
||||
totalDeals: number;
|
||||
availableBatches: string[];
|
||||
batchFilter: string[];
|
||||
onBatchFilterChange: (next: string[]) => void;
|
||||
batchFilterActive: boolean;
|
||||
availableGroups: GroupNode[];
|
||||
groupFilter: string[];
|
||||
onGroupFilterChange: (next: string[]) => void;
|
||||
groupFilterActive: boolean;
|
||||
}
|
||||
|
||||
export default function AnalyticsView({
|
||||
|
|
@ -327,18 +327,18 @@ export default function AnalyticsView({
|
|||
onOpenTable,
|
||||
majorConditionDeals,
|
||||
totalDeals,
|
||||
availableBatches,
|
||||
batchFilter,
|
||||
onBatchFilterChange,
|
||||
batchFilterActive,
|
||||
availableGroups,
|
||||
groupFilter,
|
||||
onGroupFilterChange,
|
||||
groupFilterActive,
|
||||
}: AnalyticsViewProps) {
|
||||
const showBatchFilter = availableBatches.length > 0;
|
||||
const showGroupFilter = availableGroups.length > 0;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: project selector + (optional) batch filter + properties count */}
|
||||
{/* Row 1: project selector + (optional) group filter + properties count */}
|
||||
<div
|
||||
className={`grid grid-cols-1 gap-4 ${
|
||||
showBatchFilter ? "sm:grid-cols-3" : "sm:grid-cols-2"
|
||||
showGroupFilter ? "sm:grid-cols-3" : "sm:grid-cols-2"
|
||||
}`}
|
||||
>
|
||||
{/* Project selector */}
|
||||
|
|
@ -369,19 +369,19 @@ export default function AnalyticsView({
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Batch filter — only when current project has batched deals */}
|
||||
{showBatchFilter && (
|
||||
<BatchFilter
|
||||
options={availableBatches}
|
||||
selected={batchFilter}
|
||||
onChange={onBatchFilterChange}
|
||||
{/* Group filter — only when current project has more than one group */}
|
||||
{showGroupFilter && (
|
||||
<GroupFilter
|
||||
options={availableGroups}
|
||||
selected={groupFilter}
|
||||
onChange={onGroupFilterChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Properties in project (label swaps when batch filter is active) */}
|
||||
{/* Properties in project (label swaps when group filter is active) */}
|
||||
<StatCard
|
||||
icon={Home}
|
||||
title={batchFilterActive ? "Properties in Group" : "Properties in Project"}
|
||||
title={groupFilterActive ? "Properties in Group" : "Properties in Project"}
|
||||
value={currentProject.allDeals.length}
|
||||
onClick={() =>
|
||||
onOpenTable(
|
||||
|
|
|
|||
|
|
@ -1,123 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, Layers } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/app/shadcn_components/ui/dropdown-menu";
|
||||
import { Card } from "@/app/shadcn_components/ui/card";
|
||||
|
||||
export const UNBATCHED_KEY = "__UNBATCHED__" as const;
|
||||
|
||||
interface BatchFilterProps {
|
||||
options: string[]; // batch codes present in the current project; may include UNBATCHED_KEY
|
||||
selected: string[]; // empty array = no filter applied (show everything)
|
||||
onChange: (next: string[]) => void;
|
||||
variant?: "card" | "inline";
|
||||
}
|
||||
|
||||
function BatchDropdown({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
triggerClassName,
|
||||
align = "start",
|
||||
}: BatchFilterProps & {
|
||||
triggerClassName: string;
|
||||
align?: "start" | "end";
|
||||
}) {
|
||||
const toggle = (value: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
onChange([...selected, value]);
|
||||
} else {
|
||||
onChange(selected.filter((v) => v !== value));
|
||||
}
|
||||
};
|
||||
|
||||
const label =
|
||||
selected.length === 0
|
||||
? "All groups"
|
||||
: selected
|
||||
.map((s) => (s === UNBATCHED_KEY ? "(Ungrouped)" : s))
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={triggerClassName}>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<Layers className="h-3.5 w-3.5 shrink-0 text-brandblue" />
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
className="min-w-[220px] max-w-[360px] w-[--radix-dropdown-menu-trigger-width]"
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs text-gray-500 flex items-center justify-between">
|
||||
<span>Groups</span>
|
||||
{selected.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([])}
|
||||
className="text-[11px] text-brandblue hover:underline font-medium"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{options.map((opt) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={opt}
|
||||
checked={selected.includes(opt)}
|
||||
onCheckedChange={(val) => toggle(opt, !!val)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="text-sm"
|
||||
>
|
||||
{opt === UNBATCHED_KEY ? (
|
||||
<span className="italic text-gray-500">(Ungrouped)</span>
|
||||
) : (
|
||||
opt
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BatchFilter(props: BatchFilterProps) {
|
||||
const { variant = "card" } = props;
|
||||
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<BatchDropdown
|
||||
{...props}
|
||||
triggerClassName="h-9 px-3 pr-2 border border-brandblue/20 rounded-lg bg-white text-gray-800 font-medium text-sm focus:ring-2 focus:ring-brandblue focus:border-brandblue focus:outline-none transition-all flex items-center gap-2 min-w-[160px] max-w-[280px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col justify-center items-center border border-brandblue/10 bg-gradient-to-br from-brandlightblue/20 to-white shadow-sm hover:shadow-md transition-shadow p-5">
|
||||
<div className="w-full flex flex-col">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-600 mb-3 font-semibold">
|
||||
Filter by Group
|
||||
</p>
|
||||
<BatchDropdown
|
||||
{...props}
|
||||
triggerClassName="w-full px-4 py-2.5 pr-3 border border-brandblue/20 rounded-lg bg-white text-gray-800 font-medium text-sm focus:ring-2 focus:ring-brandblue focus:border-brandblue focus:outline-none transition-all flex items-center justify-between gap-2"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
import { ChevronDown, Layers, Minus } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/app/shadcn_components/ui/dropdown-menu";
|
||||
import { Card } from "@/app/shadcn_components/ui/card";
|
||||
|
||||
export type GroupLeaf = {
|
||||
value: string;
|
||||
// Full label, used in the trigger when this leaf is selected outside the
|
||||
// context of a fully-checked parent.
|
||||
label: string;
|
||||
// Short label rendered under a parent header (the parent already provides
|
||||
// context). Falls back to `label`.
|
||||
shortLabel?: string;
|
||||
muted?: boolean;
|
||||
};
|
||||
|
||||
export type GroupNode =
|
||||
| { kind: "leaf"; leaf: GroupLeaf }
|
||||
| {
|
||||
kind: "parent";
|
||||
label: string;
|
||||
muted?: boolean;
|
||||
children: GroupLeaf[];
|
||||
};
|
||||
|
||||
interface GroupFilterProps {
|
||||
options: GroupNode[];
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
variant?: "card" | "inline";
|
||||
}
|
||||
|
||||
function GroupDropdown({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
triggerClassName,
|
||||
align = "start",
|
||||
}: GroupFilterProps & {
|
||||
triggerClassName: string;
|
||||
align?: "start" | "end";
|
||||
}) {
|
||||
const selectedSet = new Set(selected);
|
||||
|
||||
const toggleLeaf = (value: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
onChange([...selected, value]);
|
||||
} else {
|
||||
onChange(selected.filter((v) => v !== value));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleParent = (childValues: string[]) => {
|
||||
const allChecked = childValues.every((v) => selectedSet.has(v));
|
||||
if (allChecked) {
|
||||
const remove = new Set(childValues);
|
||||
onChange(selected.filter((v) => !remove.has(v)));
|
||||
} else {
|
||||
const merged = new Set(selected);
|
||||
for (const v of childValues) merged.add(v);
|
||||
onChange(Array.from(merged));
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger chunks: fully-selected parents collapse to the parent's label;
|
||||
// standalone leaves and partial-parent children contribute their own labels.
|
||||
const triggerChunks: string[] = [];
|
||||
for (const node of options) {
|
||||
if (node.kind === "leaf") {
|
||||
if (selectedSet.has(node.leaf.value)) triggerChunks.push(node.leaf.label);
|
||||
} else {
|
||||
const childValues = node.children.map((c) => c.value);
|
||||
const allSelected =
|
||||
childValues.length > 0 && childValues.every((v) => selectedSet.has(v));
|
||||
if (allSelected) {
|
||||
triggerChunks.push(node.label);
|
||||
} else {
|
||||
for (const child of node.children) {
|
||||
if (selectedSet.has(child.value)) triggerChunks.push(child.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const triggerLabel =
|
||||
triggerChunks.length === 0
|
||||
? "All groups"
|
||||
: triggerChunks.length === 1
|
||||
? triggerChunks[0]
|
||||
: `${triggerChunks[0]} +${triggerChunks.length - 1}`;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={triggerClassName}>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<Layers className="h-3.5 w-3.5 shrink-0 text-brandblue" />
|
||||
<span className="truncate">{triggerLabel}</span>
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
className="min-w-[240px] max-w-[360px] w-[--radix-dropdown-menu-trigger-width]"
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs text-gray-500 flex items-center justify-between">
|
||||
<span>Groups</span>
|
||||
{selected.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([])}
|
||||
className="text-[11px] text-brandblue hover:underline font-medium"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{options.map((node, i) => {
|
||||
const needsSeparator = i > 0;
|
||||
if (node.kind === "leaf") {
|
||||
return (
|
||||
<Fragment key={`leaf-${node.leaf.value}`}>
|
||||
{needsSeparator && <DropdownMenuSeparator />}
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={selectedSet.has(node.leaf.value)}
|
||||
onCheckedChange={(v) => toggleLeaf(node.leaf.value, !!v)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="text-sm"
|
||||
>
|
||||
{node.leaf.muted ? (
|
||||
<span className="italic text-gray-500">
|
||||
{node.leaf.label}
|
||||
</span>
|
||||
) : (
|
||||
node.leaf.label
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const childValues = node.children.map((c) => c.value);
|
||||
const selectedCount = childValues.filter((v) =>
|
||||
selectedSet.has(v),
|
||||
).length;
|
||||
const parentState: boolean | "indeterminate" =
|
||||
selectedCount === 0
|
||||
? false
|
||||
: selectedCount === childValues.length
|
||||
? true
|
||||
: "indeterminate";
|
||||
|
||||
return (
|
||||
<Fragment key={`parent-${node.label}`}>
|
||||
{needsSeparator && <DropdownMenuSeparator />}
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={parentState}
|
||||
onCheckedChange={() => toggleParent(childValues)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
{parentState === "indeterminate" && (
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<Minus className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
{node.muted ? (
|
||||
<span className="italic text-gray-500">{node.label}</span>
|
||||
) : (
|
||||
node.label
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
{node.children.map((child) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={child.value}
|
||||
checked={selectedSet.has(child.value)}
|
||||
onCheckedChange={(v) => toggleLeaf(child.value, !!v)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="text-sm pl-12"
|
||||
>
|
||||
{child.muted ? (
|
||||
<span className="italic text-gray-500">
|
||||
{child.shortLabel ?? child.label}
|
||||
</span>
|
||||
) : (
|
||||
(child.shortLabel ?? child.label)
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GroupFilter(props: GroupFilterProps) {
|
||||
const { variant = "card" } = props;
|
||||
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<GroupDropdown
|
||||
{...props}
|
||||
triggerClassName="h-9 px-3 pr-2 border border-brandblue/20 rounded-lg bg-white text-gray-800 font-medium text-sm focus:ring-2 focus:ring-brandblue focus:border-brandblue focus:outline-none transition-all flex items-center gap-2 min-w-[160px] max-w-[280px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col justify-center items-center border border-brandblue/10 bg-gradient-to-br from-brandlightblue/20 to-white shadow-sm hover:shadow-md transition-shadow p-5">
|
||||
<div className="w-full flex flex-col">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-600 mb-3 font-semibold">
|
||||
Filter by Group
|
||||
</p>
|
||||
<GroupDropdown
|
||||
{...props}
|
||||
triggerClassName="w-full px-4 py-2.5 pr-3 border border-brandblue/20 rounded-lg bg-white text-gray-800 font-medium text-sm focus:ring-2 focus:ring-brandblue focus:border-brandblue focus:outline-none transition-all flex items-center justify-between gap-2"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
Loading…
Add table
Reference in a new issue