diff --git a/backend/src/main/java/com/ldpv2/controller/BusinessUnitController.java b/backend/src/main/java/com/ldpv2/controller/BusinessUnitController.java new file mode 100644 index 0000000..b9e01e7 --- /dev/null +++ b/backend/src/main/java/com/ldpv2/controller/BusinessUnitController.java @@ -0,0 +1,89 @@ +package com.ldpv2.controller; + +import com.ldpv2.dto.request.CreateBusinessUnitRequest; +import com.ldpv2.dto.request.UpdateBusinessUnitRequest; +import com.ldpv2.dto.response.BusinessUnitResponse; +import com.ldpv2.service.BusinessUnitService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@RestController +@RequestMapping("/business-units") +@Tag(name = "Business Units", description = "Business unit management endpoints") +@SecurityRequirement(name = "bearerAuth") +public class BusinessUnitController { + + @Autowired + private BusinessUnitService businessUnitService; + + @PostMapping + @Operation(summary = "Create business unit", description = "Create a new business unit") + public ResponseEntity create(@Valid @RequestBody CreateBusinessUnitRequest request) { + BusinessUnitResponse response = businessUnitService.create(request); + return new ResponseEntity<>(response, HttpStatus.CREATED); + } + + @PutMapping("/{id}") + @Operation(summary = "Update business unit", description = "Update an existing business unit") + public ResponseEntity update( + @PathVariable UUID id, + @Valid @RequestBody UpdateBusinessUnitRequest request) { + BusinessUnitResponse response = businessUnitService.update(id, request); + return ResponseEntity.ok(response); + } + + @GetMapping("/{id}") + @Operation(summary = "Get business unit", description = "Get business unit by ID") + public ResponseEntity getById(@PathVariable UUID id) { + BusinessUnitResponse response = businessUnitService.findById(id); + return ResponseEntity.ok(response); + } + + @GetMapping + @Operation(summary = "List business units", description = "Get paginated list of business units") + public ResponseEntity> getAll( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "name") String sortBy, + @RequestParam(defaultValue = "asc") String sortDirection) { + + Sort sort = sortDirection.equalsIgnoreCase("desc") + ? Sort.by(sortBy).descending() + : Sort.by(sortBy).ascending(); + + Pageable pageable = PageRequest.of(page, size, sort); + Page response = businessUnitService.findAll(pageable); + return ResponseEntity.ok(response); + } + + @GetMapping("/search") + @Operation(summary = "Search business units", description = "Search business units by name") + public ResponseEntity> search( + @RequestParam String q, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + + Pageable pageable = PageRequest.of(page, size); + Page response = businessUnitService.search(q, pageable); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/{id}") + @Operation(summary = "Delete business unit", description = "Delete a business unit") + public ResponseEntity delete(@PathVariable UUID id) { + businessUnitService.delete(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/com/ldpv2/domain/entity/BusinessUnit.java b/backend/src/main/java/com/ldpv2/domain/entity/BusinessUnit.java new file mode 100644 index 0000000..7bd0524 --- /dev/null +++ b/backend/src/main/java/com/ldpv2/domain/entity/BusinessUnit.java @@ -0,0 +1,25 @@ +package com.ldpv2.domain.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * Business Unit entity representing organizational units + */ +@Data +@Entity +@Table(name = "business_unit") +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class BusinessUnit extends BaseEntity { + + @Column(nullable = false, unique = true, length = 255) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; +} diff --git a/backend/src/main/java/com/ldpv2/dto/request/CreateBusinessUnitRequest.java b/backend/src/main/java/com/ldpv2/dto/request/CreateBusinessUnitRequest.java new file mode 100644 index 0000000..cdac0de --- /dev/null +++ b/backend/src/main/java/com/ldpv2/dto/request/CreateBusinessUnitRequest.java @@ -0,0 +1,19 @@ +package com.ldpv2.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CreateBusinessUnitRequest { + + @NotBlank(message = "Name is required") + @Size(max = 255, message = "Name must not exceed 255 characters") + private String name; + + private String description; +} diff --git a/backend/src/main/java/com/ldpv2/dto/request/UpdateBusinessUnitRequest.java b/backend/src/main/java/com/ldpv2/dto/request/UpdateBusinessUnitRequest.java new file mode 100644 index 0000000..63c0935 --- /dev/null +++ b/backend/src/main/java/com/ldpv2/dto/request/UpdateBusinessUnitRequest.java @@ -0,0 +1,17 @@ +package com.ldpv2.dto.request; + +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UpdateBusinessUnitRequest { + + @Size(max = 255, message = "Name must not exceed 255 characters") + private String name; + + private String description; +} diff --git a/backend/src/main/java/com/ldpv2/dto/response/BusinessUnitResponse.java b/backend/src/main/java/com/ldpv2/dto/response/BusinessUnitResponse.java new file mode 100644 index 0000000..3385f88 --- /dev/null +++ b/backend/src/main/java/com/ldpv2/dto/response/BusinessUnitResponse.java @@ -0,0 +1,19 @@ +package com.ldpv2.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class BusinessUnitResponse { + private UUID id; + private String name; + private String description; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/ldpv2/dto/response/BusinessUnitSummaryResponse.java b/backend/src/main/java/com/ldpv2/dto/response/BusinessUnitSummaryResponse.java new file mode 100644 index 0000000..1f1f3a4 --- /dev/null +++ b/backend/src/main/java/com/ldpv2/dto/response/BusinessUnitSummaryResponse.java @@ -0,0 +1,15 @@ +package com.ldpv2.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class BusinessUnitSummaryResponse { + private UUID id; + private String name; +} diff --git a/backend/src/main/java/com/ldpv2/repository/BusinessUnitRepository.java b/backend/src/main/java/com/ldpv2/repository/BusinessUnitRepository.java new file mode 100644 index 0000000..14e06c9 --- /dev/null +++ b/backend/src/main/java/com/ldpv2/repository/BusinessUnitRepository.java @@ -0,0 +1,17 @@ +package com.ldpv2.repository; + +import com.ldpv2.domain.entity.BusinessUnit; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface BusinessUnitRepository extends JpaRepository { + Optional findByName(String name); + boolean existsByName(String name); + Page findByNameContainingIgnoreCase(String name, Pageable pageable); +} diff --git a/backend/src/main/java/com/ldpv2/service/BusinessUnitService.java b/backend/src/main/java/com/ldpv2/service/BusinessUnitService.java new file mode 100644 index 0000000..754f1ae --- /dev/null +++ b/backend/src/main/java/com/ldpv2/service/BusinessUnitService.java @@ -0,0 +1,92 @@ +package com.ldpv2.service; + +import com.ldpv2.domain.entity.BusinessUnit; +import com.ldpv2.dto.request.CreateBusinessUnitRequest; +import com.ldpv2.dto.request.UpdateBusinessUnitRequest; +import com.ldpv2.dto.response.BusinessUnitResponse; +import com.ldpv2.exception.BadRequestException; +import com.ldpv2.exception.ResourceNotFoundException; +import com.ldpv2.repository.BusinessUnitRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +@Service +public class BusinessUnitService { + + @Autowired + private BusinessUnitRepository businessUnitRepository; + + @Transactional + public BusinessUnitResponse create(CreateBusinessUnitRequest request) { + // Check if name already exists + if (businessUnitRepository.existsByName(request.getName())) { + throw new BadRequestException("Business unit with name '" + request.getName() + "' already exists"); + } + + BusinessUnit businessUnit = new BusinessUnit(); + businessUnit.setName(request.getName()); + businessUnit.setDescription(request.getDescription()); + + businessUnit = businessUnitRepository.save(businessUnit); + return mapToResponse(businessUnit); + } + + @Transactional + public BusinessUnitResponse update(UUID id, UpdateBusinessUnitRequest request) { + BusinessUnit businessUnit = businessUnitRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Business unit not found with id: " + id)); + + // Check if new name already exists (excluding current business unit) + if (request.getName() != null && !request.getName().equals(businessUnit.getName())) { + if (businessUnitRepository.existsByName(request.getName())) { + throw new BadRequestException("Business unit with name '" + request.getName() + "' already exists"); + } + businessUnit.setName(request.getName()); + } + + if (request.getDescription() != null) { + businessUnit.setDescription(request.getDescription()); + } + + businessUnit = businessUnitRepository.save(businessUnit); + return mapToResponse(businessUnit); + } + + public BusinessUnitResponse findById(UUID id) { + BusinessUnit businessUnit = businessUnitRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Business unit not found with id: " + id)); + return mapToResponse(businessUnit); + } + + public Page findAll(Pageable pageable) { + return businessUnitRepository.findAll(pageable).map(this::mapToResponse); + } + + public Page search(String query, Pageable pageable) { + return businessUnitRepository.findByNameContainingIgnoreCase(query, pageable) + .map(this::mapToResponse); + } + + @Transactional + public void delete(UUID id) { + if (!businessUnitRepository.existsById(id)) { + throw new ResourceNotFoundException("Business unit not found with id: " + id); + } + businessUnitRepository.deleteById(id); + } + + private BusinessUnitResponse mapToResponse(BusinessUnit businessUnit) { + return new BusinessUnitResponse( + businessUnit.getId(), + businessUnit.getName(), + businessUnit.getDescription(), + businessUnit.getCreatedAt(), + businessUnit.getUpdatedAt() + ); + } +} diff --git a/backend/src/main/resources/db/changelog/data/initial-data.xml b/backend/src/main/resources/db/changelog/data/initial-data.xml index 9dc33cd..01f69b7 100644 --- a/backend/src/main/resources/db/changelog/data/initial-data.xml +++ b/backend/src/main/resources/db/changelog/data/initial-data.xml @@ -12,7 +12,7 @@ - + diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.xml b/backend/src/main/resources/db/changelog/db.changelog-master.xml index 8c5523e..209660a 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -10,4 +10,7 @@ + + + diff --git a/backend/src/main/resources/db/changelog/v1.0/003-create-business-unit-table.xml b/backend/src/main/resources/db/changelog/v1.0/003-create-business-unit-table.xml new file mode 100644 index 0000000..3dead8f --- /dev/null +++ b/backend/src/main/resources/db/changelog/v1.0/003-create-business-unit-table.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index a736010..4fa65c2 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -4,13 +4,39 @@ import { authGuard } from './core/guards/auth.guard'; export const routes: Routes = [ { path: '', - redirectTo: '/environments', + redirectTo: '/business-units', pathMatch: 'full' }, { path: 'login', loadComponent: () => import('./core/auth/login/login.component').then(m => m.LoginComponent) }, + { + path: 'business-units', + canActivate: [authGuard], + children: [ + { + path: '', + loadComponent: () => import('./features/business-units/business-unit-list/business-unit-list.component') + .then(m => m.BusinessUnitListComponent) + }, + { + path: 'new', + loadComponent: () => import('./features/business-units/business-unit-form/business-unit-form.component') + .then(m => m.BusinessUnitFormComponent) + }, + { + path: ':id', + loadComponent: () => import('./features/business-units/business-unit-detail/business-unit-detail.component') + .then(m => m.BusinessUnitDetailComponent) + }, + { + path: ':id/edit', + loadComponent: () => import('./features/business-units/business-unit-form/business-unit-form.component') + .then(m => m.BusinessUnitFormComponent) + } + ] + }, { path: 'environments', canActivate: [authGuard], diff --git a/frontend/src/app/features/business-units/business-unit-detail/business-unit-detail.component.html b/frontend/src/app/features/business-units/business-unit-detail/business-unit-detail.component.html new file mode 100644 index 0000000..be17c64 --- /dev/null +++ b/frontend/src/app/features/business-units/business-unit-detail/business-unit-detail.component.html @@ -0,0 +1,38 @@ +
+
Loading...
+
{{ error }}
+ +
+
+

{{ businessUnit.name }}

+
+ + +
+
+ +
+
+ + {{ businessUnit.name }} +
+ +
+ + {{ businessUnit.description || '-' }} +
+ +
+ + {{ businessUnit.createdAt | date:'medium' }} +
+ +
+ + {{ businessUnit.updatedAt | date:'medium' }} +
+
+ + +
+
diff --git a/frontend/src/app/features/business-units/business-unit-detail/business-unit-detail.component.scss b/frontend/src/app/features/business-units/business-unit-detail/business-unit-detail.component.scss new file mode 100644 index 0000000..8f19eb8 --- /dev/null +++ b/frontend/src/app/features/business-units/business-unit-detail/business-unit-detail.component.scss @@ -0,0 +1,95 @@ +.container { + max-width: 800px; + margin: 0 auto; + padding: 2rem; +} + +.loading, .error { + text-align: center; + padding: 2rem; +} + +.error { + color: #f44336; +} + +.detail-card { + background: white; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 2px solid #f5f5f5; + + h1 { + margin: 0; + } + + .actions { + display: flex; + gap: 0.5rem; + } +} + +.details { + margin-bottom: 2rem; +} + +.detail-row { + display: flex; + padding: 1rem 0; + border-bottom: 1px solid #f5f5f5; + + label { + font-weight: 600; + width: 200px; + color: #555; + } + + span { + flex: 1; + color: #333; + } +} + +.btn-primary, .btn-secondary, .btn-danger { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; +} + +.btn-primary { + background-color: #3f51b5; + color: white; + + &:hover { + background-color: #303f9f; + } +} + +.btn-secondary { + background-color: #f5f5f5; + color: #333; + + &:hover { + background-color: #e0e0e0; + } +} + +.btn-danger { + background-color: #f44336; + color: white; + + &:hover { + background-color: #d32f2f; + } +} diff --git a/frontend/src/app/features/business-units/business-unit-detail/business-unit-detail.component.ts b/frontend/src/app/features/business-units/business-unit-detail/business-unit-detail.component.ts new file mode 100644 index 0000000..2f8f998 --- /dev/null +++ b/frontend/src/app/features/business-units/business-unit-detail/business-unit-detail.component.ts @@ -0,0 +1,68 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router, ActivatedRoute } from '@angular/router'; +import { BusinessUnitService } from '../business-unit.service'; +import { BusinessUnit } from '../../../shared/models/business-unit.model'; + +@Component({ + selector: 'app-business-unit-detail', + standalone: true, + imports: [CommonModule], + templateUrl: './business-unit-detail.component.html', + styleUrls: ['./business-unit-detail.component.scss'] +}) +export class BusinessUnitDetailComponent implements OnInit { + businessUnit?: BusinessUnit; + loading = false; + error = ''; + + constructor( + private businessUnitService: BusinessUnitService, + private router: Router, + private route: ActivatedRoute + ) {} + + ngOnInit(): void { + const id = this.route.snapshot.paramMap.get('id'); + if (id) { + this.loadBusinessUnit(id); + } + } + + loadBusinessUnit(id: string): void { + this.loading = true; + this.businessUnitService.getBusinessUnit(id).subscribe({ + next: (bu) => { + this.businessUnit = bu; + this.loading = false; + }, + error: (err) => { + this.error = 'Failed to load business unit'; + this.loading = false; + } + }); + } + + edit(): void { + if (this.businessUnit) { + this.router.navigate(['/business-units', this.businessUnit.id, 'edit']); + } + } + + delete(): void { + if (this.businessUnit && confirm('Are you sure you want to delete this business unit?')) { + this.businessUnitService.deleteBusinessUnit(this.businessUnit.id).subscribe({ + next: () => { + this.router.navigate(['/business-units']); + }, + error: (err) => { + this.error = 'Failed to delete business unit'; + } + }); + } + } + + back(): void { + this.router.navigate(['/business-units']); + } +} diff --git a/frontend/src/app/features/business-units/business-unit-form/business-unit-form.component.html b/frontend/src/app/features/business-units/business-unit-form/business-unit-form.component.html new file mode 100644 index 0000000..7a7432c --- /dev/null +++ b/frontend/src/app/features/business-units/business-unit-form/business-unit-form.component.html @@ -0,0 +1,39 @@ +
+

{{ isEditMode ? 'Edit Business Unit' : 'Create New Business Unit' }}

+ +
+
+ + +
+ Name is required + Name must not exceed 255 characters +
+
+ +
+ + +
+ +
+ {{ error }} +
+ +
+ + +
+
+
diff --git a/frontend/src/app/features/business-units/business-unit-form/business-unit-form.component.scss b/frontend/src/app/features/business-units/business-unit-form/business-unit-form.component.scss new file mode 100644 index 0000000..8c790c2 --- /dev/null +++ b/frontend/src/app/features/business-units/business-unit-form/business-unit-form.component.scss @@ -0,0 +1,90 @@ +.container { + max-width: 600px; + margin: 0 auto; + padding: 2rem; + + h1 { + margin-bottom: 2rem; + } +} + +form { + background: white; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.form-group { + margin-bottom: 1.5rem; + + label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #555; + } + + input[type="text"], + textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + font-family: inherit; + + &:focus { + outline: none; + border-color: #3f51b5; + } + + &.error { + border-color: #f44336; + } + } +} + +.form-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 2rem; +} + +.btn-primary, .btn-secondary { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; +} + +.btn-primary { + background-color: #3f51b5; + color: white; + + &:hover:not(:disabled) { + background-color: #303f9f; + } + + &:disabled { + background-color: #ccc; + cursor: not-allowed; + } +} + +.btn-secondary { + background-color: #f5f5f5; + color: #333; + + &:hover { + background-color: #e0e0e0; + } +} + +.error-message { + color: #f44336; + font-size: 0.875rem; + margin-top: 0.25rem; +} diff --git a/frontend/src/app/features/business-units/business-unit-form/business-unit-form.component.ts b/frontend/src/app/features/business-units/business-unit-form/business-unit-form.component.ts new file mode 100644 index 0000000..402ca2f --- /dev/null +++ b/frontend/src/app/features/business-units/business-unit-form/business-unit-form.component.ts @@ -0,0 +1,80 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { Router, ActivatedRoute } from '@angular/router'; +import { BusinessUnitService } from '../business-unit.service'; + +@Component({ + selector: 'app-business-unit-form', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './business-unit-form.component.html', + styleUrls: ['./business-unit-form.component.scss'] +}) +export class BusinessUnitFormComponent implements OnInit { + form: FormGroup; + loading = false; + error = ''; + isEditMode = false; + businessUnitId?: string; + + constructor( + private fb: FormBuilder, + private businessUnitService: BusinessUnitService, + private router: Router, + private route: ActivatedRoute + ) { + this.form = this.fb.group({ + name: ['', [Validators.required, Validators.maxLength(255)]], + description: [''] + }); + } + + ngOnInit(): void { + this.businessUnitId = this.route.snapshot.paramMap.get('id') || undefined; + this.isEditMode = !!this.businessUnitId; + + if (this.isEditMode && this.businessUnitId) { + this.loadBusinessUnit(this.businessUnitId); + } + } + + loadBusinessUnit(id: string): void { + this.loading = true; + this.businessUnitService.getBusinessUnit(id).subscribe({ + next: (bu) => { + this.form.patchValue(bu); + this.loading = false; + }, + error: (err) => { + this.error = 'Failed to load business unit'; + this.loading = false; + } + }); + } + + onSubmit(): void { + if (this.form.valid) { + this.loading = true; + this.error = ''; + + const request$ = this.isEditMode && this.businessUnitId + ? this.businessUnitService.updateBusinessUnit(this.businessUnitId, this.form.value) + : this.businessUnitService.createBusinessUnit(this.form.value); + + request$.subscribe({ + next: () => { + this.router.navigate(['/business-units']); + }, + error: (err) => { + this.error = err.error?.message || 'Failed to save business unit'; + this.loading = false; + } + }); + } + } + + cancel(): void { + this.router.navigate(['/business-units']); + } +} diff --git a/frontend/src/app/features/business-units/business-unit-list/business-unit-list.component.html b/frontend/src/app/features/business-units/business-unit-list/business-unit-list.component.html new file mode 100644 index 0000000..77f6e3c --- /dev/null +++ b/frontend/src/app/features/business-units/business-unit-list/business-unit-list.component.html @@ -0,0 +1,58 @@ +
+
+

Business Units

+ +
+ + + +
Loading...
+
{{ error }}
+ +
+ + + + + + + + + + + + + + + + + +
NameDescriptionCreatedActions
{{ bu.name }}{{ bu.description || '-' }}{{ bu.createdAt | date:'short' }} + + + +
+
+ +
+ No business units found. Click "Create New Business Unit" to get started. +
+ +
+ No business units match your search "{{ searchQuery }}". +
+ + +
diff --git a/frontend/src/app/features/business-units/business-unit-list/business-unit-list.component.scss b/frontend/src/app/features/business-units/business-unit-list/business-unit-list.component.scss new file mode 100644 index 0000000..44d4748 --- /dev/null +++ b/frontend/src/app/features/business-units/business-unit-list/business-unit-list.component.scss @@ -0,0 +1,140 @@ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + + h1 { + margin: 0; + } +} + +.search-bar { + margin-bottom: 1.5rem; + + .search-input { + width: 100%; + max-width: 400px; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + + &:focus { + outline: none; + border-color: #3f51b5; + } + } +} + +.btn-primary { + background-color: #3f51b5; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 4px; + cursor: pointer; + + &:hover { + background-color: #303f9f; + } +} + +.loading, .error, .empty { + text-align: center; + padding: 2rem; + color: #666; +} + +.error { + color: #f44336; +} + +.table-container { + overflow-x: auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +table { + width: 100%; + border-collapse: collapse; + + th, td { + padding: 1rem; + text-align: left; + border-bottom: 1px solid #ddd; + } + + th { + background-color: #f5f5f5; + font-weight: 600; + } + + tbody tr:hover { + background-color: #f9f9f9; + } +} + +.actions { + display: flex; + gap: 0.5rem; +} + +.btn-sm { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + background-color: #2196f3; + color: white; + font-size: 0.875rem; + + &:hover { + background-color: #1976d2; + } + + &.btn-danger { + background-color: #f44336; + + &:hover { + background-color: #d32f2f; + } + } +} + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + margin-top: 2rem; + + button { + padding: 0.5rem 1rem; + border: 1px solid #ddd; + border-radius: 4px; + background: white; + cursor: pointer; + + &:hover:not(:disabled) { + background-color: #f5f5f5; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + span { + color: #666; + } +} diff --git a/frontend/src/app/features/business-units/business-unit-list/business-unit-list.component.ts b/frontend/src/app/features/business-units/business-unit-list/business-unit-list.component.ts new file mode 100644 index 0000000..fb103c8 --- /dev/null +++ b/frontend/src/app/features/business-units/business-unit-list/business-unit-list.component.ts @@ -0,0 +1,136 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { debounceTime, distinctUntilChanged, Subject } from 'rxjs'; +import { BusinessUnitService } from '../business-unit.service'; +import { BusinessUnit } from '../../../shared/models/business-unit.model'; +import { Page } from '../../../shared/models/environment.model'; + +@Component({ + selector: 'app-business-unit-list', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './business-unit-list.component.html', + styleUrls: ['./business-unit-list.component.scss'] +}) +export class BusinessUnitListComponent implements OnInit { + businessUnits: BusinessUnit[] = []; + loading = false; + error = ''; + + page = 0; + size = 20; + totalElements = 0; + totalPages = 0; + + searchQuery = ''; + private searchSubject = new Subject(); + + constructor( + private businessUnitService: BusinessUnitService, + private router: Router + ) { + // Setup search debounce + this.searchSubject.pipe( + debounceTime(300), + distinctUntilChanged() + ).subscribe(query => { + this.performSearch(query); + }); + } + + ngOnInit(): void { + this.loadBusinessUnits(); + } + + loadBusinessUnits(): void { + this.loading = true; + this.businessUnitService.getBusinessUnits(this.page, this.size).subscribe({ + next: (data: Page) => { + this.businessUnits = data.content; + this.totalElements = data.totalElements; + this.totalPages = data.totalPages; + this.loading = false; + }, + error: (err) => { + this.error = 'Failed to load business units'; + this.loading = false; + } + }); + } + + onSearchChange(query: string): void { + this.searchQuery = query; + this.searchSubject.next(query); + } + + performSearch(query: string): void { + if (!query || query.trim() === '') { + this.page = 0; + this.loadBusinessUnits(); + return; + } + + this.loading = true; + this.businessUnitService.searchBusinessUnits(query, this.page, this.size).subscribe({ + next: (data: Page) => { + this.businessUnits = data.content; + this.totalElements = data.totalElements; + this.totalPages = data.totalPages; + this.loading = false; + }, + error: (err) => { + this.error = 'Search failed'; + this.loading = false; + } + }); + } + + createNew(): void { + this.router.navigate(['/business-units/new']); + } + + viewDetails(id: string): void { + this.router.navigate(['/business-units', id]); + } + + edit(id: string): void { + this.router.navigate(['/business-units', id, 'edit']); + } + + delete(id: string): void { + if (confirm('Are you sure you want to delete this business unit?')) { + this.businessUnitService.deleteBusinessUnit(id).subscribe({ + next: () => { + this.loadBusinessUnits(); + }, + error: (err) => { + this.error = 'Failed to delete business unit'; + } + }); + } + } + + nextPage(): void { + if (this.page < this.totalPages - 1) { + this.page++; + if (this.searchQuery) { + this.performSearch(this.searchQuery); + } else { + this.loadBusinessUnits(); + } + } + } + + previousPage(): void { + if (this.page > 0) { + this.page--; + if (this.searchQuery) { + this.performSearch(this.searchQuery); + } else { + this.loadBusinessUnits(); + } + } + } +} diff --git a/frontend/src/app/features/business-units/business-unit.service.ts b/frontend/src/app/features/business-units/business-unit.service.ts new file mode 100644 index 0000000..fb6fda2 --- /dev/null +++ b/frontend/src/app/features/business-units/business-unit.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { + BusinessUnit, + CreateBusinessUnitRequest, + UpdateBusinessUnitRequest +} from '../../shared/models/business-unit.model'; +import { Page } from '../../shared/models/environment.model'; + +@Injectable({ + providedIn: 'root' +}) +export class BusinessUnitService { + private readonly API_URL = '/api/business-units'; + + constructor(private http: HttpClient) {} + + getBusinessUnits( + page: number = 0, + size: number = 20, + sortBy: string = 'name', + sortDirection: string = 'asc' + ): Observable> { + const params = new HttpParams() + .set('page', page.toString()) + .set('size', size.toString()) + .set('sortBy', sortBy) + .set('sortDirection', sortDirection); + + return this.http.get>(this.API_URL, { params }); + } + + searchBusinessUnits(query: string, page: number = 0, size: number = 20): Observable> { + const params = new HttpParams() + .set('q', query) + .set('page', page.toString()) + .set('size', size.toString()); + + return this.http.get>(`${this.API_URL}/search`, { params }); + } + + getBusinessUnit(id: string): Observable { + return this.http.get(`${this.API_URL}/${id}`); + } + + createBusinessUnit(data: CreateBusinessUnitRequest): Observable { + return this.http.post(this.API_URL, data); + } + + updateBusinessUnit(id: string, data: UpdateBusinessUnitRequest): Observable { + return this.http.put(`${this.API_URL}/${id}`, data); + } + + deleteBusinessUnit(id: string): Observable { + return this.http.delete(`${this.API_URL}/${id}`); + } +} diff --git a/frontend/src/app/shared/models/business-unit.model.ts b/frontend/src/app/shared/models/business-unit.model.ts new file mode 100644 index 0000000..50509d0 --- /dev/null +++ b/frontend/src/app/shared/models/business-unit.model.ts @@ -0,0 +1,22 @@ +export interface BusinessUnit { + id: string; + name: string; + description?: string; + createdAt: Date; + updatedAt: Date; +} + +export interface BusinessUnitSummary { + id: string; + name: string; +} + +export interface CreateBusinessUnitRequest { + name: string; + description?: string; +} + +export interface UpdateBusinessUnitRequest { + name?: string; + description?: string; +}