mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
merging the madness
This commit is contained in:
commit
9b9b9e2848
21 changed files with 20714 additions and 205 deletions
|
|
@ -5,10 +5,11 @@
|
|||
"remoteUser": "vscode",
|
||||
"workspaceFolder": "/workspaces/assessment-model",
|
||||
"postStartCommand": "bash .devcontainer/post-install.sh",
|
||||
"forwardPorts": [3000],
|
||||
"forwardPorts": [3000], # For vscode
|
||||
"appPort": ["3000:3000"], # For devcontainer shell
|
||||
"mounts": [
|
||||
// Optional, just makes getting from Downloads (local env) easier
|
||||
// "source=${localEnv:HOME},target=/workspaces/home,type=bind"
|
||||
"source=${localEnv:HOME},target=/workspaces/home,type=bind"
|
||||
],
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
|
|
|
|||
102
devcontainer.sh
Normal file
102
devcontainer.sh
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# devcontainer.sh — devcontainer helper for this repo
|
||||
#
|
||||
# Usage:
|
||||
# ./devcontainer.sh <command>
|
||||
#
|
||||
# Commands:
|
||||
# up build + start the devcontainer (idempotent)
|
||||
# shell attach a bash shell; auto-ups if not running
|
||||
# down stop the devcontainer
|
||||
# rebuild remove + rebuild from scratch, no cache
|
||||
#
|
||||
# Examples:
|
||||
# ./devcontainer.sh shell # one-shot: up if needed, then bash
|
||||
# ./devcontainer.sh rebuild
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
|
||||
REPO_ROOT="${SCRIPT_DIR}"
|
||||
CONFIG_PATH="${REPO_ROOT}/.devcontainer/devcontainer.json"
|
||||
|
||||
VALID_COMMANDS=(up shell down rebuild)
|
||||
|
||||
# --- helpers ---------------------------------------------------------------
|
||||
|
||||
usage() {
|
||||
sed -n '3,15p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
die() {
|
||||
echo "error: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
in_list() {
|
||||
local needle="$1"
|
||||
shift
|
||||
local item
|
||||
for item in "$@"; do
|
||||
[[ "${item}" == "${needle}" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
container_id() {
|
||||
# Find the running container for this repo via devcontainer labels.
|
||||
docker ps -q \
|
||||
--filter "label=devcontainer.local_folder=${REPO_ROOT}" \
|
||||
--filter "label=devcontainer.config_file=${CONFIG_PATH}"
|
||||
}
|
||||
|
||||
# --- argument parsing ------------------------------------------------------
|
||||
|
||||
[[ $# -eq 1 ]] || usage 1
|
||||
|
||||
COMMAND="$1"
|
||||
|
||||
in_list "${COMMAND}" "${VALID_COMMANDS[@]}" \
|
||||
|| die "invalid command '${COMMAND}' (expected: ${VALID_COMMANDS[*]})"
|
||||
|
||||
[[ -f "${CONFIG_PATH}" ]] || die "config not found: ${CONFIG_PATH}"
|
||||
|
||||
DC_ARGS=(--workspace-folder "${REPO_ROOT}")
|
||||
|
||||
# --- dispatch --------------------------------------------------------------
|
||||
|
||||
case "${COMMAND}" in
|
||||
up)
|
||||
echo ">> bringing up devcontainer"
|
||||
devcontainer up "${DC_ARGS[@]}"
|
||||
;;
|
||||
|
||||
shell)
|
||||
# Auto-up if not already running. `devcontainer up` is idempotent —
|
||||
# it reuses an existing container, so this is cheap on warm starts.
|
||||
if [[ -z "$(container_id)" ]]; then
|
||||
echo ">> devcontainer not running, bringing it up first"
|
||||
devcontainer up "${DC_ARGS[@]}"
|
||||
fi
|
||||
echo ">> attaching shell"
|
||||
devcontainer exec "${DC_ARGS[@]}" bash 2>/dev/null \
|
||||
|| devcontainer exec "${DC_ARGS[@]}" sh
|
||||
;;
|
||||
|
||||
down)
|
||||
cid="$(container_id)"
|
||||
if [[ -z "${cid}" ]]; then
|
||||
echo ">> devcontainer not running, nothing to stop"
|
||||
exit 0
|
||||
fi
|
||||
echo ">> stopping devcontainer"
|
||||
docker stop "${cid}"
|
||||
;;
|
||||
|
||||
rebuild)
|
||||
echo ">> rebuilding devcontainer from scratch"
|
||||
devcontainer up "${DC_ARGS[@]}" --remove-existing-container --build-no-cache
|
||||
;;
|
||||
esac
|
||||
|
|
@ -29,7 +29,6 @@ export async function GET(req: Request) {
|
|||
s3FileBucket: uploadedFiles.s3FileBucket,
|
||||
s3UploadTimestamp: uploadedFiles.s3UploadTimestamp,
|
||||
fileType: uploadedFiles.fileType,
|
||||
source: uploadedFiles.source,
|
||||
uprn: uploadedFiles.uprn,
|
||||
landlordPropertyId: uploadedFiles.landlordPropertyId,
|
||||
measureName: uploadedFiles.measureName,
|
||||
|
|
@ -41,8 +40,7 @@ export async function GET(req: Request) {
|
|||
id: String(row.id),
|
||||
s3FileKey: row.s3FileKey,
|
||||
s3FileBucket: row.s3FileBucket,
|
||||
docType: row.fileType ?? null,
|
||||
source: row.source ?? null,
|
||||
docType: row.fileType ?? "unknown",
|
||||
s3UploadTimestamp: row.s3UploadTimestamp.toISOString(),
|
||||
uprn: row.uprn !== null ? String(row.uprn) : null,
|
||||
landlordPropertyId: row.landlordPropertyId,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
import { cn } from "@/lib/utils";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
// import BulkUploadComingSoonModal from "@/app/components/portfolio/BulkUploadComingSoonModal";
|
||||
import BulkUploadComingSoonModal from "@/app/components/portfolio/BulkUploadComingSoonModal";
|
||||
|
||||
interface AddNewProps {
|
||||
portfolioId: string;
|
||||
|
|
@ -37,112 +37,112 @@ export default function AddNew({
|
|||
|
||||
return (
|
||||
<>
|
||||
{/* <BulkUploadComingSoonModal
|
||||
<BulkUploadComingSoonModal
|
||||
isOpen={isBulkUploadOpen}
|
||||
onClose={() => setIsBulkUploadOpen(false)}
|
||||
portfolioId={portfolioId}
|
||||
/> */}
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<MenuButton
|
||||
className="
|
||||
/>
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<MenuButton
|
||||
className="
|
||||
inline-flex items-center gap-1 px-4 py-2 rounded-md
|
||||
bg-gray-50 text-gray-900 hover:bg-midblue hover:text-gray-100
|
||||
transition-colors text-sm font-medium
|
||||
"
|
||||
>
|
||||
<DocumentPlusIcon className="h-4 w-4 mr-2" />
|
||||
New Property
|
||||
<ChevronDownIcon className="h-4 w-4 opacity-70" />
|
||||
</MenuButton>
|
||||
>
|
||||
<DocumentPlusIcon className="h-4 w-4 mr-2" />
|
||||
New Property
|
||||
<ChevronDownIcon className="h-4 w-4 opacity-70" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuItems
|
||||
className="
|
||||
<MenuItems
|
||||
className="
|
||||
absolute right-0 mt-3 w-72 origin-top-right rounded-md
|
||||
bg-white shadow-lg ring-1 ring-black/5 focus:outline-none
|
||||
z-[9999] py-3
|
||||
"
|
||||
>
|
||||
<div className="flex flex-col gap-2 px-3">
|
||||
{/* Remote Assessment */}
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={handleRemoteAssessment}
|
||||
className={cn(
|
||||
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
|
||||
active && "bg-gray-100",
|
||||
)}
|
||||
>
|
||||
<DocumentMagnifyingGlassIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
|
||||
>
|
||||
<div className="flex flex-col gap-2 px-3">
|
||||
{/* Remote Assessment */}
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={handleRemoteAssessment}
|
||||
className={cn(
|
||||
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
|
||||
active && "bg-gray-100"
|
||||
)}
|
||||
>
|
||||
<DocumentMagnifyingGlassIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900 flex items-center gap-2">
|
||||
Remote Assessment
|
||||
{loadingRemote && (
|
||||
<span className="h-3 w-3 rounded-full border-2 border-gray-400 border-t-transparent animate-spin"></span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 leading-snug">
|
||||
Run a remote assessment for a single property.
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900 flex items-center gap-2">
|
||||
Remote Assessment
|
||||
{loadingRemote && (
|
||||
<span className="h-3 w-3 rounded-full border-2 border-gray-400 border-t-transparent animate-spin"></span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 leading-snug">
|
||||
Run a remote assessment for a single property.
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
|
||||
{/* CSV Upload */}
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => setIsUploadCsvOpen(!isUploadCsvOpen)}
|
||||
className={cn(
|
||||
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
|
||||
active && "bg-gray-100",
|
||||
)}
|
||||
>
|
||||
<TableCellsIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
|
||||
{/* CSV Upload */}
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => setIsUploadCsvOpen(!isUploadCsvOpen)}
|
||||
className={cn(
|
||||
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
|
||||
active && "bg-gray-100"
|
||||
)}
|
||||
>
|
||||
<TableCellsIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
File Import
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 leading-snug">
|
||||
For bulk uploads, please contact a Domna user.
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
File Import
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 leading-snug">
|
||||
For bulk uploads, please contact a Domna user.
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
|
||||
{/* Bulk Upload (Coming Soon) */}
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => setIsBulkUploadOpen(true)}
|
||||
className={cn(
|
||||
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
|
||||
active && "bg-gray-100",
|
||||
)}
|
||||
>
|
||||
<RectangleStackIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
|
||||
{/* Bulk Upload (Coming Soon) */}
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => setIsBulkUploadOpen(true)}
|
||||
className={cn(
|
||||
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
|
||||
active && "bg-gray-100"
|
||||
)}
|
||||
>
|
||||
<RectangleStackIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900 flex items-center gap-2">
|
||||
new: Bulk upload
|
||||
<span className="text-[10px] font-semibold text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded-full leading-none">
|
||||
coming soon
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900 flex items-center gap-2">
|
||||
new: Bulk upload
|
||||
<span className="text-[10px] font-semibold text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded-full leading-none">
|
||||
coming soon
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 leading-snug">
|
||||
Upload multiple addresses in one go.
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 leading-snug">
|
||||
Upload multiple addresses in one go.
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
1
src/app/db/migrations/0174_bulk_upload_task_id.sql
Normal file
1
src/app/db/migrations/0174_bulk_upload_task_id.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "bulk_address_uploads" ADD COLUMN "task_id" uuid;
|
||||
1
src/app/db/migrations/0175_sweet_otto_octavius.sql
Normal file
1
src/app/db/migrations/0175_sweet_otto_octavius.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "bulk_address_uploads" ADD COLUMN "task_id" uuid;
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "bulk_address_uploads" ADD COLUMN "combined_output_s3_uri" text;
|
||||
1
src/app/db/migrations/0177_wooden_dexter_bennett.sql
Normal file
1
src/app/db/migrations/0177_wooden_dexter_bennett.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "bulk_address_uploads" ADD COLUMN "combined_output_s3_uri" text;
|
||||
27
src/app/db/migrations/0178_parched_midnight.sql
Normal file
27
src/app/db/migrations/0178_parched_midnight.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
CREATE TABLE "property_removal_requests" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"hubspot_deal_id" text NOT NULL,
|
||||
"portfolio_id" bigint NOT NULL,
|
||||
"reason" text NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"requested_by" bigint NOT NULL,
|
||||
"requested_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"reviewed_by" bigint,
|
||||
"reviewed_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "installed_measure" ALTER COLUMN "measure_type" SET DATA TYPE text;--> statement-breakpoint
|
||||
DROP TYPE "public"."measure_type";--> statement-breakpoint
|
||||
CREATE TYPE "public"."measure_type" AS ENUM('air_source_heat_pump', 'boiler_upgrade', 'high_heat_retention_storage_heaters', 'secondary_heating', 'roomstat_programmer_trvs', 'time_temperature_zone_control', 'cylinder_thermostat', 'cavity_wall_insulation', 'extension_cavity_wall_insulation', 'external_wall_insulation', 'internal_wall_insulation', 'loft_insulation', 'flat_roof_insulation', 'room_roof_insulation', 'solid_floor_insulation', 'suspended_floor_insulation', 'double_glazing', 'secondary_glazing', 'draught_proofing', 'mechanical_ventilation', 'low_energy_lighting', 'solar_pv', 'hot_water_tank_insulation', 'sealing_open_fireplace');--> statement-breakpoint
|
||||
ALTER TABLE "installed_measure" ALTER COLUMN "measure_type" SET DATA TYPE "public"."measure_type" USING "measure_type"::"public"."measure_type";--> statement-breakpoint
|
||||
ALTER TABLE "uploaded_files" ALTER COLUMN "file_type" SET DATA TYPE text;--> statement-breakpoint
|
||||
DROP TYPE "public"."file_type";--> statement-breakpoint
|
||||
CREATE TYPE "public"."file_type" AS ENUM('photo_pack', 'site_note', 'rd_sap_site_note', 'pas_2023_ventilation', 'pas_2023_condition', 'pas_significance', 'par_photo_pack', 'pas_2023_property', 'pas_2023_occupancy', 'ecmk_site_note', 'ecmk_rd_sap_site_note', 'ecmk_survey_xml', 'pre_photo', 'mid_photo', 'post_photo', 'loft_hatch_photo', 'dmev_photos', 'door_undercut_photos', 'trickle_vent_photos', 'pre_installation_building_inspection', 'point_of_work_risk_assessment', 'claim_of_compliance', 'mcs_compliance_certificate', 'certificate_of_conformity', 'minor_works_electrical_certificate', 'trustmark_licence_numbers', 'operative_competency', 'ventilation_assessment_checklist', 'anemometer_readings', 'commissioning_records', 'part_f_ventilation_document', 'handover_pack', 'insurance_guarantee', 'workmanship_warranty', 'g98_notification', 'installer_qualifications', 'installer_feedback', 'contractor_other');--> statement-breakpoint
|
||||
ALTER TABLE "uploaded_files" ALTER COLUMN "file_type" SET DATA TYPE "public"."file_type" USING "file_type"::"public"."file_type";--> statement-breakpoint
|
||||
ALTER TABLE "property_removal_requests" ADD CONSTRAINT "property_removal_requests_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "property_removal_requests" ADD CONSTRAINT "property_removal_requests_requested_by_user_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "property_removal_requests" ADD CONSTRAINT "property_removal_requests_reviewed_by_user_id_fk" FOREIGN KEY ("reviewed_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_removal_requests_deal_id" ON "property_removal_requests" USING btree ("hubspot_deal_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_removal_requests_portfolio_id" ON "property_removal_requests" USING btree ("portfolio_id");--> statement-breakpoint
|
||||
ALTER TABLE "bulk_address_uploads" DROP COLUMN "task_id";--> statement-breakpoint
|
||||
ALTER TABLE "bulk_address_uploads" DROP COLUMN "combined_output_s3_uri";
|
||||
6760
src/app/db/migrations/meta/0175_snapshot.json
Normal file
6760
src/app/db/migrations/meta/0175_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
6766
src/app/db/migrations/meta/0177_snapshot.json
Normal file
6766
src/app/db/migrations/meta/0177_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
6898
src/app/db/migrations/meta/0178_snapshot.json
Normal file
6898
src/app/db/migrations/meta/0178_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1223,8 +1223,36 @@
|
|||
{
|
||||
"idx": 174,
|
||||
"version": "7",
|
||||
"when": 1776453858204,
|
||||
"tag": "0174_condemned_lady_bullseye",
|
||||
"when": 1776900000000,
|
||||
"tag": "0174_bulk_upload_task_id",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 175,
|
||||
"version": "7",
|
||||
"when": 1776434096854,
|
||||
"tag": "0175_sweet_otto_octavius",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 176,
|
||||
"version": "7",
|
||||
"when": 1776900120000,
|
||||
"tag": "0176_bulk_upload_combined_output",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 177,
|
||||
"version": "7",
|
||||
"when": 1776451871348,
|
||||
"tag": "0177_wooden_dexter_bennett",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 178,
|
||||
"version": "7",
|
||||
"when": 1776458454019,
|
||||
"tag": "0178_parched_midnight",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -58,13 +58,6 @@ export const measureTypeEnum = pgEnum("measure_type", [
|
|||
// Other fabric / hot water
|
||||
"hot_water_tank_insulation",
|
||||
"sealing_open_fireplace",
|
||||
|
||||
// Contractor workflow measures
|
||||
"damp_mould",
|
||||
"door_undercut",
|
||||
"extractor_fan",
|
||||
"loft_board",
|
||||
"trickle_vent",
|
||||
]);
|
||||
|
||||
export const recommendation = pgTable(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { bigint, bigserial, pgEnum, pgTable, text, timestamp } from "drizzle-orm
|
|||
import { user } from "./users";
|
||||
|
||||
export const fileType = pgEnum("file_type", [
|
||||
// Survey documents (existing)
|
||||
"photo_pack",
|
||||
"site_note",
|
||||
"rd_sap_site_note",
|
||||
|
|
|
|||
|
|
@ -27,13 +27,15 @@ async function getPortfolioUsers(portfolioId: string): Promise<Collaborator[]> {
|
|||
const users = Array.isArray(json) ? json : json.users; // support both shapes
|
||||
// Guard + shape to Collaborator[]
|
||||
return Array.isArray(users)
|
||||
? users.map((u: any) => ({
|
||||
portfolioUserId: String(u.portfolioUserId),
|
||||
userId: String(u.userId),
|
||||
name: u.name ?? null,
|
||||
email: u.email ?? "",
|
||||
role: u.role,
|
||||
}))
|
||||
? users
|
||||
.filter((u: any) => u.role !== "creator") // 👈 filter out creator
|
||||
.map((u: any) => ({
|
||||
portfolioUserId: String(u.portfolioUserId),
|
||||
userId: String(u.userId),
|
||||
name: u.name ?? null,
|
||||
email: u.email ?? "",
|
||||
role: u.role,
|
||||
}))
|
||||
: [];
|
||||
}
|
||||
|
||||
|
|
@ -249,20 +251,12 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
|
|||
<TableCell>{c.name || "—"}</TableCell>
|
||||
<TableCell>{c.email}</TableCell>
|
||||
<TableCell className="min-w-40">
|
||||
{c.role === "creator" || c.role === "admin" ? (
|
||||
<span className="text-xs font-medium text-gray-500 px-2 py-1 bg-gray-100 rounded-md capitalize">
|
||||
{c.role}
|
||||
</span>
|
||||
) : (
|
||||
<RoleDropdown value={c.role as "read" | "write"} onChange={(r) => onChangeRole(c.portfolioUserId, r)} />
|
||||
)}
|
||||
<RoleDropdown value={c.role} onChange={(r) => onChangeRole(c.portfolioUserId, r)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{c.role !== "creator" && (
|
||||
<Button variant="destructive" className="bg-red-700" onClick={() => onRemove(c.portfolioUserId)}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="destructive" className="bg-red-700" onClick={() => onRemove(c.portfolioUserId)}>
|
||||
Remove
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ export type Role = typeof ROLE_OPTIONS[number];
|
|||
|
||||
export type Collaborator = {
|
||||
portfolioUserId: string;
|
||||
userId: string;
|
||||
userId: string;
|
||||
name?: string | null;
|
||||
email: string;
|
||||
role: Role | "creator" | "admin";
|
||||
role: Role;
|
||||
};
|
||||
|
||||
// Small role dropdown using shadcn Select
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { UsersPermissionsCard } from "../UsersPermissionsCard";
|
||||
import { CapabilitiesCard } from "../CapabilitiesCard";
|
||||
|
||||
export default async function UserAccessPage(props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
|
|
@ -9,7 +8,6 @@ export default async function UserAccessPage(props: {
|
|||
return (
|
||||
<div>
|
||||
<UsersPermissionsCard portfolioId={slug} />
|
||||
<CapabilitiesCard portfolioId={slug} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -217,8 +217,6 @@ export default function LiveTracker({
|
|||
data={currentProject?.allDeals ?? []}
|
||||
onOpenDrawer={handleOpenDrawer}
|
||||
docStatusMap={docStatusMap}
|
||||
portfolioId={portfolioId}
|
||||
userCapability={userCapability}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
|
@ -360,8 +358,8 @@ export default function LiveTracker({
|
|||
{/* ── Property detail drawer ─────────────────────────────────────── */}
|
||||
<PropertyDetailDrawer
|
||||
deal={detailDeal}
|
||||
portfolioId={portfolioId}
|
||||
onClose={() => setDetailDeal(null)}
|
||||
portfolioId={portfolioId}
|
||||
userRole={userRole}
|
||||
userCapability={userCapability}
|
||||
userEmail={userEmail}
|
||||
|
|
|
|||
|
|
@ -260,81 +260,24 @@ function RemovalRequestSection({
|
|||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Approval log types + helpers
|
||||
// Approval log placeholder (expand into a real implementation as needed)
|
||||
// -----------------------------------------------------------------------
|
||||
type AuditEvent = {
|
||||
id: string;
|
||||
measureName: string;
|
||||
action: string; // 'approved' | 'unapproved'
|
||||
actedByEmail: string;
|
||||
actedByName: string | null;
|
||||
actedAt: string;
|
||||
};
|
||||
|
||||
function formatDateTime(iso: string) {
|
||||
return new Date(iso).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
function ApprovalLogSection({ dealId, portfolioId }: { dealId: string; portfolioId: string }) {
|
||||
void dealId; void portfolioId;
|
||||
return <p className="text-xs text-gray-400">No approvals recorded.</p>;
|
||||
}
|
||||
|
||||
function ApprovalLogSection({
|
||||
dealId,
|
||||
portfolioId,
|
||||
}: {
|
||||
dealId: string;
|
||||
portfolioId: string;
|
||||
}) {
|
||||
const { data, isLoading } = useQuery<{ events: AuditEvent[] }>({
|
||||
queryKey: ["approvalEvents", portfolioId, dealId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/portfolio/${portfolioId}/approvals?dealIds=${dealId}&include=events`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to fetch events");
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-xs text-gray-400 py-2">Loading activity…</p>;
|
||||
}
|
||||
|
||||
const events = data?.events ?? [];
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-gray-400 py-2">No approval activity yet.</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pt-1">
|
||||
{events.map((e) => (
|
||||
<div key={e.id} className="flex items-start gap-2 text-xs">
|
||||
<span
|
||||
className={`mt-0.5 shrink-0 px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
e.action === "approved"
|
||||
? "bg-emerald-50 text-emerald-700"
|
||||
: "bg-red-50 text-red-600"
|
||||
}`}
|
||||
>
|
||||
{e.action === "approved" ? "Approved" : "Unapproved"}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium text-gray-700">{e.measureName}</span>
|
||||
<div className="text-gray-400 mt-0.5">
|
||||
{e.actedByName ?? e.actedByEmail} · {formatDateTime(e.actedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
function formatDateTime(d: string | Date | null | undefined): string {
|
||||
if (!d) return "";
|
||||
try {
|
||||
return new Date(d).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -465,8 +408,8 @@ function MilestoneTimeline({ deal }: { deal: ClassifiedDeal }) {
|
|||
// -----------------------------------------------------------------------
|
||||
interface PropertyDetailDrawerProps {
|
||||
deal: ClassifiedDeal | null;
|
||||
portfolioId: string;
|
||||
onClose: () => void;
|
||||
portfolioId: string;
|
||||
userRole: string;
|
||||
userCapability: PortfolioCapabilityType;
|
||||
userEmail: string;
|
||||
|
|
@ -483,7 +426,7 @@ export default function PropertyDetailDrawer({
|
|||
const [isLogOpen, setIsLogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={(v) => { if (!v) { setIsLogOpen(false); onClose(); } }} direction="right">
|
||||
<Drawer open={open} onOpenChange={(v) => !v && onClose()} direction="right">
|
||||
<DrawerContent className="fixed right-0 top-0 bottom-0 h-full w-[42vw] min-w-80 max-w-lg rounded-l-2xl rounded-r-none mt-0 flex flex-col border-l border-t-0 border-b-0 border-r-0 border-brandblue/10 bg-white shadow-2xl overflow-hidden">
|
||||
<div className="hidden" />
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { redirect } from "next/navigation";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { eq, inArray, and } from "drizzle-orm";
|
||||
import LiveTracker from "./LiveTracker";
|
||||
import { computeLiveTrackerData } from "./transforms";
|
||||
import { db } from "@/app/db/db";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue