mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
added in addition requests
This commit is contained in:
parent
20e5eaff9a
commit
09931cb878
9 changed files with 7092 additions and 60 deletions
|
|
@ -71,6 +71,7 @@ export async function GET(
|
|||
.select({
|
||||
id: propertyRemovalRequests.id,
|
||||
hubspotDealId: propertyRemovalRequests.hubspotDealId,
|
||||
type: propertyRemovalRequests.type,
|
||||
reason: propertyRemovalRequests.reason,
|
||||
status: propertyRemovalRequests.status,
|
||||
requestedAt: propertyRemovalRequests.requestedAt,
|
||||
|
|
@ -111,6 +112,7 @@ export async function GET(
|
|||
return {
|
||||
id: String(row.id),
|
||||
hubspotDealId: row.hubspotDealId,
|
||||
type: row.type,
|
||||
reason: row.reason,
|
||||
status: row.status,
|
||||
requestedByEmail: row.requestedByEmail,
|
||||
|
|
@ -131,6 +133,7 @@ export async function GET(
|
|||
const postSchema = z.object({
|
||||
hubspotDealId: z.string().min(1),
|
||||
reason: z.string().min(1, "Reason is required"),
|
||||
type: z.enum(["removal", "re_addition"]).default("removal"),
|
||||
});
|
||||
|
||||
// POST /api/portfolio/[portfolioId]/removal-requests
|
||||
|
|
@ -171,35 +174,58 @@ export async function POST(
|
|||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
const { hubspotDealId, reason } = parsed.data;
|
||||
const { hubspotDealId, reason, type } = parsed.data;
|
||||
|
||||
try {
|
||||
// Block if there's already a pending request for this deal
|
||||
const existing = await db
|
||||
.select({ id: propertyRemovalRequests.id })
|
||||
// Fetch the most recent request for this deal to determine current state
|
||||
const [latest] = await db
|
||||
.select({
|
||||
status: propertyRemovalRequests.status,
|
||||
type: propertyRemovalRequests.type,
|
||||
})
|
||||
.from(propertyRemovalRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(propertyRemovalRequests.hubspotDealId, hubspotDealId),
|
||||
eq(propertyRemovalRequests.portfolioId, BigInt(portfolioId)),
|
||||
eq(propertyRemovalRequests.status, "pending"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(propertyRemovalRequests.requestedAt))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
if (latest?.status === "pending") {
|
||||
return NextResponse.json(
|
||||
{ error: "A pending removal request already exists for this property" },
|
||||
{ error: "A pending request already exists for this property" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "removal" && latest?.type === "removal" && latest?.status === "approved") {
|
||||
return NextResponse.json(
|
||||
{ error: "This property has already been removed" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "re_addition") {
|
||||
const isRemoved =
|
||||
(latest?.type === "removal" && latest?.status === "approved") ||
|
||||
(latest?.type === "re_addition" && latest?.status === "declined");
|
||||
if (!isRemoved) {
|
||||
return NextResponse.json(
|
||||
{ error: "This property is not currently removed" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(propertyRemovalRequests)
|
||||
.values({
|
||||
hubspotDealId,
|
||||
portfolioId: BigInt(portfolioId),
|
||||
reason,
|
||||
type,
|
||||
status: "pending",
|
||||
requestedBy: requestingUser.id,
|
||||
})
|
||||
|
|
@ -207,6 +233,7 @@ export async function POST(
|
|||
|
||||
void syncRemovalRequestToHubSpot({
|
||||
hubspotDealId,
|
||||
type,
|
||||
status: "pending",
|
||||
reason,
|
||||
requestedByEmail: requestingUser.email,
|
||||
|
|
@ -268,6 +295,7 @@ export async function PATCH(
|
|||
const target = await db
|
||||
.select({
|
||||
id: propertyRemovalRequests.id,
|
||||
type: propertyRemovalRequests.type,
|
||||
status: propertyRemovalRequests.status,
|
||||
hubspotDealId: propertyRemovalRequests.hubspotDealId,
|
||||
reason: propertyRemovalRequests.reason,
|
||||
|
|
@ -300,6 +328,7 @@ export async function PATCH(
|
|||
|
||||
void syncRemovalRequestToHubSpot({
|
||||
hubspotDealId: target[0].hubspotDealId,
|
||||
type: (target[0].type ?? "removal") as "removal" | "re_addition",
|
||||
status: action,
|
||||
reason: target[0].reason,
|
||||
requestedByEmail: target[0].requestedByEmail,
|
||||
|
|
|
|||
1
src/app/db/migrations/0180_ambitious_jane_foster.sql
Normal file
1
src/app/db/migrations/0180_ambitious_jane_foster.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "property_removal_requests" ADD COLUMN "type" text DEFAULT 'removal' NOT NULL;
|
||||
1
src/app/db/migrations/0180_removal_request_type.sql
Normal file
1
src/app/db/migrations/0180_removal_request_type.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "property_removal_requests" ADD COLUMN IF NOT EXISTS "type" text NOT NULL DEFAULT 'removal';
|
||||
6917
src/app/db/migrations/meta/0180_snapshot.json
Normal file
6917
src/app/db/migrations/meta/0180_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1261,6 +1261,13 @@
|
|||
"when": 1776459924335,
|
||||
"tag": "0179_mighty_cardiac",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 180,
|
||||
"version": "7",
|
||||
"when": 1776683523665,
|
||||
"tag": "0180_ambitious_jane_foster",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ export const propertyRemovalRequests = pgTable(
|
|||
.notNull()
|
||||
.references(() => portfolio.id),
|
||||
reason: text("reason").notNull(),
|
||||
// 'removal' | 're_addition'
|
||||
type: text("type").notNull().default("removal"),
|
||||
// 'pending' | 'approved' | 'declined'
|
||||
status: text("status").notNull().default("pending"),
|
||||
requestedBy: bigint("requested_by", { mode: "bigint" })
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { getHubSpotClient } from "./client";
|
|||
|
||||
export async function syncRemovalRequestToHubSpot(params: {
|
||||
hubspotDealId: string;
|
||||
type: "removal" | "re_addition";
|
||||
status: "pending" | "approved" | "declined";
|
||||
reason: string;
|
||||
requestedByEmail: string;
|
||||
|
|
@ -10,12 +11,22 @@ export async function syncRemovalRequestToHubSpot(params: {
|
|||
try {
|
||||
const client = getHubSpotClient();
|
||||
|
||||
const statusLabel =
|
||||
params.status === "pending"
|
||||
? "Removal Request In Progress"
|
||||
: params.status === "approved"
|
||||
? "Removed From Project"
|
||||
: "";
|
||||
let statusLabel: string;
|
||||
if (params.type === "removal") {
|
||||
statusLabel =
|
||||
params.status === "pending"
|
||||
? "Removal Request In Progress"
|
||||
: params.status === "approved"
|
||||
? "Removed From Project"
|
||||
: "";
|
||||
} else {
|
||||
statusLabel =
|
||||
params.status === "pending"
|
||||
? "Re-addition Requested"
|
||||
: params.status === "approved"
|
||||
? ""
|
||||
: "Removed From Project";
|
||||
}
|
||||
|
||||
let log = `Requested by: ${params.requestedByEmail}\nReason: ${params.reason}`;
|
||||
if (params.reviewedByEmail) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown, Trash2 } from "lucide-react";
|
||||
import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown, Trash2, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
|
|
@ -43,7 +43,7 @@ function RemovalRequestSection({
|
|||
userCapability: PortfolioCapabilityType;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogType, setDialogType] = useState<"removal" | "re_addition" | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [reviewing, setReviewing] = useState(false);
|
||||
|
|
@ -64,26 +64,45 @@ function RemovalRequestSection({
|
|||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const pendingRequest = data?.requests?.find((r) => r.status === "pending") ?? null;
|
||||
const latestResolvedRequest = data?.requests?.find((r) => r.status !== "pending") ?? null;
|
||||
const latest = data?.requests?.[0] ?? null;
|
||||
|
||||
// Derive effective state from the most recent request
|
||||
type EffectiveState = "active" | "pending_removal" | "removed" | "pending_re_addition";
|
||||
const effectiveState: EffectiveState = (() => {
|
||||
if (!latest) return "active";
|
||||
if (latest.status === "pending") {
|
||||
return latest.type === "re_addition" ? "pending_re_addition" : "pending_removal";
|
||||
}
|
||||
if (latest.type === "removal" && latest.status === "approved") return "removed";
|
||||
if (latest.type === "re_addition" && latest.status === "declined") return "removed";
|
||||
return "active";
|
||||
})();
|
||||
|
||||
const pendingRequest = latest?.status === "pending" ? latest : null;
|
||||
const latestResolvedRequest = latest?.status !== "pending" ? latest : null;
|
||||
|
||||
function closeDialog() {
|
||||
setDialogType(null);
|
||||
setReason("");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!reason.trim()) return;
|
||||
if (!reason.trim() || !dialogType) 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() }),
|
||||
body: JSON.stringify({ hubspotDealId: dealId, reason: reason.trim(), type: dialogType }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
setError(json.error ?? "Failed to submit request");
|
||||
return;
|
||||
}
|
||||
setDialogOpen(false);
|
||||
setReason("");
|
||||
closeDialog();
|
||||
queryClient.invalidateQueries({ queryKey: ["removalRequests", portfolioId, dealId] });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
|
|
@ -110,6 +129,13 @@ function RemovalRequestSection({
|
|||
}
|
||||
}
|
||||
|
||||
function resolvedLabel(req: RemovalRequest): string {
|
||||
if (req.type === "re_addition") {
|
||||
return req.status === "approved" ? "Re-addition Approved" : "Re-addition Declined";
|
||||
}
|
||||
return req.status === "approved" ? "Removal Approved" : "Removal Declined";
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-xs text-gray-400 py-2">Loading…</p>;
|
||||
}
|
||||
|
|
@ -125,7 +151,7 @@ function RemovalRequestSection({
|
|||
<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
|
||||
{pendingRequest.type === "re_addition" ? "Pending Re-addition Request" : "Pending Removal Request"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-700 leading-relaxed">{pendingRequest.reason}</p>
|
||||
|
|
@ -134,7 +160,6 @@ function RemovalRequestSection({
|
|||
{" · "}
|
||||
{formatDateTime(pendingRequest.requestedAt)}
|
||||
</p>
|
||||
{/* Approver actions */}
|
||||
{isApprover && (
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
|
|
@ -142,7 +167,7 @@ function RemovalRequestSection({
|
|||
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
|
||||
{pendingRequest.type === "re_addition" ? "Approve Re-addition" : "Approve Removal"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReview(pendingRequest.id, "declined")}
|
||||
|
|
@ -157,7 +182,7 @@ function RemovalRequestSection({
|
|||
)}
|
||||
|
||||
{/* Most recent resolved request */}
|
||||
{!pendingRequest && latestResolvedRequest && (
|
||||
{latestResolvedRequest && (
|
||||
<div className={`rounded-xl border p-3.5 space-y-1.5 ${
|
||||
latestResolvedRequest.status === "approved"
|
||||
? "border-emerald-200 bg-emerald-50"
|
||||
|
|
@ -169,7 +194,7 @@ function RemovalRequestSection({
|
|||
? "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"}
|
||||
{resolvedLabel(latestResolvedRequest)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 leading-relaxed">{latestResolvedRequest.reason}</p>
|
||||
|
|
@ -188,50 +213,84 @@ function RemovalRequestSection({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Request button — only shown when no pending request exists */}
|
||||
{/* Action buttons — shown when no pending request */}
|
||||
{!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>
|
||||
<>
|
||||
{effectiveState === "active" && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block w-full">
|
||||
<button
|
||||
onClick={() => { if (canRequest) setDialogType("removal"); }}
|
||||
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>
|
||||
)}
|
||||
|
||||
{effectiveState === "removed" && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block w-full">
|
||||
<button
|
||||
onClick={() => { if (canRequest) setDialogType("re_addition"); }}
|
||||
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-blue-200 text-blue-600 hover:bg-blue-50 bg-white"
|
||||
: "border-gray-100 text-gray-300 bg-gray-50 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Request Re-addition to 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); } }}>
|
||||
{/* Shared dialog for removal and re-addition requests */}
|
||||
<Dialog open={dialogType !== null} onOpenChange={(v) => { if (!v) closeDialog(); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold text-gray-800">
|
||||
Request Removal from Project
|
||||
{dialogType === "re_addition" ? "Request Re-addition to Project" : "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.
|
||||
{dialogType === "re_addition"
|
||||
? "Please provide a reason why this property should be re-added to the project. This will be recorded for audit purposes."
|
||||
: "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…"
|
||||
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-blue-200 focus:border-blue-300 resize-none"
|
||||
placeholder={dialogType === "re_addition" ? "Reason for re-addition…" : "Reason for removal…"}
|
||||
rows={4}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
|
|
@ -240,7 +299,7 @@ function RemovalRequestSection({
|
|||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<button
|
||||
onClick={() => { setDialogOpen(false); setReason(""); setError(null); }}
|
||||
onClick={closeDialog}
|
||||
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
|
||||
|
|
@ -248,7 +307,11 @@ function RemovalRequestSection({
|
|||
<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"
|
||||
className={`text-xs font-medium px-4 py-2 rounded-lg text-white disabled:opacity-50 transition-colors ${
|
||||
dialogType === "re_addition"
|
||||
? "bg-blue-600 hover:bg-blue-700"
|
||||
: "bg-red-600 hover:bg-red-700"
|
||||
}`}
|
||||
>
|
||||
{submitting ? "Submitting…" : "Submit Request"}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@ export type ApprovalsByDeal = Record<string, string[]>;
|
|||
export type RemovalRequest = {
|
||||
id: string;
|
||||
hubspotDealId: string;
|
||||
type: "removal" | "re_addition";
|
||||
status: "pending" | "approved" | "declined";
|
||||
reason: string;
|
||||
requestedByEmail: string;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue