diff --git a/apps/www/src/actions/create-analyse.ts b/apps/www/src/actions/create-analyse.ts new file mode 100644 index 0000000..0e3c67b --- /dev/null +++ b/apps/www/src/actions/create-analyse.ts @@ -0,0 +1,89 @@ +"use server" + +import { revalidatePath } from "next/cache" + +import { prisma } from "@/lib/db" +import { getCurrentUser } from "@/lib/session" + +export async function createAnalysis(analysisData) { + const user = await getCurrentUser() + const userId = user?.id + + if (!userId) { + console.error("No user is currently logged in.") + return { success: false, error: "User not authenticated" } + } + + try { + // Fetch the user's workspace to associate the analysis + const workspace = await prisma.workspace.findFirst({ + where: { users: { some: { id: userId } } }, + select: { id: true }, + }) + + if (!workspace) { + throw new Error("No workspace found for this user") + } + + // Create a new financial analysis for the selected building + const newAnalysis = await prisma.financialAnalysisBuilding.create({ + data: { + buildingId: analysisData.buildingId, + name: analysisData.name, // Include the name field + rentableArea: 1000, + ratioAreaOffice: 0.5, + ratioAreaMerch: 0.3, + ratioAreaMisc: 0.2, + rentPerArea: 500, + avgExpiryPeriod: 12, + appreciationDate: new Date(), + lastDayOfYear: new Date(new Date().getFullYear(), 11, 31), + lastBalanceDate: new Date(), + numMonthsOfYear: 12, + sumValueNow: 1000000, + sumValueExit: 1200000, + vacancyPerYear: JSON.stringify({ "2024": "10.5", "2025": "9.8" }), + ownerCostsMethod: true, + ownerCostsManual: 50000, + costMaintenance: 10000, + costInsurance: 5000, + costRevision: 3000, + costAdm: 2000, + costOther: 1000, + costNegotiation: 2000, + costLegalFees: 1000, + costConsultFees: 1500, + costAssetMgmt: 2500, + costSum: 45000, + costBigExpenses: JSON.stringify({ "2024": "100000", "2025": "20500" }), + useCalcROI: true, + roiWeightedYield: 0.05, + roiInflation: 0.02, + roiCalculated: 0.07, + roiManual: 0.06, + marketRentOffice: 600, + marketRentMerch: 400, + marketRentMisc: 300, + usePrimeYield: true, + manYieldOffice: 0.05, + manYieldMerch: 0.04, + manYieldMisc: 0.03, + manYieldWeighted: 0.045, + kpi1: 0, + kpi2: 0, + kpi3: 0, + kpi4: 0, + }, + }) + console.log( + `Created analysis with ID: ${newAnalysis.id} for building ID: ${analysisData.buildingId}.`, + ) + + revalidatePath("/analytics") + + return { success: true, analysis: newAnalysis } + } catch (error) { + console.error(`Error creating analysis for user ID: ${userId}`, error) + return { success: false, error: error.message } + } +} diff --git a/apps/www/src/actions/get-analysis-details.ts b/apps/www/src/actions/get-analysis-details.ts new file mode 100644 index 0000000..c7ca275 --- /dev/null +++ b/apps/www/src/actions/get-analysis-details.ts @@ -0,0 +1,32 @@ +"use server" + +import { prisma } from "@/lib/db" + +export async function getAnalysisDetails(analysisId: string) { + try { + const numericId = parseInt(analysisId, 10) + if (isNaN(numericId)) { + throw new Error("Invalid analysis ID") + } + + const analysisDetails = await prisma.financialAnalysisBuilding.findUnique({ + where: { id: numericId }, + include: { + building: { + select: { + name: true, + }, + }, + }, + }) + + if (!analysisDetails) { + throw new Error("Analysis not found") + } + + return { success: true, analysisDetails } + } catch (error) { + console.error("Error fetching analysis details:", error) + return { success: false, error: error.message } + } +} diff --git a/apps/www/src/actions/get-analyst.ts b/apps/www/src/actions/get-analyst.ts new file mode 100644 index 0000000..bd12f68 --- /dev/null +++ b/apps/www/src/actions/get-analyst.ts @@ -0,0 +1,38 @@ +"use server" + +import { prisma } from "@/lib/db" + +export async function getAnalyses(workspaceId: string) { + try { + const analyses = await prisma.financialAnalysisBuilding.findMany({ + where: { + building: { + property: { + workspaceId, + }, + }, + }, + select: { + id: true, + name: true, + rentableArea: true, + rentPerArea: true, + sumValueNow: true, + sumValueExit: true, + building: { + select: { + name: true, + }, + }, + }, + orderBy: { + name: "asc", + }, + }) + + return { success: true, analyses } + } catch (error) { + console.error("Error fetching analyses:", error) + return { success: false, error: error.message } + } +} diff --git a/apps/www/src/actions/update-analysis.ts b/apps/www/src/actions/update-analysis.ts new file mode 100644 index 0000000..01b9e9d --- /dev/null +++ b/apps/www/src/actions/update-analysis.ts @@ -0,0 +1,30 @@ +"use server" + +import { prisma } from "@/lib/db" +import { getCurrentUser } from "@/lib/session" + +export async function updateAnalysis(analysisId, updateData) { + const user = await getCurrentUser() + const userId = user?.id + + if (!userId) { + console.error("No user is currently logged in.") + return { success: false, error: "User not authenticated" } + } + + try { + const updatedAnalysis = await prisma.financialAnalysisBuilding.update({ + where: { id: parseInt(analysisId, 10) }, + data: { + ...updateData, + }, + }) + + console.log(`Updated analysis with ID: ${updatedAnalysis.id}.`) + + return { success: true, analysis: updatedAnalysis } + } catch (error) { + console.error(`Error updating analysis for user ID: ${userId}`, error) + return { success: false, error: error.message } + } +} diff --git a/apps/www/src/app/(analytics)/analytics/[id]/contract/page.tsx b/apps/www/src/app/(analytics)/analytics/[id]/contract/page.tsx deleted file mode 100644 index 007d676..0000000 --- a/apps/www/src/app/(analytics)/analytics/[id]/contract/page.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; - -import React, { useState } from "react"; - -import { DashboardHeader } from "@/components/dashboard/header"; -import { DashboardShell } from "@/components/dashboard/shell"; -import Editor from "@/components/editor/editor"; - -export default function Home() { - const [value, setValue] = useState("Hello World! đŸŒŽïž"); - - return ( -
- - - - {/* */} - -
- ); -} diff --git a/apps/www/src/app/(analytics)/analytics/[id]/dcf/page.tsx b/apps/www/src/app/(analytics)/analytics/[id]/dcf/page.tsx new file mode 100644 index 0000000..c678eb7 --- /dev/null +++ b/apps/www/src/app/(analytics)/analytics/[id]/dcf/page.tsx @@ -0,0 +1,51 @@ +import Link from "next/link" +import { getAnalysisDetails } from "@/actions/get-analysis-details" + +import { Button } from "@dingify/ui/components/button" + +import { AnalysisTableDCF } from "@/components/analyse/AnalysisTableDCF" +import { DashboardHeader } from "@/components/dashboard/header" +import { DashboardShell } from "@/components/dashboard/shell" + +export default async function DCFDetailsPage({ + params, +}: { + params: { id: string } +}) { + const analysisId = params.id + + try { + const { success, analysisDetails, error } = + await getAnalysisDetails(analysisId) + console.log(analysisDetails) + + if (!success || !analysisDetails) { + return ( + + + + ) + } + + return ( + + +
+ +
+
+ ) + } catch (error) { + return ( + + + + ) + } +} diff --git a/apps/www/src/app/(analytics)/analytics/[id]/layout.tsx b/apps/www/src/app/(analytics)/analytics/[id]/layout.tsx new file mode 100644 index 0000000..a2de130 --- /dev/null +++ b/apps/www/src/app/(analytics)/analytics/[id]/layout.tsx @@ -0,0 +1,45 @@ +import type { SidebarNavItem } from "@/types" +import { notFound } from "next/navigation" + +import { getCurrentUser } from "@/lib/session" +import { DashboardNav } from "@/components/layout/nav" + +interface DashboardLayoutProps { + children?: React.ReactNode + params: { id: string } +} + +export default async function DashboardLayout({ + children, + params, +}: DashboardLayoutProps) { + const user = await getCurrentUser() + + if (!user) { + return notFound() + } + + const sidebarNavItems: SidebarNavItem[] = [ + { + title: "Informasjon", + href: `/analytics/${params.id}`, + icon: "home", + }, + { + title: "KontantstrĂžm", + href: `/analytics/${params.id}/dcf`, + icon: "piechart", + }, + ] + + return ( +
+ +
+ {children} +
+
+ ) +} diff --git a/apps/www/src/app/(analytics)/analytics/[id]/page.tsx b/apps/www/src/app/(analytics)/analytics/[id]/page.tsx index e0b57f5..1580b39 100644 --- a/apps/www/src/app/(analytics)/analytics/[id]/page.tsx +++ b/apps/www/src/app/(analytics)/analytics/[id]/page.tsx @@ -1,63 +1,125 @@ -import Link from "next/link"; -import { getTenantDetails } from "@/actions/get-tenant-details"; +import Link from "next/link" +import { getAnalysisDetails } from "@/actions/get-analysis-details" -import { Button } from "@dingify/ui/components/button"; +import { Button } from "@dingify/ui/components/button" -import { DashboardHeader } from "@/components/dashboard/header"; -import { DashboardShell } from "@/components/dashboard/shell"; -import { EmptyPlaceholder } from "@/components/shared/empty-placeholder"; -import UserCard from "@/components/users/UserCard"; +import { AnalysisDetailsTable } from "@/components/analyse/AnalysisDetailsTable" +import { EditAnalysisNameCard } from "@/components/analyse/EditAnalysisNameCard" +import { EditKpiCard } from "@/components/analyse/EditKpiCard.tsx" +import { EditMarketDataCard } from "@/components/analyse/EditMarketDataCard" +import { EditOwnerCostsCard } from "@/components/analyse/EditOwnerCostsCard" +import { EditRentableAreaCard } from "@/components/analyse/EditRentableAreaCard" +import { EditROICard } from "@/components/analyse/EditROICard" +import { EditVacancyCard } from "@/components/analyse/EditVacancyCard" +import { DashboardHeader } from "@/components/dashboard/header" +import { DashboardShell } from "@/components/dashboard/shell" -export default async function TenantDetailsPage({ +export default async function AnalysisDetailsPage({ params, }: { - params: { id: string }; + params: { id: string } }) { - const tenantId = parseInt(params.id); - - if (isNaN(tenantId)) { - return ( - - - - ); - } + const analysisId = params.id try { - const tenantDetails = await getTenantDetails(tenantId); + const { success, analysisDetails, error } = + await getAnalysisDetails(analysisId) + console.log(analysisDetails) - if (!tenantDetails) { + if (!success || !analysisDetails) { return ( - ); + ) } return ( - {" "} -
- +
+ + + + + + + +
+
+
- ); + ) } catch (error) { return ( - ); + ) } } diff --git a/apps/www/src/app/(analytics)/analytics/layout.tsx b/apps/www/src/app/(analytics)/analytics/layout.tsx index cee1472..6bd6d80 100644 --- a/apps/www/src/app/(analytics)/analytics/layout.tsx +++ b/apps/www/src/app/(analytics)/analytics/layout.tsx @@ -21,16 +21,14 @@ export default async function DashboardLayout({ return notFound() } - // const userChannels = await getUserChannels(); - const sidebarNavItems: SidebarNavItem[] = [ { - title: "Generell innstillinger", - href: "/analytics", + title: "Data", + href: "/analytics/", icon: "home", }, { - title: "Import", + title: "Kart", href: "/analytics/maps", icon: "map", }, @@ -45,10 +43,10 @@ export default async function DashboardLayout({
-
- */}
{children}
diff --git a/apps/www/src/app/(analytics)/analytics/page.tsx b/apps/www/src/app/(analytics)/analytics/page.tsx index 12d4a7e..4705bdd 100644 --- a/apps/www/src/app/(analytics)/analytics/page.tsx +++ b/apps/www/src/app/(analytics)/analytics/page.tsx @@ -1,36 +1,35 @@ -import Link from "next/link"; -import { redirect } from "next/navigation"; -import { getTenants } from "@/actions/get-tenants"; +import Link from "next/link" +import { redirect } from "next/navigation" +import { getAnalyses } from "@/actions/get-analyst" import { Card, CardContent, CardHeader, CardTitle, -} from "@dingify/ui/components/card"; +} from "@dingify/ui/components/card" -import { authOptions } from "@/lib/auth"; -import { prisma } from "@/lib/db"; -import { getCurrentUser } from "@/lib/session"; -import AddTenantDropdownButton from "@/components/buttons/AddTenantDropdownButton"; -import { AddTenantSheet } from "@/components/buttons/AddTenantSheet"; -import { AddWorkspaceButton } from "@/components/buttons/AddWorkspaceButton"; -import { DashboardHeader } from "@/components/dashboard/header"; -import { DashboardShell } from "@/components/dashboard/shell"; -import PropertyMap from "@/components/maps/PropertyMap"; -import { EmptyPlaceholder } from "@/components/shared/empty-placeholder"; +import { authOptions } from "@/lib/auth" +import { prisma } from "@/lib/db" +import { getCurrentUser } from "@/lib/session" +import { AddAnalysisSheet } from "@/components/buttons/AddAnalysisSheet" +import { AddWorkspaceButton } from "@/components/buttons/AddWorkspaceButton" +import { DashboardHeader } from "@/components/dashboard/header" +import { DashboardShell } from "@/components/dashboard/shell" +import PropertyMap from "@/components/maps/PropertyMap" +import { EmptyPlaceholder } from "@/components/shared/empty-placeholder" export const metadata = { title: "Propdock Analyser - Oversikt over dine analyser", description: "Monitor and analyze all your critical events in real-time. Access key metrics, track important journeys, and make data-driven decisions to optimize your business performance on the Dingify Dashboard.", -}; +} export default async function DashboardPage() { - const user = await getCurrentUser(); + const user = await getCurrentUser() if (!user) { - redirect(authOptions.pages?.signIn || "/login"); + redirect(authOptions.pages?.signIn || "/login") } // Fetch workspace associated with the user @@ -45,7 +44,7 @@ export default async function DashboardPage() { select: { id: true, }, - }); + }) if (!userWorkspace) { return ( @@ -66,18 +65,18 @@ export default async function DashboardPage() { - ); + ) } - // Fetch tenants associated with the user's workspace - const { success, tenants = [], error } = await getTenants(userWorkspace.id); + // Fetch analyses associated with the user's workspace + const { success, analyses = [], error } = await getAnalyses(userWorkspace.id) if (!success) { return ( - ); + ) } return ( @@ -86,21 +85,21 @@ export default async function DashboardPage() { heading="Analyser" text="Dine analyser for dine eiendommer" > - - + + {/* */}
- {tenants.length === 0 ? ( + {analyses.length === 0 ? ( - Legg til din fÞrste leietaker + Legg til din fÞrste analyse - Du har ingen leietakere ennÄ. Legg til en leietaker for Ä komme i + Du har ingen analyser ennÄ. Legg til en analyse for Ä komme i gang. - + ) : (
@@ -108,20 +107,19 @@ export default async function DashboardPage() {
- {tenants.map((tenant) => ( - + {analyses.map((analysis) => ( + - {tenant.name} + + {analysis.name} + -

Organisasjonsnummer: {tenant.orgnr}

-

Antall ansatte: {tenant.numEmployees}

-

Bygning: {tenant.building?.name || "N/A"}

-

Etasje: {tenant.floor ? tenant.floor.number : "N/A"}

-

- Kontorplass:{" "} - {tenant.officeSpace ? tenant.officeSpace.name : "N/A"} -

+

Bygning: {analysis.building?.name || "N/A"}

+

Utleibart areal: {analysis.rentableArea}

+

Leiepris per kvm per Ă„r: {analysis.rentPerArea}

+

Sum nÄverdi: {analysis.sumValueNow}

+

Exit verdi: {analysis.sumValueExit}

))} @@ -130,5 +128,5 @@ export default async function DashboardPage() { )}
- ); + ) } diff --git a/apps/www/src/app/(tenant)/tenant/[id]/invoice/page.tsx b/apps/www/src/app/(tenant)/tenant/[id]/invoice/page.tsx new file mode 100644 index 0000000..30ad895 --- /dev/null +++ b/apps/www/src/app/(tenant)/tenant/[id]/invoice/page.tsx @@ -0,0 +1,83 @@ +import { getTenantDetails } from "@/actions/get-tenant-details" +import { Settings } from "lucide-react" + +import { Button } from "@dingify/ui/components/button" +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@dingify/ui/components/card" + +import { AddContactPersonSheet } from "@/components/buttons/AddContactPersonSheet" +import { EditContactPersonSheet } from "@/components/buttons/EditContactPersonSheet" +import { DashboardHeader } from "@/components/dashboard/header" +import { DashboardShell } from "@/components/dashboard/shell" +import { EmptyPlaceholder } from "@/components/shared/empty-placeholder" +import TenantSendInvoice from "@/components/tenant/TenantSendInvoice" + +const mockCustomers = [ + { id: "genesis", name: "Proaktiv Eiendomsmegling", orgnr: "123456789" }, + { id: "explorer", name: "Neural Explorer", orgnr: "987654321" }, + { id: "quantum", name: "Neural Quantum", orgnr: "192837465" }, +] + +const mockProducts = [ + { id: "product1", name: "Produkt 1", price: 100 }, + { id: "product2", name: "Produkt 2", price: 200 }, + { id: "product3", name: "Produkt 3", price: 300 }, +] + +export default async function ContactPerson({ + params, +}: { + params: { id: string } +}) { + const tenantId = params.id + + if (!tenantId) { + return ( + + + + ) + } + + try { + const tenantDetails = await getTenantDetails(tenantId) + + if (!tenantDetails || tenantDetails.contacts.length === 0) { + return ( + + + + + ) + } + + return ( + + +
asdasda
+
+ ) + } catch (error) { + return ( + + + + ) + } +} diff --git a/apps/www/src/components/AnalysisCards.tsx b/apps/www/src/components/AnalysisCards.tsx new file mode 100644 index 0000000..12b0da3 --- /dev/null +++ b/apps/www/src/components/AnalysisCards.tsx @@ -0,0 +1,105 @@ +import Link from "next/link" +import { CircleUser, Menu, Package2, Search } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" +import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" +import { EditAnalysisSheet } from "@/components/buttons/EditAnalysisSheet" // Import the EditAnalysisSheet component + +export function AnalysisCards({ analysis }) { + return ( +
+ +
+ {/* Analysis Name Card */} + + + Analysis Name + + This is the name of the financial analysis. + + + +
+ +
+
+ + + +
+ + {/* Analysis Details Card */} + + + Analysis Details + + Detailed information about the financial analysis. + + + +
+ + + + + +
+
+ + + +
+
+
+ ) +} diff --git a/apps/www/src/components/analyse/AnalysisDetailsTable.tsx b/apps/www/src/components/analyse/AnalysisDetailsTable.tsx new file mode 100644 index 0000000..dcb4d5b --- /dev/null +++ b/apps/www/src/components/analyse/AnalysisDetailsTable.tsx @@ -0,0 +1,109 @@ +import { format } from "date-fns" + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@dingify/ui/components/card" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@dingify/ui/components/table" + +interface AnalysisDetailsTableProps { + details: any +} + +export function AnalysisDetailsTable({ details }: AnalysisDetailsTableProps) { + const tableHeaders = ["Field", "Value"] + + const formatValue = (value: any) => { + if (value instanceof Date) { + return format(value, "yyyy-MM-dd") + } + return value + } + + const dataRows = [ + { label: "Building", value: details.building.name }, + { label: "Rentable Area", value: details.rentableArea }, + { label: "Rent Per Area", value: details.rentPerArea }, + { label: "Sum Value Now", value: details.sumValueNow }, + { label: "Sum Value Exit", value: details.sumValueExit }, + { + label: "Appreciation Date", + value: formatValue(details.appreciationDate), + }, + { label: "Last Day of Year", value: formatValue(details.lastDayOfYear) }, + { label: "Last Balance Date", value: formatValue(details.lastBalanceDate) }, + { + label: "Vacancy Per Year", + value: JSON.stringify(details.vacancyPerYear), + }, + { + label: "Owner Costs Method", + value: details.ownerCostsMethod ? "True" : "False", + }, + { label: "Owner Costs Manual", value: details.ownerCostsManual }, + { label: "Cost Maintenance", value: details.costMaintenance }, + { label: "Cost Insurance", value: details.costInsurance }, + { label: "Cost Revision", value: details.costRevision }, + { label: "Cost Adm", value: details.costAdm }, + { label: "Cost Other", value: details.costOther }, + { label: "Cost Negotiation", value: details.costNegotiation }, + { label: "Cost Legal Fees", value: details.costLegalFees }, + { label: "Cost Consult Fees", value: details.costConsultFees }, + { label: "Cost Asset Mgmt", value: details.costAssetMgmt }, + { label: "Cost Sum", value: details.costSum }, + { label: "Use Calc ROI", value: details.useCalcROI ? "True" : "False" }, + { label: "ROI Weighted Yield", value: details.roiWeightedYield }, + { label: "ROI Inflation", value: details.roiInflation }, + { label: "ROI Calculated", value: details.roiCalculated }, + { label: "ROI Manual", value: details.roiManual }, + { label: "Market Rent Office", value: details.marketRentOffice }, + { label: "Market Rent Merch", value: details.marketRentMerch }, + { label: "Market Rent Misc", value: details.marketRentMisc }, + { + label: "Use Prime Yield", + value: details.usePrimeYield ? "True" : "False", + }, + { label: "Manual Yield Office", value: details.manYieldOffice }, + { label: "Manual Yield Merch", value: details.manYieldMerch }, + { label: "Manual Yield Misc", value: details.manYieldMisc }, + { label: "Manual Yield Weighted", value: details.manYieldWeighted }, + ] + + return ( + + + Analysis Details + All details about the analysis + + + + + + {tableHeaders.map((header) => ( + {header} + ))} + + + + {dataRows.map((row, index) => ( + + {row.label} + {formatValue(row.value)} + + ))} + +
+
+
+ ) +} diff --git a/apps/www/src/components/analyse/AnalysisTableDCF.tsx b/apps/www/src/components/analyse/AnalysisTableDCF.tsx new file mode 100644 index 0000000..87fe752 --- /dev/null +++ b/apps/www/src/components/analyse/AnalysisTableDCF.tsx @@ -0,0 +1,422 @@ +import { differenceInDays } from "date-fns" + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@dingify/ui/components/card" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@dingify/ui/components/table" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@dingify/ui/components/tooltip" + +interface AnalysisTableDCFProps { + details: any +} + +export function AnalysisTableDCF({ details }: AnalysisTableDCFProps) { + const startYear = 2024 + const endYear = 2033 + const years = Array.from({ length: endYear - startYear + 1 }, (_, i) => + (startYear + i).toString(), + ) + + const kpiAdjustments = [ + 0, // First year, no adjustment + details.kpi1, + details.kpi2, + details.kpi3, + ...Array(endYear - startYear - 3).fill(details.kpi4), + ] + + // Calculate rental incomes for each year + const rentalIncomes = [details.rentPerArea] + for (let i = 1; i < years.length; i++) { + const previousIncome = rentalIncomes[i - 1] + const kpi = kpiAdjustments[i] || 0 + rentalIncomes.push(previousIncome * (1 + kpi / 100)) + } + + // Round the rental incomes for display + const roundedRentalIncomes = rentalIncomes.map((income) => Math.round(income)) + + // Calculate gross rental incomes + const grossRentalIncomes = roundedRentalIncomes.map( + (income) => income * details.rentableArea, + ) + + const formatNumber = (num: number) => { + return new Intl.NumberFormat("nb-NO", { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(num) + } + + // Parse the big expenses + const bigExpenses = JSON.parse(details.costBigExpenses || "{}") + + // Calculate the sum of individual cost items + const calculateAutomaticCosts = () => { + const sum = + (details.costMaintenance || 0) + + (details.costInsurance || 0) + + (details.costRevision || 0) + + (details.costAdm || 0) + + (details.costOther || 0) + + (details.costNegotiation || 0) + + (details.costLegalFees || 0) + + (details.costConsultFees || 0) + + (details.costAssetMgmt || 0) + + return sum + } + + // Calculate the net rental incomes + const netRentalIncomes = grossRentalIncomes.map((grossIncome, index) => { + const year = years[index] + const totalCosts = details.ownerCostsMethod + ? calculateAutomaticCosts() + : details.ownerCostsManual || 0 + const tenantAdjustments = bigExpenses[year] || 0 + return grossIncome - totalCosts - tenantAdjustments + }) + + // Calculate "Andel inkludert i DCF" for each year + const andelInkludertIDCF = years.map((year, index) => { + const currentYear = new Date(`${year}-01-01`) + const lastDayOfYear = new Date(`${year}-12-31`) + const daysForDCF = + index === 0 + ? differenceInDays(lastDayOfYear, new Date(details.appreciationDate)) + : 365 + return daysForDCF / 365 + }) + + // Determine the discount rate based on useCalcROI + const discountRate = details.useCalcROI + ? details.roiWeightedYield + details.roiInflation + details.roiCalculated + : details.roiManual + + // Calculate diskonteringsperiode, diskonteringsfaktor, and nÄverdi kontantstrÞm + const calculateDiscountValues = () => { + const discountValues = andelInkludertIDCF.map((percentage, index) => { + const discountPeriod = index === 0 ? percentage : index + percentage + const discountFactor = 1 / Math.pow(1 + discountRate, discountPeriod) + const discountedCashFlow = netRentalIncomes[index] * discountFactor + return { + discountPeriod: discountPeriod, + discountFactor: discountFactor, + discountedCashFlow: discountedCashFlow, + } + }) + return discountValues + } + + const discountValues = calculateDiscountValues() + + // Calculate Sum NÄverdi by summing all the discounted cash flows + const sumNaverdi = discountValues.reduce( + (sum, value) => sum + value.discountedCashFlow, + 0, + ) + + // Calculate Exit Verdi using the last year's net rental income and yield + const exitYearNetIncome = netRentalIncomes[netRentalIncomes.length - 1] + const exitYield = details.usePrimeYield + ? details.manYieldWeighted + : (details.manYieldOffice + details.manYieldMerch + details.manYieldMisc) / + 3 + const exitVerdi = exitYearNetIncome / exitYield + + // Calculate Total Eiendomsverdi by summing Sum NÄverdi and Exit Verdi and multiplying by the last year's discount factor + const totalEiendomsverdi = + (sumNaverdi + exitVerdi) * + discountValues[discountValues.length - 1].discountFactor + + return ( + <> + + + KontantstrÞm + KontantstrÞm for eiendommen + + + + + + + NOK + {years.map((year) => ( + {year} + ))} + + + + + + + + Utleibart areal + + +

Dette representerer utleibart areal

+
+
+
+ {years.map((year) => ( + + {formatNumber(details.rentableArea)} + + ))} +
+ + + + + LĂžpende leie + + +

Dette representerer lĂžpende leie

+
+
+
+ {roundedRentalIncomes.map((income, index) => ( + + {formatNumber(income)} + + ))} +
+ + + + + Brutto leieinntekter + + +

Utleibart areal x lĂžpende leie

+
+
+
+ {grossRentalIncomes.map((income, index) => ( + + {formatNumber(income)} + + ))} +
+ + Kostnader + + {details.ownerCostsMethod ? ( + + + + + Automatisk regnet kostnader + + +

+ Sum av alle individuelle kostnader: vedlikehold, + forsikring, revisjon, administrasjon, andre + driftskostnader, megling/utleie, juridiske + honorarer, honorar konsulenter, asset management. +

+
+
+
+ {years.map((year) => ( + + {formatNumber(calculateAutomaticCosts())} + + ))} +
+ ) : ( + + + + + Manuelle eierkostnader + + +

Totale manuelle eierkostnader

+
+
+
+ {years.map((year) => ( + + {formatNumber(details.ownerCostsManual || 0)} + + ))} +
+ )} + + + + + Leietakertilpassninger + + +

Kostnader for leietakertilpassninger

+
+
+
+ {years.map((year) => ( + + {formatNumber(bigExpenses[year] || 0)} + + ))} +
+ + + + + Netto leieinntekter + + +

+ Brutto leieinntekter minus kostnader og + leietakertilpassninger +

+
+
+
+ {netRentalIncomes.map((income, index) => ( + + {formatNumber(income)} + + ))} +
+ + + Diskonteringsdetaljer + + + + + + + Andel inkludert i DCF + + +

+ Andel inkludert i DCF for hver periode. FÞrste Äret er + antall dager mellom verdsettelsesdato og slutten av + Äret delt pÄ 365. For pÄfÞlgende Är er det 365 dager + delt pÄ 365. +

+
+
+
+ {andelInkludertIDCF.map((percentage, index) => ( + + {(percentage * 100).toFixed(2)}% + + ))} +
+ + + + + Diskonteringsperiode + + +

+ Periode for diskontering. For fÞrste Äret er det andel + inkludert i DCF. For pÄfÞlgende Är er det Äret + andel + inkludert i DCF. +

+
+
+
+ {discountValues.map((value, index) => ( + + {value.discountPeriod.toFixed(2)} + + ))} +
+ + + + + Diskonteringsfaktor + + +

+ Faktor for diskontering, beregnet som 1 / (1 + + diskonteringsrate) ^ diskonteringsperiode. +

+
+
+
+ {discountValues.map((value, index) => ( + + {value.discountFactor.toFixed(4)} + + ))} +
+ + + + + NĂ„verdi kontantstrĂžm + + +

+ NĂ„verdi av kontantstrĂžm, beregnet som netto + leieinntekter multiplisert med diskonteringsfaktor. +

+
+
+
+ {discountValues.map((value, index) => ( + + {formatNumber(value.discountedCashFlow)} + + ))} +
+
+
+
+
+
+ +
+ + + Sum NĂ„verdi + + +

{formatNumber(sumNaverdi)}

+
+
+ + + Exit Verdi + + +

{formatNumber(exitVerdi)}

+
+
+ + + Total Eiendomsverdi + + +

{formatNumber(totalEiendomsverdi)}

+
+
+
+ + ) +} diff --git a/apps/www/src/components/analyse/EditAnalysisNameCard.tsx b/apps/www/src/components/analyse/EditAnalysisNameCard.tsx new file mode 100644 index 0000000..c6ed926 --- /dev/null +++ b/apps/www/src/components/analyse/EditAnalysisNameCard.tsx @@ -0,0 +1,159 @@ +"use client" + +import { useState } from "react" +import { updateAnalysis } from "@/actions/update-analysis" +import { zodResolver } from "@hookform/resolvers/zod" +import { CalendarIcon } from "@radix-ui/react-icons" +import { format } from "date-fns" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" + +import { Button } from "@dingify/ui/components/button" +import { Calendar } from "@dingify/ui/components/calendar" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@dingify/ui/components/card" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@dingify/ui/components/form" +import { Input } from "@dingify/ui/components/input" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@dingify/ui/components/popover" + +import { cn } from "@/lib/utils" + +const FormSchema = z.object({ + name: z.string().min(2, { + message: "Name must be at least 2 characters.", + }), + appreciationDate: z.date({ + required_error: "Appreciation date is required.", + }), +}) + +interface EditAnalysisNameCardProps { + analysisId: number + initialName: string + initialDate: Date +} + +export function EditAnalysisNameCard({ + analysisId, + initialName, + initialDate, +}: EditAnalysisNameCardProps) { + const [isLoading, setIsLoading] = useState(false) + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + name: initialName, + appreciationDate: initialDate, + }, + }) + + async function onSubmit(data: z.infer) { + setIsLoading(true) + try { + const result = await updateAnalysis(analysisId, { + name: data.name, + appreciationDate: data.appreciationDate, + }) + if (result.success) { + toast.success("Analysis updated successfully.") + } else { + throw new Error(result.error || "Failed to update analysis.") + } + } catch (error) { + toast.error(error.message) + console.error("Error updating analysis:", error) + } finally { + setIsLoading(false) + } + } + + return ( + + + Navn + Endre navn og dato pÄ analysen. + + +
+ + ( + + Name + + + + + + )} + /> + ( + + Appreciation Date + + + + + + + + + date > new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + + )} + /> + + + +
+
+ ) +} diff --git a/apps/www/src/components/analyse/EditKpiCard.tsx.tsx b/apps/www/src/components/analyse/EditKpiCard.tsx.tsx new file mode 100644 index 0000000..b244e30 --- /dev/null +++ b/apps/www/src/components/analyse/EditKpiCard.tsx.tsx @@ -0,0 +1,175 @@ +"use client" + +import { useState } from "react" +import { updateAnalysis } from "@/actions/update-analysis" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" + +import { Button } from "@dingify/ui/components/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@dingify/ui/components/card" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@dingify/ui/components/form" +import { Input } from "@dingify/ui/components/input" + +const FormSchema = z.object({ + kpi1: z + .string() + .nonempty("KPI 1 is required.") + .refine((value) => !isNaN(Number(value)), { + message: "KPI 1 must be a number.", + }), + kpi2: z + .string() + .nonempty("KPI 2 is required.") + .refine((value) => !isNaN(Number(value)), { + message: "KPI 2 must be a number.", + }), + kpi3: z + .string() + .nonempty("KPI 3 is required.") + .refine((value) => !isNaN(Number(value)), { + message: "KPI 3 must be a number.", + }), + kpi4: z + .string() + .nonempty("KPI 4 is required.") + .refine((value) => !isNaN(Number(value)), { + message: "KPI 4 must be a number.", + }), +}) + +interface EditKpiCardProps { + analysisId: number + initialKpi1: number + initialKpi2: number + initialKpi3: number + initialKpi4: number +} + +export function EditKpiCard({ + analysisId, + initialKpi1, + initialKpi2, + initialKpi3, + initialKpi4, +}: EditKpiCardProps) { + const [isLoading, setIsLoading] = useState(false) + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + kpi1: initialKpi1.toString(), + kpi2: initialKpi2.toString(), + kpi3: initialKpi3.toString(), + kpi4: initialKpi4.toString(), + }, + }) + + async function onSubmit(data: z.infer) { + setIsLoading(true) + try { + const result = await updateAnalysis(analysisId, { + kpi1: Number(data.kpi1), + kpi2: Number(data.kpi2), + kpi3: Number(data.kpi3), + kpi4: Number(data.kpi4), + }) + if (result.success) { + toast.success("Analysis updated successfully.") + } else { + throw new Error(result.error || "Failed to update analysis.") + } + } catch (error) { + toast.error(error.message) + console.error("Error updating analysis:", error) + } finally { + setIsLoading(false) + } + } + + return ( + + + KPI Values + Edit the KPI values. + + +
+ +
+ ( + + KPI 1 + + + + + + )} + /> + ( + + KPI 2 + + + + + + )} + /> + ( + + KPI 3 + + + + + + )} + /> + ( + + KPI 4 + + + + + + )} + /> +
+ +
+ +
+
+ ) +} diff --git a/apps/www/src/components/analyse/EditMarketDataCard.tsx b/apps/www/src/components/analyse/EditMarketDataCard.tsx new file mode 100644 index 0000000..69201a0 --- /dev/null +++ b/apps/www/src/components/analyse/EditMarketDataCard.tsx @@ -0,0 +1,293 @@ +"use client" + +import { useState } from "react" +import { updateAnalysis } from "@/actions/update-analysis" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" + +import { Button } from "@dingify/ui/components/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@dingify/ui/components/card" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@dingify/ui/components/form" +import { Input } from "@dingify/ui/components/input" +import { Switch } from "@dingify/ui/components/switch" + +const FormSchema = z.object({ + marketRentOffice: z + .string() + .nonempty("Market Rent Office is required.") + .refine((value) => !isNaN(Number(value)), { + message: "Market Rent Office must be a number.", + }), + marketRentMerch: z + .string() + .nonempty("Market Rent Merch is required.") + .refine((value) => !isNaN(Number(value)), { + message: "Market Rent Merch must be a number.", + }), + marketRentMisc: z + .string() + .nonempty("Market Rent Misc is required.") + .refine((value) => !isNaN(Number(value)), { + message: "Market Rent Misc must be a number.", + }), + usePrimeYield: z.boolean().default(false).optional(), + manYieldOffice: z + .string() + .nonempty("Manual Yield Office is required.") + .optional(), + manYieldMerch: z + .string() + .nonempty("Manual Yield Merch is required.") + .optional(), + manYieldMisc: z + .string() + .nonempty("Manual Yield Misc is required.") + .optional(), + manYieldWeighted: z + .string() + .nonempty("Manual Yield Weighted is required.") + .optional(), +}) + +interface EditMarketDataCardProps { + analysisId: number + initialMarketRentOffice: number + initialMarketRentMerch: number + initialMarketRentMisc: number + initialUsePrimeYield: boolean + initialManYieldOffice?: number + initialManYieldMerch?: number + initialManYieldMisc?: number + initialManYieldWeighted?: number +} + +export function EditMarketDataCard({ + analysisId, + initialMarketRentOffice, + initialMarketRentMerch, + initialMarketRentMisc, + initialUsePrimeYield, + initialManYieldOffice, + initialManYieldMerch, + initialManYieldMisc, + initialManYieldWeighted, +}: EditMarketDataCardProps) { + const [isLoading, setIsLoading] = useState(false) + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + marketRentOffice: initialMarketRentOffice.toString(), + marketRentMerch: initialMarketRentMerch.toString(), + marketRentMisc: initialMarketRentMisc.toString(), + usePrimeYield: initialUsePrimeYield, + manYieldOffice: initialManYieldOffice?.toString() || "", + manYieldMerch: initialManYieldMerch?.toString() || "", + manYieldMisc: initialManYieldMisc?.toString() || "", + manYieldWeighted: initialManYieldWeighted?.toString() || "", + }, + }) + + async function onSubmit(data: z.infer) { + setIsLoading(true) + try { + const result = await updateAnalysis(analysisId, { + marketRentOffice: Number(data.marketRentOffice), + marketRentMerch: Number(data.marketRentMerch), + marketRentMisc: Number(data.marketRentMisc), + usePrimeYield: data.usePrimeYield, + manYieldOffice: data.manYieldOffice + ? Number(data.manYieldOffice) + : undefined, + manYieldMerch: data.manYieldMerch + ? Number(data.manYieldMerch) + : undefined, + manYieldMisc: data.manYieldMisc ? Number(data.manYieldMisc) : undefined, + manYieldWeighted: data.manYieldWeighted + ? Number(data.manYieldWeighted) + : undefined, + }) + if (result.success) { + toast.success("Analysis updated successfully.") + } else { + throw new Error(result.error || "Failed to update analysis.") + } + } catch (error) { + toast.error(error.message) + console.error("Error updating analysis:", error) + } finally { + setIsLoading(false) + } + } + + return ( + + + Market Data + Edit market rent and yield data. + + +
+ + ( + + Market Rent Office + + + + + + )} + /> + ( + + Market Rent Merch + + + + + + )} + /> + ( + + Market Rent Misc + + + + + + )} + /> + ( + + Use Prime Yield + + + + + )} + /> + {form.watch("usePrimeYield") && ( + <> + ( + + Manual Yield Office + + + + + + )} + /> + ( + + Manual Yield Merch + + + + + + )} + /> + ( + + Manual Yield Misc + + + + + + )} + /> + ( + + Manual Yield Weighted + + + + + + )} + /> + + )} + + + +
+
+ ) +} diff --git a/apps/www/src/components/analyse/EditOwnerCostsCard.tsx b/apps/www/src/components/analyse/EditOwnerCostsCard.tsx new file mode 100644 index 0000000..3c7808c --- /dev/null +++ b/apps/www/src/components/analyse/EditOwnerCostsCard.tsx @@ -0,0 +1,367 @@ +"use client" + +import { useState } from "react" +import { updateAnalysis } from "@/actions/update-analysis" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" + +import { Button } from "@dingify/ui/components/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@dingify/ui/components/card" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@dingify/ui/components/form" +import { Input } from "@dingify/ui/components/input" +import { Switch } from "@dingify/ui/components/switch" + +const FormSchema = z.object({ + ownerCostsMethod: z.boolean().default(false).optional(), + ownerCostsManual: z + .string() + .nonempty("Owner Costs Manual is required.") + .optional(), + costMaintenance: z + .string() + .nonempty("Cost Maintenance is required.") + .optional(), + costInsurance: z.string().nonempty("Cost Insurance is required.").optional(), + costRevision: z.string().nonempty("Cost Revision is required.").optional(), + costAdm: z.string().nonempty("Cost Adm is required.").optional(), + costOther: z.string().nonempty("Cost Other is required.").optional(), + costNegotiation: z + .string() + .nonempty("Cost Negotiation is required.") + .optional(), + costLegalFees: z.string().nonempty("Cost Legal Fees is required.").optional(), + costConsultFees: z + .string() + .nonempty("Cost Consult Fees is required.") + .optional(), + costAssetMgmt: z.string().nonempty("Cost Asset Mgmt is required.").optional(), + costSum: z.string().nonempty("Cost Sum is required.").optional(), +}) + +interface EditOwnerCostsCardProps { + analysisId: number + initialOwnerCostsMethod: boolean + initialOwnerCostsManual?: number + initialCostMaintenance?: number + initialCostInsurance?: number + initialCostRevision?: number + initialCostAdm?: number + initialCostOther?: number + initialCostNegotiation?: number + initialCostLegalFees?: number + initialCostConsultFees?: number + initialCostAssetMgmt?: number + initialCostSum?: number +} + +export function EditOwnerCostsCard({ + analysisId, + initialOwnerCostsMethod, + initialOwnerCostsManual, + initialCostMaintenance, + initialCostInsurance, + initialCostRevision, + initialCostAdm, + initialCostOther, + initialCostNegotiation, + initialCostLegalFees, + initialCostConsultFees, + initialCostAssetMgmt, + initialCostSum, +}: EditOwnerCostsCardProps) { + const [isLoading, setIsLoading] = useState(false) + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + ownerCostsMethod: initialOwnerCostsMethod, + ownerCostsManual: initialOwnerCostsManual?.toString() || "", + costMaintenance: initialCostMaintenance?.toString() || "", + costInsurance: initialCostInsurance?.toString() || "", + costRevision: initialCostRevision?.toString() || "", + costAdm: initialCostAdm?.toString() || "", + costOther: initialCostOther?.toString() || "", + costNegotiation: initialCostNegotiation?.toString() || "", + costLegalFees: initialCostLegalFees?.toString() || "", + costConsultFees: initialCostConsultFees?.toString() || "", + costAssetMgmt: initialCostAssetMgmt?.toString() || "", + costSum: initialCostSum?.toString() || "", + }, + }) + + async function onSubmit(data: z.infer) { + setIsLoading(true) + try { + const result = await updateAnalysis(analysisId, { + ownerCostsMethod: data.ownerCostsMethod, + ownerCostsManual: data.ownerCostsManual + ? Number(data.ownerCostsManual) + : undefined, + costMaintenance: data.costMaintenance + ? Number(data.costMaintenance) + : undefined, + costInsurance: data.costInsurance + ? Number(data.costInsurance) + : undefined, + costRevision: data.costRevision ? Number(data.costRevision) : undefined, + costAdm: data.costAdm ? Number(data.costAdm) : undefined, + costOther: data.costOther ? Number(data.costOther) : undefined, + costNegotiation: data.costNegotiation + ? Number(data.costNegotiation) + : undefined, + costLegalFees: data.costLegalFees + ? Number(data.costLegalFees) + : undefined, + costConsultFees: data.costConsultFees + ? Number(data.costConsultFees) + : undefined, + costAssetMgmt: data.costAssetMgmt + ? Number(data.costAssetMgmt) + : undefined, + costSum: data.costSum ? Number(data.costSum) : undefined, + }) + if (result.success) { + toast.success("Analysis updated successfully.") + } else { + throw new Error(result.error || "Failed to update analysis.") + } + } catch (error) { + toast.error(error.message) + console.error("Error updating analysis:", error) + } finally { + setIsLoading(false) + } + } + + return ( + + + Owner Costs + Edit owner costs and related values. + + +
+ + ( + + Owner Costs Method + + + + + )} + /> + {form.watch("ownerCostsMethod") ? ( + <> + ( + + Cost Maintenance + + + + + + )} + /> + ( + + Cost Insurance + + + + + + )} + /> + ( + + Cost Revision + + + + + + )} + /> + ( + + Cost Adm + + + + + + )} + /> + ( + + Cost Other + + + + + + )} + /> + ( + + Cost Negotiation + + + + + + )} + /> + ( + + Cost Legal Fees + + + + + + )} + /> + ( + + Cost Consult Fees + + + + + + )} + /> + ( + + Cost Asset Mgmt + + + + + + )} + /> + ( + + Cost Sum + + + + + + )} + /> + + ) : ( + ( + + Owner Costs Manual + + + + + + )} + /> + )} + + + +
+
+ ) +} diff --git a/apps/www/src/components/analyse/EditROICard.tsx b/apps/www/src/components/analyse/EditROICard.tsx new file mode 100644 index 0000000..a728fea --- /dev/null +++ b/apps/www/src/components/analyse/EditROICard.tsx @@ -0,0 +1,194 @@ +"use client" + +import { useState } from "react" +import { updateAnalysis } from "@/actions/update-analysis" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" + +import { Button } from "@dingify/ui/components/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@dingify/ui/components/card" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@dingify/ui/components/form" +import { Input } from "@dingify/ui/components/input" +import { Switch } from "@dingify/ui/components/switch" + +const FormSchema = z.object({ + useCalcROI: z.boolean().default(false).optional(), + roiWeightedYield: z + .string() + .nonempty("Weighted Yield is required.") + .optional(), + roiInflation: z.string().nonempty("Inflation is required.").optional(), + roiCalculated: z.string().nonempty("Calculated ROI is required.").optional(), + roiManual: z.string().nonempty("Manual ROI is required.").optional(), +}) + +interface EditROICardProps { + analysisId: number + initialUseCalcROI: boolean + initialROIWeightedYield?: number + initialROIInflation?: number + initialROICalculated?: number + initialROIManual?: number +} + +export function EditROICard({ + analysisId, + initialUseCalcROI, + initialROIWeightedYield, + initialROIInflation, + initialROICalculated, + initialROIManual, +}: EditROICardProps) { + const [isLoading, setIsLoading] = useState(false) + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + useCalcROI: initialUseCalcROI, + roiWeightedYield: initialROIWeightedYield?.toString() || "", + roiInflation: initialROIInflation?.toString() || "", + roiCalculated: initialROICalculated?.toString() || "", + roiManual: initialROIManual?.toString() || "", + }, + }) + + async function onSubmit(data: z.infer) { + setIsLoading(true) + try { + const result = await updateAnalysis(analysisId, { + useCalcROI: data.useCalcROI, + roiWeightedYield: data.roiWeightedYield + ? Number(data.roiWeightedYield) + : undefined, + roiInflation: data.roiInflation ? Number(data.roiInflation) : undefined, + roiCalculated: data.roiCalculated + ? Number(data.roiCalculated) + : undefined, + roiManual: data.roiManual ? Number(data.roiManual) : undefined, + }) + if (result.success) { + toast.success("Analysis updated successfully.") + } else { + throw new Error(result.error || "Failed to update analysis.") + } + } catch (error) { + toast.error(error.message) + console.error("Error updating analysis:", error) + } finally { + setIsLoading(false) + } + } + + return ( + + + ROI Inputs + Edit ROI inputs and related values. + + +
+ + ( + + Use Calculated ROI + + + + + )} + /> + {form.watch("useCalcROI") ? ( + <> + ( + + Weighted Yield + + + + + + )} + /> + ( + + Inflation + + + + + + )} + /> + ( + + Calculated ROI + + + + + + )} + /> + + ) : ( + ( + + Manual ROI + + + + + + )} + /> + )} + + + +
+
+ ) +} diff --git a/apps/www/src/components/analyse/EditRentableAreaCard.tsx b/apps/www/src/components/analyse/EditRentableAreaCard.tsx new file mode 100644 index 0000000..59ad195 --- /dev/null +++ b/apps/www/src/components/analyse/EditRentableAreaCard.tsx @@ -0,0 +1,197 @@ +"use client" + +import { useState } from "react" +import { updateAnalysis } from "@/actions/update-analysis" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" + +import { Button } from "@dingify/ui/components/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@dingify/ui/components/card" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@dingify/ui/components/form" +import { Input } from "@dingify/ui/components/input" + +const FormSchema = z.object({ + rentableArea: z + .string() + .nonempty("Rentable area is required.") + .refine((value) => !isNaN(Number(value)), { + message: "Rentable area must be a number.", + }), + ratioAreaOffice: z + .string() + .nonempty("Ratio Area Office is required.") + .refine( + (value) => + !isNaN(Number(value)) && Number(value) >= 0 && Number(value) <= 1, + { + message: "Ratio Area Office must be a number between 0 and 1.", + }, + ), + ratioAreaMerch: z + .string() + .nonempty("Ratio Area Merch is required.") + .refine( + (value) => + !isNaN(Number(value)) && Number(value) >= 0 && Number(value) <= 1, + { + message: "Ratio Area Merch must be a number between 0 and 1.", + }, + ), + ratioAreaMisc: z + .string() + .nonempty("Ratio Area Misc is required.") + .refine( + (value) => + !isNaN(Number(value)) && Number(value) >= 0 && Number(value) <= 1, + { + message: "Ratio Area Misc must be a number between 0 and 1.", + }, + ), +}) + +interface EditRentableAreaCardProps { + analysisId: number + initialRentableArea: number + initialRatioAreaOffice: number + initialRatioAreaMerch: number + initialRatioAreaMisc: number +} + +export function EditRentableAreaCard({ + analysisId, + initialRentableArea, + initialRatioAreaOffice, + initialRatioAreaMerch, + initialRatioAreaMisc, +}: EditRentableAreaCardProps) { + const [isLoading, setIsLoading] = useState(false) + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + rentableArea: initialRentableArea.toString(), + ratioAreaOffice: initialRatioAreaOffice.toString(), + ratioAreaMerch: initialRatioAreaMerch.toString(), + ratioAreaMisc: initialRatioAreaMisc.toString(), + }, + }) + + async function onSubmit(data: z.infer) { + setIsLoading(true) + try { + const result = await updateAnalysis(analysisId, { + rentableArea: Number(data.rentableArea), + ratioAreaOffice: Number(data.ratioAreaOffice), + ratioAreaMerch: Number(data.ratioAreaMerch), + ratioAreaMisc: Number(data.ratioAreaMisc), + }) + if (result.success) { + toast.success("Analysis updated successfully.") + } else { + throw new Error(result.error || "Failed to update analysis.") + } + } catch (error) { + toast.error(error.message) + console.error("Error updating analysis:", error) + } finally { + setIsLoading(false) + } + } + + return ( + + + Rentable Area + Edit rentable area and ratios. + + +
+ + ( + + Rentable Area + + + + + + )} + /> + ( + + Ratio Area Office + + + + + + )} + /> + ( + + Ratio Area Merch + + + + + + )} + /> + ( + + Ratio Area Misc + + + + + + )} + /> + + + +
+
+ ) +} diff --git a/apps/www/src/components/analyse/EditVacancyCard.tsx b/apps/www/src/components/analyse/EditVacancyCard.tsx new file mode 100644 index 0000000..9675d9c --- /dev/null +++ b/apps/www/src/components/analyse/EditVacancyCard.tsx @@ -0,0 +1,152 @@ +"use client" + +import { useState } from "react" +import { updateAnalysis } from "@/actions/update-analysis" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" + +import { Button } from "@dingify/ui/components/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@dingify/ui/components/card" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@dingify/ui/components/form" +import { Input } from "@dingify/ui/components/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@dingify/ui/components/select" + +const FormSchema = z.object({ + year: z.string().min(1, "Year is required"), + value: z.string().min(1, "Value is required"), +}) + +interface EditVacancyCardProps { + analysisId: number + initialVacancyPerYear: string // JSON string of vacancy per year +} + +export function EditVacancyCard({ + analysisId, + initialVacancyPerYear, +}: EditVacancyCardProps) { + const [isLoading, setIsLoading] = useState(false) + const [vacancyData, setVacancyData] = useState( + JSON.parse(initialVacancyPerYear), + ) + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + year: "", + value: "", + }, + }) + + const years = Array.from({ length: 10 }, (_, i) => + (new Date().getFullYear() + i).toString(), + ) + + async function onSubmit(data: z.infer) { + setIsLoading(true) + try { + const updatedVacancyData = { ...vacancyData, [data.year]: data.value } + const result = await updateAnalysis(analysisId, { + vacancyPerYear: JSON.stringify(updatedVacancyData), + }) + if (result.success) { + toast.success("Analysis updated successfully.") + setVacancyData(updatedVacancyData) + } else { + throw new Error(result.error || "Failed to update analysis.") + } + } catch (error) { + toast.error(error.message) + console.error("Error updating analysis:", error) + } finally { + setIsLoading(false) + } + } + + return ( + + + Vacancy Per Year + Edit vacancy per year values. + + +
+ + ( + + Year + + + + )} + /> + ( + + Value + + + + + + )} + /> + + + +
+ {Object.entries(vacancyData).map(([year, value]) => ( +
+ {year} + {value} +
+ ))} +
+
+
+ ) +} diff --git a/apps/www/src/components/buttons/AddAnalysisSheet.tsx b/apps/www/src/components/buttons/AddAnalysisSheet.tsx new file mode 100644 index 0000000..a2b8991 --- /dev/null +++ b/apps/www/src/components/buttons/AddAnalysisSheet.tsx @@ -0,0 +1,215 @@ +"use client" + +import { useEffect, useState } from "react" +import { createAnalysis } from "@/actions/create-analyse" +import { getBuildings } from "@/actions/get-buildings" // Import this function +import { getProperties } from "@/actions/get-properties" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" + +import { Button } from "@dingify/ui/components/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@dingify/ui/components/form" +import { Input } from "@dingify/ui/components/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@dingify/ui/components/select" +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@dingify/ui/components/sheet" + +const AnalysisSchema = z.object({ + name: z.string().min(1, "Name is required"), + propertyId: z.string().min(1, "Property is required"), + buildingId: z.string().min(1, "Building is required"), +}) + +interface Property { + id: string + name: string +} + +interface Building { + id: string + name: string +} + +export function AddAnalysisSheet() { + const [isLoading, setIsLoading] = useState(false) + const [properties, setProperties] = useState([]) + const [buildings, setBuildings] = useState([]) + + const form = useForm({ + resolver: zodResolver(AnalysisSchema), + defaultValues: { + name: "", + propertyId: "", + buildingId: "", + }, + }) + + useEffect(() => { + async function fetchProperties() { + try { + const properties = await getProperties() + setProperties(properties) + } catch (error) { + console.error("Failed to fetch properties:", error) + } + } + fetchProperties() + }, []) + + const onPropertyChange = async (propertyId: string) => { + form.setValue("propertyId", propertyId) + try { + const buildings = await getBuildings(propertyId) + setBuildings(buildings) + form.setValue("buildingId", "") // Reset building selection when property changes + } catch (error) { + console.error("Failed to fetch buildings:", error) + } + } + + const onSubmit = async (data: z.infer) => { + setIsLoading(true) + + try { + const analysisData = { + ...data, + } + + const result = await createAnalysis(analysisData) + + if (!result.success) { + throw new Error(result.error || "Failed to save analysis.") + } + + toast.success(`Analysis for property was saved.`) + form.reset() + // Optionally, refresh the page or update the state to show the new analysis + } catch (error) { + toast.error(error.message) + console.error(error) + } finally { + setIsLoading(false) + } + } + + return ( + + + + + + + Lag en ny analyse + Skriv basis informasjon + + +
+ + ( + + Name + + + + + + )} + /> + ( + + Property + + + + )} + /> + ( + + Building + + + + )} + /> + + + + + +
+
+ ) +} diff --git a/apps/www/src/components/shared/icons.tsx b/apps/www/src/components/shared/icons.tsx index 49934ed..0844497 100644 --- a/apps/www/src/components/shared/icons.tsx +++ b/apps/www/src/components/shared/icons.tsx @@ -143,3 +143,47 @@ export const Icons = { ), } + +export function PlusIcon(props: LucideProps) { + return ( + + + + + ) +} + +export function BirdIcon(props: LucideProps) { + return ( + + + + + + + + + ) +} diff --git a/apps/www/src/components/tenant/TenantSendInvoice.tsx b/apps/www/src/components/tenant/TenantSendInvoice.tsx new file mode 100644 index 0000000..4fd456a --- /dev/null +++ b/apps/www/src/components/tenant/TenantSendInvoice.tsx @@ -0,0 +1,538 @@ +"use client" + +import { useEffect, useState } from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { addDays, differenceInCalendarDays, format } from "date-fns" +import { nb } from "date-fns/locale" +import { CalendarIcon, PlusIcon, SendIcon } from "lucide-react" +import { useForm, useWatch } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" + +import { Button } from "@dingify/ui/components/button" +import { Calendar } from "@dingify/ui/components/calendar" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@dingify/ui/components/form" +import { Input } from "@dingify/ui/components/input" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@dingify/ui/components/popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@dingify/ui/components/select" +import { Separator } from "@dingify/ui/components/separator" +import { Textarea } from "@dingify/ui/components/textarea" + +import { cn } from "@/lib/utils" + +const InvoiceSchema = z.object({ + customer: z.string().min(1, "Kunde er pÄkrevd"), + email: z.string().email("Ugyldig epostadresse").min(1, "Epost er pÄkrevd"), + ourReference: z.string().optional(), + theirReference: z.string().optional(), + orderReference: z.string().optional(), + product: z.string().min(1, "Produkt er pÄkrevd"), + quantity: z.number().min(1, "Antall mÄ vÊre minst 1"), + price: z.number().min(0, "Pris mÄ vÊre minst 0"), + invoiceEmail: z + .string() + .email("Ugyldig epostadresse") + .min(1, "Epost er pÄkrevd"), + date: z.date({ required_error: "Dato er pÄkrevd" }), + dueDate: z.date({ required_error: "Forfallsdato er pÄkrevd" }), + accountNumber: z.string().min(1, "Kontonummer er pÄkrevd"), + comment: z.string().optional(), +}) + +export default function TenantSendInvoice({ customers, products }) { + const today = new Date() + const fourteenDaysFromToday = addDays(today, 14) + + const form = useForm({ + resolver: zodResolver(InvoiceSchema), + defaultValues: { + customer: "", + email: "", + ourReference: "", + theirReference: "", + orderReference: "", + product: "", + quantity: 1, + price: 0, + invoiceEmail: "", + date: today, + dueDate: fourteenDaysFromToday, + accountNumber: "", + comment: "", + }, + }) + + const quantity = useWatch({ control: form.control, name: "quantity" }) + const price = useWatch({ control: form.control, name: "price" }) + const date = useWatch({ control: form.control, name: "date" }) + const dueDate = useWatch({ control: form.control, name: "dueDate" }) + + const totalPrice = quantity * price + const vat = totalPrice * 0.25 + const totalPriceWithVat = totalPrice + vat + + const daysBetween = + date && dueDate ? differenceInCalendarDays(dueDate, date) : 0 + + const onSubmit = async (data) => { + const promise = new Promise((resolve, reject) => { + setTimeout(() => { + const isSuccess = true + if (isSuccess) { + resolve({ name: "Invoice" }) + } else { + reject("Error creating invoice") + } + }, 2000) + }) + + toast.promise(promise, { + loading: "Vennligst vent...", + success: (data) => { + return `Faktura er blitt sendt` + }, + error: "Error", + }) + + promise + .then(() => { + console.log(data) + form.reset() + }) + .catch((error) => { + console.error(error) + }) + } + + return ( +
+
+
+

Lag faktura

+
+ + +
+
+
+
+
+ +
+ + Kunde + + ( + + Kunde + + + + + + )} + /> + ( + + Epost + + + + + + )} + /> +
+ ( + + VĂ„r referanse + + + + + + )} + /> + ( + + Deres referanse + + + + + + )} + /> +
+ ( + + Ordrereferanse + + + + + + )} + /> +
+
+ + Produkter + + ( + + Produkter + + + + + + )} + /> + ( + + Antall + + + field.onChange(parseFloat(e.target.value)) + } + /> + + + + )} + /> + ( + + Pris + + + field.onChange(parseFloat(e.target.value)) + } + /> + + + + )} + /> +
+
+ +
+
+
+ +
+ + Faktura + + ( + + Faktura + + + + + + )} + /> +
+ ( + + Dato + + + + + + + + + + + + + )} + /> + ( + + + Forfall + {date && dueDate && ( + + ({daysBetween} dager) + + )} + + + + + + + + + + + + + + )} + /> +
+ ( + + Kontonummer + + + + + + )} + /> +
+
+ + Pris + +
+
+ Sum + {totalPrice.toFixed(2)} NOK +
+
+ Mva + {vat.toFixed(2)} NOK +
+
+ Rabatt + 0% +
+
+ Sum + {totalPriceWithVat.toFixed(2)} NOK (inkl.mva) +
+
+ + ( + + Kommentar + +