mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
adding removal requests
This commit is contained in:
parent
f04fb8f026
commit
ffe79a27e4
4 changed files with 320 additions and 7 deletions
|
|
@ -34,6 +34,8 @@ export default function LiveTracker({
|
|||
userCapability,
|
||||
approvalsByDeal,
|
||||
portfolioId,
|
||||
userRole,
|
||||
userEmail,
|
||||
}: LiveTrackerProps) {
|
||||
// ── Tab state ────────────────────────────────────────────────────────
|
||||
const [activeTab, setActiveTab] = useState<"analytics" | "properties" | "documents" | "measures">(
|
||||
|
|
@ -360,6 +362,9 @@ export default function LiveTracker({
|
|||
deal={detailDeal}
|
||||
portfolioId={portfolioId}
|
||||
onClose={() => setDetailDeal(null)}
|
||||
userRole={userRole}
|
||||
userCapability={userCapability}
|
||||
userEmail={userEmail}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,19 +1,263 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
} from "@/app/shadcn_components/ui/drawer";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/app/shadcn_components/ui/dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/app/shadcn_components/ui/tooltip";
|
||||
import { STAGE_COLORS } from "./types";
|
||||
import type { ClassifiedDeal } from "./types";
|
||||
import type { ClassifiedDeal, PortfolioCapabilityType, RemovalRequest } from "./types";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Removal request section
|
||||
// -----------------------------------------------------------------------
|
||||
const WRITE_ROLES = ["creator", "admin", "write"];
|
||||
|
||||
function RemovalRequestSection({
|
||||
dealId,
|
||||
portfolioId,
|
||||
userRole,
|
||||
userCapability,
|
||||
}: {
|
||||
dealId: string;
|
||||
portfolioId: string;
|
||||
userRole: string;
|
||||
userCapability: PortfolioCapabilityType;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [reviewing, setReviewing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canRequest = WRITE_ROLES.includes(userRole);
|
||||
const isApprover = userCapability.includes("approver");
|
||||
|
||||
const { data, isLoading } = useQuery<{ requests: RemovalRequest[] }>({
|
||||
queryKey: ["removalRequests", portfolioId, dealId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/portfolio/${portfolioId}/removal-requests?dealId=${dealId}`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to fetch removal requests");
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const pendingRequest = data?.requests?.find((r) => r.status === "pending") ?? null;
|
||||
const latestResolvedRequest = data?.requests?.find((r) => r.status !== "pending") ?? null;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!reason.trim()) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/portfolio/${portfolioId}/removal-requests`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ hubspotDealId: dealId, reason: reason.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
setError(json.error ?? "Failed to submit request");
|
||||
return;
|
||||
}
|
||||
setDialogOpen(false);
|
||||
setReason("");
|
||||
queryClient.invalidateQueries({ queryKey: ["removalRequests", portfolioId, dealId] });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReview(requestId: string, action: "approved" | "declined") {
|
||||
setReviewing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/portfolio/${portfolioId}/removal-requests`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ requestId: Number(requestId), action }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
setError(json.error ?? "Failed to review request");
|
||||
return;
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["removalRequests", portfolioId, dealId] });
|
||||
} finally {
|
||||
setReviewing(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-xs text-gray-400 py-2">Loading…</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{error && (
|
||||
<p className="text-xs text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Pending request — visible to everyone */}
|
||||
{pendingRequest && (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3.5 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-amber-700 bg-amber-100 px-2 py-0.5 rounded-full border border-amber-200">
|
||||
Pending Removal Request
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-700 leading-relaxed">{pendingRequest.reason}</p>
|
||||
<p className="text-[11px] text-gray-400">
|
||||
Requested by <span className="font-medium text-gray-600">{pendingRequest.requestedByEmail}</span>
|
||||
{" · "}
|
||||
{formatDateTime(pendingRequest.requestedAt)}
|
||||
</p>
|
||||
{/* Approver actions */}
|
||||
{isApprover && (
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => handleReview(pendingRequest.id, "approved")}
|
||||
disabled={reviewing}
|
||||
className="flex-1 text-xs font-medium px-3 py-1.5 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Approve Removal
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReview(pendingRequest.id, "declined")}
|
||||
disabled={reviewing}
|
||||
className="flex-1 text-xs font-medium px-3 py-1.5 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-100 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Most recent resolved request */}
|
||||
{!pendingRequest && latestResolvedRequest && (
|
||||
<div className={`rounded-xl border p-3.5 space-y-1.5 ${
|
||||
latestResolvedRequest.status === "approved"
|
||||
? "border-emerald-200 bg-emerald-50"
|
||||
: "border-gray-200 bg-gray-50"
|
||||
}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full border ${
|
||||
latestResolvedRequest.status === "approved"
|
||||
? "text-emerald-700 bg-emerald-100 border-emerald-200"
|
||||
: "text-gray-600 bg-gray-100 border-gray-200"
|
||||
}`}>
|
||||
{latestResolvedRequest.status === "approved" ? "Removal Approved" : "Removal Declined"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 leading-relaxed">{latestResolvedRequest.reason}</p>
|
||||
<p className="text-[11px] text-gray-400">
|
||||
Requested by <span className="font-medium text-gray-600">{latestResolvedRequest.requestedByEmail}</span>
|
||||
{" · "}
|
||||
{formatDateTime(latestResolvedRequest.requestedAt)}
|
||||
</p>
|
||||
{latestResolvedRequest.reviewedByEmail && (
|
||||
<p className="text-[11px] text-gray-400">
|
||||
{latestResolvedRequest.status === "approved" ? "Approved" : "Declined"} by{" "}
|
||||
<span className="font-medium text-gray-600">{latestResolvedRequest.reviewedByEmail}</span>
|
||||
{latestResolvedRequest.reviewedAt && ` · ${formatDateTime(latestResolvedRequest.reviewedAt)}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Request button — only shown when no pending request exists */}
|
||||
{!pendingRequest && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block w-full">
|
||||
<button
|
||||
onClick={() => { if (canRequest) setDialogOpen(true); }}
|
||||
disabled={!canRequest}
|
||||
className={`w-full flex items-center justify-center gap-2 text-xs font-medium px-3 py-2 rounded-lg border transition-colors ${
|
||||
canRequest
|
||||
? "border-red-200 text-red-600 hover:bg-red-50 bg-white"
|
||||
: "border-gray-100 text-gray-300 bg-gray-50 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Request Removal from Project
|
||||
</button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!canRequest && (
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
Not available with read-only permissions
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Reason dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={(v) => { if (!v) { setDialogOpen(false); setReason(""); setError(null); } }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold text-gray-800">
|
||||
Request Removal from Project
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<p className="text-xs text-gray-500 leading-relaxed">
|
||||
Please provide a reason why this property should be removed from the project. This will be recorded for audit purposes.
|
||||
</p>
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-800 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-200 focus:border-red-300 resize-none"
|
||||
placeholder="Reason for removal…"
|
||||
rows={4}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<button
|
||||
onClick={() => { setDialogOpen(false); setReason(""); setError(null); }}
|
||||
className="text-xs font-medium px-4 py-2 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!reason.trim() || submitting}
|
||||
className="text-xs font-medium px-4 py-2 rounded-lg bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{submitting ? "Submitting…" : "Submit Request"}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Approval log types + helpers
|
||||
|
|
@ -223,9 +467,18 @@ interface PropertyDetailDrawerProps {
|
|||
deal: ClassifiedDeal | null;
|
||||
portfolioId: string;
|
||||
onClose: () => void;
|
||||
userRole: string;
|
||||
userCapability: PortfolioCapabilityType;
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
export default function PropertyDetailDrawer({ deal, portfolioId, onClose }: PropertyDetailDrawerProps) {
|
||||
export default function PropertyDetailDrawer({
|
||||
deal,
|
||||
portfolioId,
|
||||
onClose,
|
||||
userRole,
|
||||
userCapability,
|
||||
}: PropertyDetailDrawerProps) {
|
||||
const open = !!deal;
|
||||
const [isLogOpen, setIsLogOpen] = useState(false);
|
||||
|
||||
|
|
@ -338,6 +591,19 @@ export default function PropertyDetailDrawer({ deal, portfolioId, onClose }: Pro
|
|||
<MilestoneTimeline deal={deal} />
|
||||
</div>
|
||||
|
||||
{/* Removal request */}
|
||||
<div className="border-t border-gray-100 pt-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">
|
||||
Project Removal
|
||||
</h3>
|
||||
<RemovalRequestSection
|
||||
dealId={deal.dealId}
|
||||
portfolioId={portfolioId}
|
||||
userRole={userRole}
|
||||
userCapability={userCapability}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Approval log — collapsible */}
|
||||
<div className="border-t border-gray-100 pt-4">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
|
|||
import { uploadedFiles } from "@/app/db/schema/uploaded_files";
|
||||
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
|
||||
import { organisation } from "@/app/db/schema/organisation";
|
||||
import { portfolioCapabilities } from "@/app/db/schema/portfolio";
|
||||
import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio";
|
||||
import { dealMeasureApprovals } from "@/app/db/schema/approvals";
|
||||
import { user as userTable } from "@/app/db/schema/users";
|
||||
import type { HubspotDeal, DocStatusMap, DocStatus, PortfolioCapabilityType, ApprovalsByDeal } from "./types";
|
||||
|
|
@ -149,6 +149,30 @@ export default async function LiveReportingPage(props: {
|
|||
}
|
||||
}
|
||||
|
||||
// Fetch current user's portfolio role (creator / admin / write / read)
|
||||
let userRole = "read";
|
||||
if (userEmail) {
|
||||
const userRow = await db
|
||||
.select({ id: userTable.id })
|
||||
.from(userTable)
|
||||
.where(eq(userTable.email, userEmail))
|
||||
.limit(1);
|
||||
|
||||
if (userRow[0]) {
|
||||
const roleRow = await db
|
||||
.select({ role: portfolioUsers.role })
|
||||
.from(portfolioUsers)
|
||||
.where(
|
||||
and(
|
||||
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
|
||||
eq(portfolioUsers.userId, userRow[0].id),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
userRole = roleRow[0]?.role ?? "read";
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch currently approved measures for all deals in scope
|
||||
const approvalsByDeal: ApprovalsByDeal = {};
|
||||
const dealIds = deals.map((d) => d.dealId).filter(Boolean);
|
||||
|
|
@ -250,6 +274,8 @@ export default async function LiveReportingPage(props: {
|
|||
userCapability={userCapability}
|
||||
approvalsByDeal={approvalsByDeal}
|
||||
portfolioId={portfolioId}
|
||||
userRole={userRole}
|
||||
userEmail={userEmail ?? ""}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -169,6 +169,20 @@ export type PortfolioCapabilityType = ("approver" | "contractor")[];
|
|||
// Approved measure names per HubSpot deal ID
|
||||
export type ApprovalsByDeal = Record<string, string[]>;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Removal request record returned by the API
|
||||
// -----------------------------------------------------------------------
|
||||
export type RemovalRequest = {
|
||||
id: string;
|
||||
hubspotDealId: string;
|
||||
status: "pending" | "approved" | "declined";
|
||||
reason: string;
|
||||
requestedByEmail: string;
|
||||
requestedAt: string;
|
||||
reviewedByEmail: string | null;
|
||||
reviewedAt: string | null;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Top-level props for LiveTracker (client root)
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -180,6 +194,8 @@ export type LiveTrackerProps = {
|
|||
userCapability: PortfolioCapabilityType;
|
||||
approvalsByDeal: ApprovalsByDeal;
|
||||
portfolioId: string;
|
||||
userRole: string;
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue