Add project management support and integrate customer-project functionality

This commit is contained in:
2025-07-15 18:23:53 +00:00
parent 2707aa48bc
commit 03f633ae52
26 changed files with 1135 additions and 43 deletions

View File

@@ -0,0 +1,37 @@
import {NextRequest, NextResponse} from "next/server";
import {serverCall} from "@/lib/api/serverCall";
import {projectRoutes} from "@/app/api/projects/projectRoutes";
export async function GET(request: NextRequest) {
try {
// Extract project ID from the URL
const segments = request.url.split("/");
const projectId = segments.pop();
if (!projectId) {
return NextResponse.json(
{error: "Project ID is required"},
{status: 400}
);
}
// Perform server call to fetch the project details
const response = await serverCall(projectRoutes.getById(projectId), "GET");
if (!response.ok) {
return NextResponse.json(
{error: "Project not found"},
{status: response.status}
);
}
const project = await response.json();
return NextResponse.json(project);
} catch (error) {
console.error("Error fetching project:", error);
return NextResponse.json(
{error: "Failed to fetch project"},
{status: 500}
);
}
}

View File

@@ -0,0 +1,16 @@
import {NextRequest, NextResponse} from "next/server";
import {serverCall} from "@/lib/api/serverCall";
import {projectRoutes} from "@/app/api/projects/projectRoutes";
export async function GET(request: NextRequest) {
const segments = request.url.split('/');
const id = segments[segments.indexOf('customer') + 1];
const response = await serverCall(projectRoutes.getProjectByCustomerId(id), "GET");
if (!response.ok) {
return NextResponse.json({error: "Customer not found"}, {status: 404});
}
const customer = await response.json();
return NextResponse.json(customer);
}

View File

@@ -0,0 +1,6 @@
export const projectRoutes = {
'create': '/api/projects',
getById: (id: string) => `/api/projects/${id}`,
getProjectByCustomerId: (customerId: string) => `/api/projects/customer/${customerId}`
}
;

View File

@@ -0,0 +1,21 @@
import {NextRequest, NextResponse} from "next/server";
import {serverCall} from "@/lib/api/serverCall";
import {projectRoutes} from "@/app/api/projects/projectRoutes";
export async function POST(req: NextRequest) {
try {
// Parse the incoming JSON payload
const body = await req.json();
// Make a POST request to the backend using serverCall
const response = await serverCall(projectRoutes.create, "POST", body);
// Parse and return the backend response
const result = await response.json();
return NextResponse.json(result);
} catch (error) {
// Handle errors gracefully
console.error("Error creating project:", error);
return NextResponse.json({error: "Failed to create project"}, {status: 500});
}
}

View File

@@ -1,7 +0,0 @@
export default function Home() {
return (
<div>
apps
</div>
);
}

View File

@@ -10,6 +10,7 @@ import CustomerDetailContent from "@/components/customers/details/CustomerDetail
import CustomerInformationContent from "@/components/customers/details/sub/ContactInformationContent";
import CustomerPhoneNumberContent from "@/components/customers/details/sub/CustomerPhoneNumberContent";
import CustomerNotesContent from "@/components/customers/details/sub/CustomerNotesContent";
import CustomerProjectsContent from "@/components/customers/details/sub/CustomerProjectsContent";
import {Customer} from "@/services/customers/entities/customer";
export default function CustomerDetailPage() {
@@ -51,7 +52,7 @@ export default function CustomerDetailPage() {
})
.catch((error) => {
if (!isMounted) return;
console.error('Error fetching customer:', error);
console.error("Error fetching customer:", error);
setCustomer(null);
setError("Fehler beim Laden der Kundendaten");
})
@@ -73,17 +74,17 @@ export default function CustomerDetailPage() {
const formatDate = (date: string) => {
try {
const formattedDate = new Date(date).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
const formattedDate = new Date(date).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return formattedDate === 'Invalid Date' ? '-' : formattedDate;
return formattedDate === "Invalid Date" ? "-" : formattedDate;
} catch (error) {
console.error('Error formatting date:', error);
return '-';
console.error("Error formatting date:", error);
return "-";
}
};
@@ -93,7 +94,7 @@ export default function CustomerDetailPage() {
: "Erstellt von - am -",
lastActivityInfo: customer
? `Letzte Aktivität: ${customer.updatedBy || "-"} am ${formatDate(customer.updatedAt)}`
: "Letzte Aktivität: - am -"
: "Letzte Aktivität: - am -",
};
const renderMetadata = () => {
@@ -140,26 +141,29 @@ export default function CustomerDetailPage() {
<CustomerDetailContent
loading={loading}
customer={customer}
sections={
customer
? [
<CustomerInformationContent
key="customerInformation"
customer={customer}
handleOpenDialog={handleOpenDialog}
/>,
<CustomerPhoneNumberContent
key="customerPhoneNumberInfo"
customer={customer}
handleOpenDialog={handleOpenDialog}
/>,
<CustomerNotesContent
key="customerNotes"
customer={customer}
handleOpenDialog={handleOpenDialog}
/>
]
: []
informationSection={
<CustomerInformationContent
customer={customer!}
handleOpenDialog={handleOpenDialog}
/>
}
phoneNumberSection={
<CustomerPhoneNumberContent
customer={customer!}
handleOpenDialog={handleOpenDialog}
/>
}
notesSection={
<CustomerNotesContent
customer={customer!}
handleOpenDialog={handleOpenDialog}
/>
}
projectsSection={
<CustomerProjectsContent
customer={customer!}
handleOpenDialog={handleOpenDialog}
/>
}
/>
)}
@@ -169,4 +173,4 @@ export default function CustomerDetailPage() {
</Dialog>
</div>
);
}
}

View File

@@ -0,0 +1,102 @@
"use client";
import React, {useEffect, useState} from "react";
import {useParams, useRouter} from "next/navigation";
import {ChevronLeft} from "lucide-react";
import {Button} from "@/components/ui/button";
import {Skeleton} from "@/components/ui/skeleton";
import {CustomerProject} from "@/services/projects/entities/customer-project";
export default function ProjectDetailPage() {
const router = useRouter();
const {id} = useParams<{ id: string }>();
const [project, setProject] = useState<CustomerProject | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) {
setError("No project ID provided");
setLoading(false);
return;
}
let isMounted = true;
setLoading(true);
setError(null);
fetch(`/api/projects/${id}`)
.then(async (response) => {
if (!isMounted) return;
if (response.status === 404) {
setError("Project not found");
setProject(null);
return;
}
if (!response.ok) {
throw new Error("Failed to fetch project data");
}
const result = await response.json();
setProject(result);
})
.catch((err) => {
if (!isMounted) return;
console.error("Error fetching project:", err);
setProject(null);
setError("Error loading project data");
})
.finally(() => {
if (isMounted) {
setLoading(false);
}
});
return () => {
isMounted = false;
};
}, [id]);
if (loading) {
return (
<div className="p-6">
<Skeleton className="w-full h-8 mb-4"/>
<Skeleton className="w-full h-6 mb-2"/>
<Skeleton className="w-3/4 h-6"/>
</div>
);
}
if (error) {
return (
<div className="p-6 text-red-500">
<p>{error}</p>
<Button variant="ghost" onClick={() => router.back()}>
<ChevronLeft className="w-4 h-4 mr-1"/>
Go back
</Button>
</div>
);
}
return (
<div className="h-full w-full p-6 space-y-4 text-sm">
<div className="flex justify-between items-start">
<Button
variant="ghost"
onClick={() => router.back()}
aria-label="Go back"
>
<ChevronLeft className="w-4 h-4 mr-1"/>
Zurück
</Button>
</div>
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">{project?.name}</h1>
<p className="text-lg">{project?.description}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,7 @@
export default function ProjectsPage() {
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold">Project Overview</h1>
</div>
);
}

View File

@@ -6,10 +6,20 @@ import React from "react";
type Props = {
loading: boolean;
customer: Customer | null;
sections: React.ReactNode[];
informationSection: React.ReactNode;
phoneNumberSection: React.ReactNode;
notesSection: React.ReactNode;
projectsSection: React.ReactNode;
};
export default function CustomerDetailContent({loading, customer, sections}: Readonly<Props>) {
export default function CustomerDetailContent({
loading,
customer,
informationSection,
phoneNumberSection,
notesSection,
projectsSection,
}: Readonly<Props>) {
if (loading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-fr">
@@ -40,8 +50,22 @@ export default function CustomerDetailContent({loading, customer, sections}: Rea
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-fr">
{sections}
<div className="flex flex-col gap-4 h-full">
{/* Row 1: 50/50 layout */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{informationSection}
{phoneNumberSection}
</div>
{/* Row 2: Fill remaining height */}
<div className="flex-1 grid grid-cols-1 xl:grid-cols-10 gap-4 min-h-0">
<div className="xl:col-span-6 flex flex-col min-h-0">
<div className="flex-1 overflow-auto">{notesSection}</div>
</div>
<div className="xl:col-span-4 flex flex-col min-h-0">
<div className="flex-1 overflow-auto">{projectsSection}</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,271 @@
import React, {useEffect, useState} from "react";
import {useRouter} from "next/navigation";
import {Card} from "@/components/ui/card";
import {Button} from "@/components/ui/button";
import {ChevronDown, ChevronRight, ExternalLink, Folder, Pencil, Plus, Trash2} from "lucide-react";
import {DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,} from "@/components/ui/dialog";
import {Label} from "@/components/ui/label";
import {Input} from "@/components/ui/input";
import {Textarea} from "@/components/ui/textarea";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
import {AnimatePresence, motion} from "framer-motion";
import {CustomerProject, CustomerProjectStatus, getStatusText} from "@/services/projects/entities/customer-project";
import {CreateCustomerProjectDto, ProjectNoteDto} from "@/services/projects/dtos/create-project.dto";
interface Props {
customer: { id: string; companyName: string };
handleOpenDialog: (content: React.ReactNode) => void;
}
export default function CustomerProjectsContent({customer, handleOpenDialog}: Readonly<Props>) {
const router = useRouter();
const [expandedProjects, setExpandedProjects] = useState<number[]>([]);
const [projects, setProjects] = useState<CustomerProject[]>([]);
const [selectedStatus, setSelectedStatus] = useState<CustomerProjectStatus>(CustomerProjectStatus.PLANNED);
const [notes, setNotes] = useState<ProjectNoteDto[]>([]);
const loadProjects = async () => {
try {
const response = await fetch(`/api/projects/customer/${customer.id}`);
if (!response.ok) throw new Error("Failed to fetch projects");
const data = await response.json();
setProjects(data);
} catch (error) {
console.error("Error loading projects:", error);
setProjects([]);
}
};
useEffect(() => {
loadProjects();
}, [customer.id]);
const toggleProject = (index: number) => {
setExpandedProjects((prev) =>
prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index]
);
};
const handleAddNote = () => {
// Ensure a blank note is added when the button is clicked
setNotes((prev) => [...prev, {text: ""}]);
};
const handleUpdateNote = (index: number, text: string) => {
setNotes((prev) =>
prev.map((note, i) => (i === index ? {...note, text} : note))
);
};
const handleRemoveNote = (index: number) => {
setNotes((prev) => prev.filter((_, i) => i !== index));
};
const handleAddProject = () => {
handleOpenDialog(
<DialogContent>
<DialogHeader>
<DialogTitle>Neues Projekt hinzufügen</DialogTitle>
<DialogDescription>
Füge ein neues Projekt für <b>{customer.companyName}</b> hinzu.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="project-name">Projektname</Label>
<Input id="project-name" placeholder="Projektname"/>
</div>
<div className="space-y-2">
<Label htmlFor="project-description">Beschreibung</Label>
<Textarea
id="project-description"
placeholder="Projektbeschreibung"
className="h-20"
/>
</div>
<div className="space-y-2">
<Label>Status</Label>
<Select
value={selectedStatus}
onValueChange={(newStatus: CustomerProjectStatus) => setSelectedStatus(newStatus)}
>a
<SelectTrigger id="project-status">
<SelectValue placeholder="Status auswählen"/>
</SelectTrigger>
<SelectContent>
{Object.values(CustomerProjectStatus).map((status) => (
<SelectItem key={status} value={status}>
{getStatusText(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="project-notes">Notizen</Label>
<div className="space-y-2">
{notes.map((note, index) => (
<div key={index} className="flex items-center gap-2">
<Textarea
placeholder={`Notiz ${index + 1}`}
value={note.text}
onChange={(e) => handleUpdateNote(index, e.target.value)}
className="h-20 flex-1"
/>
<Button
variant="destructive"
size="icon"
onClick={() => handleRemoveNote(index)}
>
<Trash2 className="w-4 h-4"/>
</Button>
</div>
))}
<Button size="sm" variant="outline" onClick={handleAddNote}>
Notiz hinzufügen
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenDialog(null)}>
Abbrechen
</Button>
<Button
type="submit"
onClick={async () => {
const name = (document.getElementById("project-name") as HTMLInputElement)?.value;
const description = (document.getElementById("project-description") as HTMLTextAreaElement)?.value;
const projectPayload: CreateCustomerProjectDto = {
customerId: customer.id,
name,
description,
status: selectedStatus,
notes,
};
try {
const response = await fetch("/api/projects", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(projectPayload),
});
if (!response.ok) {
throw new Error("Failed to create project");
}
await loadProjects();
handleOpenDialog(null);
} catch (error) {
console.error("Error creating project:", error);
}
}}
>
Speichern
</Button>
</DialogFooter>
</DialogContent>
);
};
return (
<Card className="p-4 md:col-span-1">
<div className="flex justify-between items-start">
<div className="flex items-center gap-2">
<Folder className="w-4 h-4 text-muted-foreground"/>
<h2 className="text-lg font-semibold">Projekte</h2>
</div>
<Button size="icon" variant="ghost" onClick={handleAddProject}>
<Plus className="w-4 h-4"/>
</Button>
</div>
<div className="space-y-1 mt-2">
{projects.length > 0 ? (
projects.map((project, index) => (
<motion.div
key={index}
initial={false}
className="group rounded-md hover:bg-muted/50 transition-colors"
>
<div className="px-2 py-1.5">
<div className="flex items-center justify-between">
<button
type="button"
className="flex items-center gap-2 flex-1 text-left"
onClick={() => toggleProject(index)}
>
<span className="text-muted-foreground">
{expandedProjects.includes(index) ? (
<ChevronDown className="w-4 h-4"/>
) : (
<ChevronRight className="w-4 h-4"/>
)}
</span>
<span className="font-medium">{project.name}</span>
<span
className="ml-2 text-xs rounded-full px-2 py-0.5 bg-muted text-muted-foreground"
>
{project.status}
</span>
</button>
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
onClick={() => router.push(`/projects/${project.id}`)}
>
<ExternalLink className="w-4 h-4"/>
</Button>
</TooltipTrigger>
<TooltipContent>Projekt öffnen</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost">
<Pencil className="w-4 h-4"/>
</Button>
</TooltipTrigger>
<TooltipContent>Bearbeiten</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost">
<Trash2 className="w-4 h-4 text-destructive"/>
</Button>
</TooltipTrigger>
<TooltipContent>Löschen</TooltipContent>
</Tooltip>
</div>
</div>
<AnimatePresence>
{expandedProjects.includes(index) && (
<motion.div
initial={{height: 0, opacity: 0}}
animate={{height: "auto", opacity: 1}}
exit={{height: 0, opacity: 0}}
transition={{duration: 0.2}}
className="overflow-hidden"
>
<div className="pt-2 pl-6 text-sm space-y-1">
<div className="text-muted-foreground">{project.description}</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
))
) : (
<div className="text-muted-foreground italic px-2 py-2">
Keine Projekte vorhanden.
</div>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,185 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -4,6 +4,7 @@ export const breadcrumbMap: Record<string, string> = {
'demo': 'Demo',
'users': 'User Management',
'customers': 'Kundenübersicht',
'projects': 'Projekte',
};
export const breadcrumbResolvers: Record<string, (id: string) => Promise<string>> = {

View File

@@ -13,6 +13,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7",
@@ -1033,6 +1034,12 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
@@ -1534,6 +1541,49 @@
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz",
"integrity": "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.10",
"@radix-ui/react-focus-guards": "1.1.2",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.7",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
@@ -1694,6 +1744,21 @@
}
}
},
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",

View File

@@ -14,6 +14,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7",

View File

@@ -0,0 +1,14 @@
import {CustomerProjectStatus} from "@/services/projects/entities/customer-project";
export interface ProjectNoteDto {
text: string;
}
export interface CreateCustomerProjectDto {
customerId: string; // Reference to the related customer
name: string; // Project name
description?: string; // Optional project description
status: CustomerProjectStatus; // Enum for project status
notes?: ProjectNoteDto[]; // Optional list of project notes
startDate?: string; // Project start date (optional)
}

View File

@@ -0,0 +1,40 @@
export enum CustomerProjectStatus {
PLANNED = 'PLANNED',
IN_PROGRESS = 'IN_PROGRESS',
COMPLETED = 'COMPLETED',
ON_HOLD = 'ON_HOLD',
CANCELLED = 'CANCELLED'
}
export interface CustomerProject {
id: string;
customerId: string;
name: string;
description: string;
status: CustomerProjectStatus;
notes?: {
text: string;
createdBy: number;
updatedBy: number;
createdAt: string;
updatedAt: string;
}[];
startDate: string;
endDate: string;
createdBy: number;
lastModifier: string;
updatedBy: number;
createdAt: string;
updatedAt: string;
}
export function getStatusText(status: CustomerProjectStatus): string {
const translations = {
[CustomerProjectStatus.PLANNED]: 'Geplant',
[CustomerProjectStatus.IN_PROGRESS]: 'In Bearbeitung',
[CustomerProjectStatus.COMPLETED]: 'Abgeschlossen',
[CustomerProjectStatus.ON_HOLD]: 'Pausiert',
[CustomerProjectStatus.CANCELLED]: 'Abgebrochen'
};
return translations[status];
}