Add project management support and integrate customer-project functionality
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
185
internal_frontend/components/ui/select.tsx
Normal file
185
internal_frontend/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user