mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
adding multi organisation connect
This commit is contained in:
parent
49519a8d82
commit
5f0617b691
5 changed files with 321 additions and 145 deletions
181
src/app/api/portfolio/[portfolioId]/organisation/route.test.ts
Normal file
181
src/app/api/portfolio/[portfolioId]/organisation/route.test.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// ── Hoisted mocks ─────────────────────────────────────────────────────────────
|
||||
const {
|
||||
mockGetServerSession,
|
||||
mockDbSelect,
|
||||
mockDbInsert,
|
||||
mockDbDelete,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetServerSession: vi.fn(),
|
||||
mockDbSelect: vi.fn(),
|
||||
mockDbInsert: vi.fn(),
|
||||
mockDbDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession }));
|
||||
vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ AuthOptions: {} }));
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
and: vi.fn((...args: unknown[]) => ({ $and: args })),
|
||||
eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })),
|
||||
inArray: vi.fn((col: unknown, vals: unknown) => ({ $inArray: [col, vals] })),
|
||||
}));
|
||||
vi.mock("@/app/db/schema/portfolio_organisation", () => ({
|
||||
portfolioOrganisation: {
|
||||
portfolioId: {},
|
||||
organisationId: {},
|
||||
id: {},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/app/db/schema/organisation", () => ({
|
||||
organisation: { id: {}, name: {}, hubspotCompanyId: {} },
|
||||
}));
|
||||
vi.mock("@/app/db/db", () => ({
|
||||
db: {
|
||||
get select() { return mockDbSelect; },
|
||||
get insert() { return mockDbInsert; },
|
||||
get delete() { return mockDbDelete; },
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Chain builders ────────────────────────────────────────────────────────────
|
||||
function makeSelectChain(rows: unknown[]) {
|
||||
const self: Record<string, unknown> = {};
|
||||
self["then"] = (resolve: (v: unknown) => unknown, reject: (e: unknown) => unknown) =>
|
||||
Promise.resolve(rows).then(resolve, reject);
|
||||
self["from"] = vi.fn(() => self);
|
||||
self["innerJoin"] = vi.fn(() => self);
|
||||
self["leftJoin"] = vi.fn(() => self);
|
||||
self["where"] = vi.fn(() => self);
|
||||
self["limit"] = vi.fn(() => Promise.resolve(rows));
|
||||
return self;
|
||||
}
|
||||
|
||||
function makeInsertChain() {
|
||||
const self: Record<string, unknown> = {};
|
||||
self["values"] = vi.fn(() => Promise.resolve([]));
|
||||
return self;
|
||||
}
|
||||
|
||||
function makeDeleteChain() {
|
||||
const self: Record<string, unknown> = {};
|
||||
self["where"] = vi.fn(() => Promise.resolve([]));
|
||||
return self;
|
||||
}
|
||||
|
||||
function makeParams(portfolioId = "42") {
|
||||
return Promise.resolve({ portfolioId });
|
||||
}
|
||||
|
||||
function makeRequest(method: string, body?: unknown, portfolioId = "42") {
|
||||
return new NextRequest(
|
||||
`http://localhost/api/portfolio/${portfolioId}/organisation`,
|
||||
{
|
||||
method,
|
||||
...(body ? { body: JSON.stringify(body), headers: { "content-type": "application/json" } } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Subject under test ────────────────────────────────────────────────────────
|
||||
import { GET, POST, DELETE } from "./route";
|
||||
|
||||
describe("GET /portfolio/:id/organisation", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("returns empty array when no orgs linked", async () => {
|
||||
mockDbSelect.mockImplementationOnce(() => makeSelectChain([]));
|
||||
const res = await GET(makeRequest("GET"), { params: makeParams() });
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all linked orgs as array", async () => {
|
||||
const orgs = [
|
||||
{ id: "org-1", name: "Alpha Housing", hubspotCompanyId: "hs-1" },
|
||||
{ id: "org-2", name: "Beta Council", hubspotCompanyId: "hs-2" },
|
||||
];
|
||||
mockDbSelect.mockImplementationOnce(() => makeSelectChain(orgs));
|
||||
const res = await GET(makeRequest("GET"), { params: makeParams() });
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json).toHaveLength(2);
|
||||
expect(json[0].name).toBe("Alpha Housing");
|
||||
expect(json[1].name).toBe("Beta Council");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /portfolio/:id/organisation", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("returns 403 for non-Domna user", async () => {
|
||||
mockGetServerSession.mockResolvedValue({ user: { email: "outsider@other.com" } });
|
||||
const res = await POST(makeRequest("POST", { organisationId: "org-1" }), { params: makeParams() });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 400 when organisationId missing", async () => {
|
||||
mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } });
|
||||
const res = await POST(makeRequest("POST", {}), { params: makeParams() });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 409 when org already linked to this portfolio", async () => {
|
||||
mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } });
|
||||
// existing link found
|
||||
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: "link-1" }]));
|
||||
const res = await POST(makeRequest("POST", { organisationId: "org-1" }), { params: makeParams() });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("adds org without removing existing links", async () => {
|
||||
mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } });
|
||||
// no existing link for this org
|
||||
mockDbSelect.mockImplementationOnce(() => makeSelectChain([]));
|
||||
const insertChain = makeInsertChain();
|
||||
mockDbInsert.mockImplementationOnce(() => insertChain);
|
||||
// return updated list
|
||||
mockDbSelect.mockImplementationOnce(() =>
|
||||
makeSelectChain([
|
||||
{ id: "org-1", name: "Alpha Housing", hubspotCompanyId: "hs-1" },
|
||||
{ id: "org-2", name: "Beta Council", hubspotCompanyId: "hs-2" },
|
||||
]),
|
||||
);
|
||||
const res = await POST(makeRequest("POST", { organisationId: "org-2" }), { params: makeParams() });
|
||||
expect(res.status).toBe(200);
|
||||
// insert called — no delete called
|
||||
expect(mockDbDelete).not.toHaveBeenCalled();
|
||||
expect(insertChain.values).toHaveBeenCalled();
|
||||
const json = await res.json();
|
||||
expect(json).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /portfolio/:id/organisation", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("returns 403 for non-Domna user", async () => {
|
||||
mockGetServerSession.mockResolvedValue({ user: { email: "outsider@other.com" } });
|
||||
const res = await DELETE(makeRequest("DELETE", { organisationId: "org-1" }), { params: makeParams() });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("removes the specific org link", async () => {
|
||||
mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } });
|
||||
const deleteChain = makeDeleteChain();
|
||||
mockDbDelete.mockImplementationOnce(() => deleteChain);
|
||||
const res = await DELETE(makeRequest("DELETE", { organisationId: "org-1" }), { params: makeParams() });
|
||||
expect(res.status).toBe(200);
|
||||
expect(deleteChain.where).toHaveBeenCalled();
|
||||
const json = await res.json();
|
||||
expect(json.success).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 400 when organisationId missing", async () => {
|
||||
mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } });
|
||||
const res = await DELETE(makeRequest("DELETE", {}), { params: makeParams() });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "@/app/db/db";
|
||||
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
|
||||
import { organisation } from "@/app/db/schema/organisation";
|
||||
|
|
@ -10,14 +10,8 @@ function isDomnaUser(email: string | null | undefined): boolean {
|
|||
return !!email?.endsWith("@domna.homes");
|
||||
}
|
||||
|
||||
// GET — fetch the current linked organisation for this portfolio
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ portfolioId: string }> },
|
||||
) {
|
||||
const { portfolioId } = await params;
|
||||
|
||||
const rows = await db
|
||||
function linkedOrgsQuery(portfolioId: string) {
|
||||
return db
|
||||
.select({
|
||||
id: organisation.id,
|
||||
name: organisation.name,
|
||||
|
|
@ -25,13 +19,20 @@ export async function GET(
|
|||
})
|
||||
.from(portfolioOrganisation)
|
||||
.innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id))
|
||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
|
||||
.limit(1);
|
||||
|
||||
return NextResponse.json(rows[0] ?? null);
|
||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
|
||||
}
|
||||
|
||||
// POST — connect an organisation to this portfolio (Domna only)
|
||||
// GET — fetch all linked organisations for this portfolio
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ portfolioId: string }> },
|
||||
) {
|
||||
const { portfolioId } = await params;
|
||||
const rows = await linkedOrgsQuery(portfolioId);
|
||||
return NextResponse.json(rows);
|
||||
}
|
||||
|
||||
// POST — add an organisation link (Domna only, rejects duplicates)
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ portfolioId: string }> },
|
||||
|
|
@ -43,40 +44,40 @@ export async function POST(
|
|||
|
||||
const { portfolioId } = await params;
|
||||
const body = await req.json();
|
||||
const { organisationId } = body as { organisationId: string };
|
||||
const { organisationId } = body as { organisationId?: string };
|
||||
|
||||
if (!organisationId) {
|
||||
return NextResponse.json({ error: "organisationId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Upsert: delete any existing link then insert fresh
|
||||
await db
|
||||
.delete(portfolioOrganisation)
|
||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
|
||||
// Reject if this org is already linked to this portfolio
|
||||
const existing = await db
|
||||
.select({ id: portfolioOrganisation.id })
|
||||
.from(portfolioOrganisation)
|
||||
.where(
|
||||
and(
|
||||
eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)),
|
||||
eq(portfolioOrganisation.organisationId, organisationId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
return NextResponse.json({ error: "Organisation already linked" }, { status: 409 });
|
||||
}
|
||||
|
||||
await db.insert(portfolioOrganisation).values({
|
||||
portfolioId: BigInt(portfolioId),
|
||||
organisationId,
|
||||
});
|
||||
|
||||
// Return the newly linked org
|
||||
const rows = await db
|
||||
.select({
|
||||
id: organisation.id,
|
||||
name: organisation.name,
|
||||
hubspotCompanyId: organisation.hubspotCompanyId,
|
||||
})
|
||||
.from(portfolioOrganisation)
|
||||
.innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id))
|
||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
|
||||
.limit(1);
|
||||
|
||||
return NextResponse.json(rows[0] ?? null);
|
||||
const rows = await linkedOrgsQuery(portfolioId);
|
||||
return NextResponse.json(rows);
|
||||
}
|
||||
|
||||
// DELETE — disconnect the organisation from this portfolio (Domna only)
|
||||
// DELETE — remove a specific organisation link (Domna only)
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ portfolioId: string }> },
|
||||
) {
|
||||
const session = await getServerSession(AuthOptions);
|
||||
|
|
@ -85,10 +86,21 @@ export async function DELETE(
|
|||
}
|
||||
|
||||
const { portfolioId } = await params;
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { organisationId } = body as { organisationId?: string };
|
||||
|
||||
if (!organisationId) {
|
||||
return NextResponse.json({ error: "organisationId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(portfolioOrganisation)
|
||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
|
||||
.where(
|
||||
and(
|
||||
eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)),
|
||||
eq(portfolioOrganisation.organisationId, organisationId),
|
||||
),
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { pgTable, bigint, uuid, timestamp } from "drizzle-orm/pg-core";
|
||||
import { pgTable, bigint, uuid, timestamp, unique } from "drizzle-orm/pg-core";
|
||||
import { portfolio } from "./portfolio";
|
||||
import { organisation } from "./organisation";
|
||||
import { InferModel } from "drizzle-orm";
|
||||
|
|
@ -7,8 +7,7 @@ export const portfolioOrganisation = pgTable("portfolio_organisation", {
|
|||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
portfolioId: bigint("portfolio_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => portfolio.id, { onDelete: "cascade" })
|
||||
.unique(), // one organisation per portfolio
|
||||
.references(() => portfolio.id, { onDelete: "cascade" }),
|
||||
organisationId: uuid("organisation_id")
|
||||
.notNull()
|
||||
.references(() => organisation.id, { onDelete: "cascade" }),
|
||||
|
|
@ -18,7 +17,9 @@ export const portfolioOrganisation = pgTable("portfolio_organisation", {
|
|||
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
});
|
||||
}, (t) => ({
|
||||
portfolioOrgUnique: unique().on(t.portfolioId, t.organisationId),
|
||||
}));
|
||||
|
||||
export type PortfolioOrganisation = InferModel<typeof portfolioOrganisation, "select">;
|
||||
export type NewPortfolioOrganisation = InferModel<typeof portfolioOrganisation, "insert">;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Building2, CheckCircle2, Link2, Link2Off, AlertTriangle, Search } from "lucide-react";
|
||||
import { Building2, Link2, Link2Off, AlertTriangle, Search, CheckCircle2, PlusCircle } from "lucide-react";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import { Input } from "@/app/shadcn_components/ui/input";
|
||||
import {
|
||||
|
|
@ -18,9 +18,9 @@ type OrgSummary = {
|
|||
hubspotCompanyId: string | null;
|
||||
};
|
||||
|
||||
async function fetchCurrentOrg(portfolioId: string): Promise<OrgSummary | null> {
|
||||
async function fetchLinkedOrgs(portfolioId: string): Promise<OrgSummary[]> {
|
||||
const res = await fetch(`/api/portfolio/${portfolioId}/organisation`);
|
||||
if (!res.ok) throw new Error("Failed to fetch linked organisation");
|
||||
if (!res.ok) throw new Error("Failed to fetch linked organisations");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
|
@ -33,25 +33,25 @@ async function fetchAllOrgs(): Promise<OrgSummary[]> {
|
|||
export default function OrganisationLinkCard({ portfolioId }: { portfolioId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [connectOpen, setConnectOpen] = useState(false);
|
||||
const [disconnectOpen, setDisconnectOpen] = useState(false);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [disconnectTarget, setDisconnectTarget] = useState<OrgSummary | null>(null);
|
||||
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
|
||||
// Current linked org
|
||||
const { data: currentOrg, isLoading: loadingCurrent } = useQuery({
|
||||
queryKey: ["portfolio-org", portfolioId],
|
||||
queryFn: () => fetchCurrentOrg(portfolioId),
|
||||
const { data: linkedOrgs = [], isLoading: loadingLinked } = useQuery({
|
||||
queryKey: ["portfolio-orgs", portfolioId],
|
||||
queryFn: () => fetchLinkedOrgs(portfolioId),
|
||||
});
|
||||
|
||||
// All orgs — only fetched when connect modal is open
|
||||
const { data: allOrgs = [], isLoading: loadingOrgs } = useQuery({
|
||||
queryKey: ["all-organisations"],
|
||||
queryFn: fetchAllOrgs,
|
||||
enabled: connectOpen,
|
||||
enabled: addOpen,
|
||||
});
|
||||
|
||||
const linkedOrgIds = useMemo(() => new Set(linkedOrgs.map((o) => o.id)), [linkedOrgs]);
|
||||
|
||||
const connectMutation = useMutation({
|
||||
mutationFn: async (organisationId: string) => {
|
||||
const res = await fetch(`/api/portfolio/${portfolioId}/organisation`, {
|
||||
|
|
@ -63,8 +63,8 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
|
|||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["portfolio-org", portfolioId] });
|
||||
setConnectOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["portfolio-orgs", portfolioId] });
|
||||
setAddOpen(false);
|
||||
setSelectedOrgId(null);
|
||||
setConfirmed(false);
|
||||
setSearchQuery("");
|
||||
|
|
@ -72,116 +72,102 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
|
|||
});
|
||||
|
||||
const disconnectMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
mutationFn: async (organisationId: string) => {
|
||||
const res = await fetch(`/api/portfolio/${portfolioId}/organisation`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ organisationId }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to disconnect organisation");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["portfolio-org", portfolioId] });
|
||||
setDisconnectOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["portfolio-orgs", portfolioId] });
|
||||
setDisconnectTarget(null);
|
||||
},
|
||||
});
|
||||
|
||||
const filteredOrgs = useMemo(
|
||||
() =>
|
||||
allOrgs.filter((o) =>
|
||||
(o.name ?? "").toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
),
|
||||
[allOrgs, searchQuery],
|
||||
const availableOrgs = useMemo(
|
||||
() => allOrgs.filter((o) => !linkedOrgIds.has(o.id) && (o.name ?? "").toLowerCase().includes(searchQuery.toLowerCase())),
|
||||
[allOrgs, linkedOrgIds, searchQuery],
|
||||
);
|
||||
|
||||
const selectedOrg = allOrgs.find((o) => o.id === selectedOrgId) ?? null;
|
||||
|
||||
function openAdd() {
|
||||
setAddOpen(true);
|
||||
setSelectedOrgId(null);
|
||||
setConfirmed(false);
|
||||
setSearchQuery("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-brandblue/15 bg-white shadow-sm mt-4 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-gray-100 bg-brandlightblue/20">
|
||||
<div className="p-2 rounded-lg bg-brandblue/10">
|
||||
<Building2 className="h-4 w-4 text-brandblue" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-brandblue">Organisation Link</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Connect this portfolio to an organisation to enable live project tracking
|
||||
</p>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 bg-brandlightblue/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-brandblue/10">
|
||||
<Building2 className="h-4 w-4 text-brandblue" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-brandblue">Organisation Links</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Connect this portfolio to one or more organisations to enable live project tracking
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 text-xs bg-brandblue hover:bg-brandmidblue"
|
||||
onClick={openAdd}
|
||||
>
|
||||
<PlusCircle className="h-3.5 w-3.5 mr-1.5" />
|
||||
Add Organisation
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4">
|
||||
{loadingCurrent ? (
|
||||
<div className="px-5 py-4 space-y-2">
|
||||
{loadingLinked ? (
|
||||
<div className="h-10 bg-gray-100 rounded-lg animate-pulse w-48" />
|
||||
) : currentOrg ? (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-800">{currentOrg.name ?? "Unnamed organisation"}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Connected · HubSpot ID: {currentOrg.hubspotCompanyId ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
) : linkedOrgs.length === 0 ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Building2 className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs border-brandblue/20 text-brandblue hover:bg-brandlightblue/30"
|
||||
onClick={() => {
|
||||
setConnectOpen(true);
|
||||
setSelectedOrgId(null);
|
||||
setConfirmed(false);
|
||||
setSearchQuery("");
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
Change
|
||||
</Button>
|
||||
<p className="text-sm text-gray-500">No organisations linked</p>
|
||||
</div>
|
||||
) : (
|
||||
linkedOrgs.map((org) => (
|
||||
<div key={org.id} className="flex items-center justify-between gap-4 py-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-800">{org.name ?? "Unnamed organisation"}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Connected · HubSpot ID: {org.hubspotCompanyId ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs border-red-200 text-red-600 hover:bg-red-50"
|
||||
onClick={() => setDisconnectOpen(true)}
|
||||
onClick={() => setDisconnectTarget(org)}
|
||||
>
|
||||
<Link2Off className="h-3.5 w-3.5 mr-1.5" />
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Building2 className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">No organisation linked</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 text-xs bg-brandblue hover:bg-brandmidblue"
|
||||
onClick={() => {
|
||||
setConnectOpen(true);
|
||||
setSelectedOrgId(null);
|
||||
setConfirmed(false);
|
||||
setSearchQuery("");
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
Connect Organisation
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Connect modal ─────────────────────────────────────────────── */}
|
||||
<Dialog open={connectOpen} onOpenChange={(v) => { setConnectOpen(v); if (!v) { setSelectedOrgId(null); setConfirmed(false); setSearchQuery(""); } }}>
|
||||
{/* ── Add Organisation modal ─────────────────────────────────────────────── */}
|
||||
<Dialog open={addOpen} onOpenChange={(v) => { setAddOpen(v); if (!v) { setSelectedOrgId(null); setConfirmed(false); setSearchQuery(""); } }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogTitle className="text-brandblue">Connect Organisation</DialogTitle>
|
||||
<DialogTitle className="text-brandblue">Add Organisation</DialogTitle>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mt-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
|
|
@ -192,14 +178,13 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Org list */}
|
||||
<div className="mt-2 max-h-56 overflow-y-auto rounded-lg border border-gray-200 divide-y divide-gray-100">
|
||||
{loadingOrgs ? (
|
||||
<div className="p-4 text-sm text-gray-400 text-center">Loading…</div>
|
||||
) : filteredOrgs.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-400 text-center">No organisations found</div>
|
||||
) : availableOrgs.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-400 text-center">No organisations available</div>
|
||||
) : (
|
||||
filteredOrgs.map((org) => (
|
||||
availableOrgs.map((org) => (
|
||||
<button
|
||||
key={org.id}
|
||||
onClick={() => setSelectedOrgId(org.id)}
|
||||
|
|
@ -218,7 +203,6 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="flex items-start gap-2.5 p-3 rounded-lg bg-amber-50 border border-amber-200 mt-1">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500 mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-amber-700 leading-relaxed">
|
||||
|
|
@ -226,7 +210,6 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Confirmation checkbox */}
|
||||
<label className="flex items-center gap-2.5 cursor-pointer select-none mt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -238,7 +221,7 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
|
|||
</label>
|
||||
|
||||
<DialogFooter className="mt-2">
|
||||
<Button variant="outline" onClick={() => setConnectOpen(false)} className="text-sm">
|
||||
<Button variant="outline" onClick={() => setAddOpen(false)} className="text-sm">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -252,21 +235,21 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Disconnect confirm dialog ──────────────────────────────────── */}
|
||||
<Dialog open={disconnectOpen} onOpenChange={setDisconnectOpen}>
|
||||
{/* ── Disconnect confirm dialog ──────────────────────────────────────────── */}
|
||||
<Dialog open={!!disconnectTarget} onOpenChange={(v) => { if (!v) setDisconnectTarget(null); }}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogTitle className="text-gray-800">Disconnect organisation?</DialogTitle>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
Are you sure you want to disconnect{" "}
|
||||
<strong>{currentOrg?.name ?? "this organisation"}</strong>?
|
||||
Live project tracking data will no longer be visible to portfolio viewers.
|
||||
<strong>{disconnectTarget?.name ?? "this organisation"}</strong>?
|
||||
Live project tracking data for this organisation will no longer be visible to portfolio viewers.
|
||||
</p>
|
||||
<DialogFooter className="mt-2">
|
||||
<Button variant="outline" onClick={() => setDisconnectOpen(false)} className="text-sm">
|
||||
<Button variant="outline" onClick={() => setDisconnectTarget(null)} className="text-sm">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => disconnectMutation.mutate()}
|
||||
onClick={() => disconnectTarget && disconnectMutation.mutate(disconnectTarget.id)}
|
||||
disabled={disconnectMutation.isPending}
|
||||
className="bg-red-600 hover:bg-red-700 text-white text-sm"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -101,13 +101,12 @@ export default async function LiveReportingPage(props: {
|
|||
redirect("/");
|
||||
}
|
||||
|
||||
// Look up the linked organisation for this portfolio
|
||||
// Look up all linked organisations for this portfolio
|
||||
const link = await db
|
||||
.select({ hubspotCompanyId: organisation.hubspotCompanyId })
|
||||
.from(portfolioOrganisation)
|
||||
.innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id))
|
||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
|
||||
.limit(1);
|
||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
|
||||
|
||||
const pageHeader = (
|
||||
<div className="mb-6">
|
||||
|
|
@ -119,7 +118,9 @@ export default async function LiveReportingPage(props: {
|
|||
</div>
|
||||
);
|
||||
|
||||
if (!link.length || !link[0].hubspotCompanyId) {
|
||||
const companyIds = link.map((l) => l.hubspotCompanyId).filter((id): id is string => !!id);
|
||||
|
||||
if (companyIds.length === 0) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
|
||||
{pageHeader}
|
||||
|
|
@ -141,8 +142,6 @@ export default async function LiveReportingPage(props: {
|
|||
);
|
||||
}
|
||||
|
||||
const companyId = link[0].hubspotCompanyId;
|
||||
|
||||
const rawDeals = await db
|
||||
.select({
|
||||
deal: hubspotDealData,
|
||||
|
|
@ -152,7 +151,7 @@ export default async function LiveReportingPage(props: {
|
|||
.from(hubspotDealData)
|
||||
.leftJoin(coordinatorUser, eq(hubspotDealData.coordinator, coordinatorUser.hubspotOwnerId))
|
||||
.leftJoin(designerUser, eq(hubspotDealData.designer, designerUser.hubspotOwnerId))
|
||||
.where(eq(hubspotDealData.companyId, companyId));
|
||||
.where(inArray(hubspotDealData.companyId, companyIds));
|
||||
|
||||
const deals = rawDeals.map(mapDbRowToHubspotDeal);
|
||||
const trackerData = computeLiveTrackerData(deals);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue