Add project management support and integrate customer-project functionality
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
package dev.rheinsw.server.project.controller;
|
||||
|
||||
import dev.rheinsw.server.common.controller.AbstractController;
|
||||
import dev.rheinsw.server.project.model.CreateCustomerProjectDto;
|
||||
import dev.rheinsw.server.project.model.Project;
|
||||
import dev.rheinsw.server.project.model.records.ProjectNote;
|
||||
import dev.rheinsw.server.project.usecase.ProjectUseCaseImpl;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 12.07.25
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/projects")
|
||||
@RequiredArgsConstructor
|
||||
public class ProjectController extends AbstractController {
|
||||
|
||||
private final ProjectUseCaseImpl useCase;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<UUID> create(@RequestBody CreateCustomerProjectDto request) {
|
||||
var currentUser = getUserFromCurrentSession();
|
||||
|
||||
var now = Instant.now();
|
||||
var notes = request.notes().stream().map(n -> new ProjectNote(n.text(), currentUser.getId(), currentUser.getId(), now, now)).toList();
|
||||
|
||||
var result = useCase.createProject(
|
||||
currentUser,
|
||||
request.customerId(),
|
||||
request.name(),
|
||||
request.description(),
|
||||
request.status(),
|
||||
notes
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<Project> findProjectById(@PathVariable("id") UUID id) {
|
||||
var result = useCase.getProjectById(id);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@GetMapping("/customer/{customerId}")
|
||||
public ResponseEntity<List<Project>> findAllCustomerProjects(@PathVariable("customerId") UUID customerId) {
|
||||
var result = useCase.getProjectsByCustomerId(customerId);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package dev.rheinsw.server.project.model;
|
||||
|
||||
import dev.rheinsw.server.project.model.enums.ProjectStatus;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 13.07.25
|
||||
*/
|
||||
public record CreateCustomerProjectDto(
|
||||
UUID customerId, // Reference to the related customer
|
||||
String name, // Project name
|
||||
String description, // Optional project description
|
||||
ProjectStatus status, // Enum for project status
|
||||
List<ProjectNoteDto> notes, // Optional list of project notes
|
||||
LocalDate startDate // Project start date
|
||||
) {
|
||||
|
||||
public record ProjectNoteDto(
|
||||
String text // Note text
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package dev.rheinsw.server.project.model;
|
||||
|
||||
import com.vladmihalcea.hibernate.type.json.JsonType;
|
||||
import dev.rheinsw.server.common.entity.BaseEntity;
|
||||
import dev.rheinsw.server.project.model.enums.ProjectStatus;
|
||||
import dev.rheinsw.server.project.model.records.ProjectNote;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.hibernate.annotations.Type;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 12.07.25
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "project")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Project extends BaseEntity {
|
||||
|
||||
@Id
|
||||
private UUID id;
|
||||
|
||||
private UUID customerId;
|
||||
|
||||
private String name;
|
||||
private String description;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
private ProjectStatus status;
|
||||
|
||||
@Column(name = "notes", columnDefinition = "jsonb")
|
||||
@Type(JsonType.class)
|
||||
private List<ProjectNote> notes;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package dev.rheinsw.server.project.model.enums;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 12.07.25
|
||||
*/
|
||||
public enum ProjectStatus {
|
||||
PLANNED,
|
||||
IN_PROGRESS,
|
||||
COMPLETED,
|
||||
ON_HOLD,
|
||||
CANCELLED
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package dev.rheinsw.server.project.model.records;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 12.07.25
|
||||
*/
|
||||
public record ProjectNote(String text,
|
||||
Long createdBy,
|
||||
Long updatedBy,
|
||||
Instant createdAt,
|
||||
Instant updatedAt) {
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package dev.rheinsw.server.project.repository;
|
||||
|
||||
import dev.rheinsw.server.customer.model.Customer;
|
||||
import dev.rheinsw.server.project.model.Project;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 12.07.25
|
||||
*/
|
||||
public interface ProjectRepository extends JpaRepository<Project, UUID> {
|
||||
List<Project> findByCustomerId(UUID customerId);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package dev.rheinsw.server.project.usecase;
|
||||
|
||||
import dev.rheinsw.server.common.entity.User;
|
||||
import dev.rheinsw.server.project.model.enums.ProjectStatus;
|
||||
import dev.rheinsw.server.project.model.records.ProjectNote;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 13.07.25
|
||||
*/
|
||||
public interface CreateProjectUseCase {
|
||||
UUID createProject(
|
||||
User creator,
|
||||
UUID customerId,
|
||||
String name,
|
||||
String description,
|
||||
ProjectStatus status,
|
||||
List<ProjectNote> notes
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package dev.rheinsw.server.project.usecase;
|
||||
|
||||
import dev.rheinsw.server.project.model.Project;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 12.07.25
|
||||
*/
|
||||
public interface LoadProjectUseCase {
|
||||
Project getProjectById(UUID id);
|
||||
|
||||
List<Project> getProjectsByCustomerId(UUID customerId);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package dev.rheinsw.server.project.usecase;
|
||||
|
||||
import dev.rheinsw.server.common.entity.User;
|
||||
import dev.rheinsw.server.project.model.Project;
|
||||
import dev.rheinsw.server.project.model.enums.ProjectStatus;
|
||||
import dev.rheinsw.server.project.model.records.ProjectNote;
|
||||
import dev.rheinsw.server.project.repository.ProjectRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 12.07.25
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ProjectUseCaseImpl implements LoadProjectUseCase, CreateProjectUseCase {
|
||||
|
||||
private final ProjectRepository repository;
|
||||
|
||||
@Override
|
||||
public UUID createProject(
|
||||
User creator,
|
||||
UUID customerId,
|
||||
String name,
|
||||
String description,
|
||||
ProjectStatus status,
|
||||
List<ProjectNote> notes
|
||||
) {
|
||||
final var now = Instant.now();
|
||||
var enrichedNotes = notes.stream()
|
||||
.map(n -> new ProjectNote(n.text(), creator.getId(), creator.getId(), now, now))
|
||||
.toList();
|
||||
|
||||
Project project = Project.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.customerId(customerId)
|
||||
.name(name)
|
||||
.description(description)
|
||||
.status(status)
|
||||
.notes(enrichedNotes)
|
||||
.build();
|
||||
|
||||
var savedProject = repository.save(project);
|
||||
return savedProject.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Project getProjectById(UUID id) {
|
||||
return repository.findById(id).orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Project> getProjectsByCustomerId(UUID customerId) {
|
||||
return repository.findByCustomerId(customerId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Migration script for Project table and related components
|
||||
CREATE TABLE project
|
||||
(
|
||||
id UUID PRIMARY KEY,
|
||||
customer_id UUID NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(50) NOT NULL, -- ProjectStatus enum
|
||||
notes JSONB, -- JSONB for storing ProjectNotes list
|
||||
start_date VARCHAR(50),
|
||||
end_date VARCHAR(50),
|
||||
created_at TIMESTAMP NOT NULL, -- From BaseEntity
|
||||
updated_at TIMESTAMP, -- From BaseEntity
|
||||
created_by BIGINT, -- From BaseEntity
|
||||
updated_by BIGINT, -- From BaseEntity
|
||||
version BIGINT -- From BaseEntity
|
||||
);
|
||||
|
||||
-- Adding a CHECK constraint to enforce valid ProjectStatus values
|
||||
ALTER TABLE project
|
||||
ADD CONSTRAINT chk_project_status
|
||||
CHECK (status IN ('PLANNED', 'IN_PROGRESS', 'COMPLETED', 'ON_HOLD', 'CANCELLED'));
|
||||
37
internal_frontend/app/api/projects/[id]/route.ts
Normal file
37
internal_frontend/app/api/projects/[id]/route.ts
Normal 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}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
6
internal_frontend/app/api/projects/projectRoutes.ts
Normal file
6
internal_frontend/app/api/projects/projectRoutes.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const projectRoutes = {
|
||||
'create': '/api/projects',
|
||||
getById: (id: string) => `/api/projects/${id}`,
|
||||
getProjectByCustomerId: (customerId: string) => `/api/projects/customer/${customerId}`
|
||||
}
|
||||
;
|
||||
21
internal_frontend/app/api/projects/route.ts
Normal file
21
internal_frontend/app/api/projects/route.ts
Normal 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});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export default function Home() {
|
||||
return (
|
||||
<div>
|
||||
apps
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
102
internal_frontend/app/projects/[id]/page.tsx
Normal file
102
internal_frontend/app/projects/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
internal_frontend/app/projects/page.tsx
Normal file
7
internal_frontend/app/projects/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>> = {
|
||||
|
||||
65
internal_frontend/package-lock.json
generated
65
internal_frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user