Hero and Service tile added newly
This commit is contained in:
@ -4,10 +4,7 @@ public class FileConstant {
|
||||
|
||||
public static final String USER_IMAGE_PATH = "/user/image/";
|
||||
public static final String JPG_EXTENSION = "jpg";
|
||||
|
||||
// ✅ CHANGED: From System.getProperty("user.home") to /app/uploads
|
||||
public static final String USER_FOLDER = "/app/uploads/user/";
|
||||
|
||||
public static final String DIRECTORY_CREATED = "Created directory for: ";
|
||||
public static final String DEFAULT_USER_IMAGE_URI_PATTERN = "/user/%s/profile-image";
|
||||
public static final String USER_IMAGE_FILENAME = "avatar.jpg";
|
||||
@ -17,9 +14,15 @@ public class FileConstant {
|
||||
public static final String NOT_AN_IMAGE_FILE = " is not an image file. Please upload an image file";
|
||||
public static final String TEMP_PROFILE_IMAGE_BASE_URL = "https://robohash.org/";
|
||||
|
||||
// ✅ CHANGED: Professor-specific constants
|
||||
// Professor-specific constants
|
||||
public static final String PROFESSOR_IMAGE_PATH = "/professor/image/";
|
||||
public static final String PROFESSOR_FOLDER = "/app/uploads/professor/";
|
||||
public static final String DEFAULT_PROFESSOR_IMAGE_URI_PATTERN = "/professor/%s/profile-image";
|
||||
public static final String PROFESSOR_IMAGE_FILENAME = "avatar.jpg";
|
||||
|
||||
// ✅ NEW: Hero Image constants
|
||||
public static final String HERO_IMAGE_PATH = "/hero/image/";
|
||||
public static final String HERO_FOLDER = "/app/uploads/hero/";
|
||||
public static final String DEFAULT_HERO_IMAGE_URI_PATTERN = "/hero/%s/image";
|
||||
public static final String HERO_IMAGE_PREFIX = "hero_";
|
||||
}
|
||||
@ -0,0 +1,110 @@
|
||||
package net.shyshkin.study.fullstack.supportportal.backend.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.shyshkin.study.fullstack.supportportal.backend.domain.HeroImage;
|
||||
import net.shyshkin.study.fullstack.supportportal.backend.domain.HttpResponse;
|
||||
import net.shyshkin.study.fullstack.supportportal.backend.service.HeroImageService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
|
||||
import static net.shyshkin.study.fullstack.supportportal.backend.constant.FileConstant.HERO_FOLDER;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/hero")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@CrossOrigin
|
||||
public class HeroImageResource {
|
||||
|
||||
private final HeroImageService heroImageService;
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAnyAuthority('user:create')")
|
||||
public ResponseEntity<HeroImage> addHeroImage(
|
||||
@RequestParam(required = false) String title,
|
||||
@RequestParam(required = false) String subtitle,
|
||||
@RequestParam(required = false) String description,
|
||||
@RequestParam(required = false) MultipartFile image) throws IOException {
|
||||
|
||||
log.info("Adding new hero image with title: {}", title);
|
||||
HeroImage heroImage = heroImageService.addHeroImage(title, subtitle, description, image);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(heroImage);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasAnyAuthority('user:update')")
|
||||
public ResponseEntity<HeroImage> updateHeroImage(
|
||||
@PathVariable Long id,
|
||||
@RequestParam(required = false) String title,
|
||||
@RequestParam(required = false) String subtitle,
|
||||
@RequestParam(required = false) String description,
|
||||
@RequestParam(required = false) MultipartFile image) throws IOException {
|
||||
|
||||
log.info("Updating hero image with id: {}", id);
|
||||
HeroImage heroImage = heroImageService.updateHeroImage(id, title, subtitle, description, image);
|
||||
return ResponseEntity.ok(heroImage);
|
||||
}
|
||||
|
||||
@GetMapping("/active")
|
||||
public ResponseEntity<HeroImage> getActiveHeroImage() {
|
||||
log.info("Getting active hero image");
|
||||
HeroImage heroImage = heroImageService.getActiveHeroImage();
|
||||
return ResponseEntity.ok(heroImage);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<HeroImage> getHeroImageById(@PathVariable Long id) {
|
||||
log.info("Getting hero image with id: {}", id);
|
||||
HeroImage heroImage = heroImageService.getHeroImageById(id);
|
||||
return ResponseEntity.ok(heroImage);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<HeroImage>> getAllHeroImages() {
|
||||
log.info("Getting all hero images");
|
||||
List<HeroImage> heroImages = heroImageService.getAllHeroImages();
|
||||
return ResponseEntity.ok(heroImages);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("hasAnyAuthority('user:delete')")
|
||||
public ResponseEntity<HttpResponse> deleteHeroImage(@PathVariable Long id) {
|
||||
log.info("Deleting hero image with id: {}", id);
|
||||
heroImageService.deleteHeroImage(id);
|
||||
return ResponseEntity.ok(
|
||||
HttpResponse.builder()
|
||||
.httpStatusCode(HttpStatus.OK.value())
|
||||
.httpStatus(HttpStatus.OK)
|
||||
.reason(HttpStatus.OK.getReasonPhrase())
|
||||
.message("Hero image deleted successfully")
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@PutMapping("/{id}/activate")
|
||||
@PreAuthorize("hasAnyAuthority('user:update')")
|
||||
public ResponseEntity<HeroImage> setActiveHeroImage(@PathVariable Long id) {
|
||||
log.info("Setting hero image as active with id: {}", id);
|
||||
HeroImage heroImage = heroImageService.setActiveHeroImage(id);
|
||||
return ResponseEntity.ok(heroImage);
|
||||
}
|
||||
|
||||
@GetMapping(path = "/image/{filename}", produces = {MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE, MediaType.IMAGE_GIF_VALUE})
|
||||
public byte[] getHeroImage(@PathVariable String filename) throws IOException {
|
||||
log.info("Getting hero image file: {}", filename);
|
||||
Path imagePath = Paths.get(HERO_FOLDER).resolve(filename);
|
||||
return Files.readAllBytes(imagePath);
|
||||
}
|
||||
}
|
||||
@ -7,12 +7,20 @@ import net.shyshkin.study.fullstack.supportportal.backend.repository.JobReposito
|
||||
import net.shyshkin.study.fullstack.supportportal.backend.repository.JobApplicationRepository;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.UrlResource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import com.amazonaws.services.secretsmanager.model.ResourceNotFoundException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@ -26,6 +34,9 @@ public class JobApplicationController {
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
|
||||
// Base path for resume storage - adjust this to your actual path
|
||||
private final String RESUME_UPLOAD_DIR = "uploads/resumes/";
|
||||
|
||||
// Get all applications (for admin)
|
||||
@GetMapping
|
||||
public List<JobApplication> getAllApplications() {
|
||||
@ -92,4 +103,68 @@ public class JobApplicationController {
|
||||
})
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
// Download/View Resume
|
||||
@GetMapping("/resume/{filename:.+}")
|
||||
public ResponseEntity<Resource> downloadResume(@PathVariable String filename) {
|
||||
try {
|
||||
// Construct the file path
|
||||
Path filePath = Paths.get(RESUME_UPLOAD_DIR).resolve(filename).normalize();
|
||||
Resource resource = new UrlResource(filePath.toUri());
|
||||
|
||||
if (resource.exists() && resource.isReadable()) {
|
||||
// Determine content type
|
||||
String contentType = "application/octet-stream";
|
||||
try {
|
||||
contentType = Files.probeContentType(filePath);
|
||||
if (contentType == null) {
|
||||
contentType = "application/pdf"; // Default to PDF
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
contentType = "application/pdf";
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(contentType))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"inline; filename=\"" + resource.getFilename() + "\"")
|
||||
.body(resource);
|
||||
} else {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
// Alternative endpoint for forced download (not inline view)
|
||||
@GetMapping("/resume/download/{filename:.+}")
|
||||
public ResponseEntity<Resource> forceDownloadResume(@PathVariable String filename) {
|
||||
try {
|
||||
Path filePath = Paths.get(RESUME_UPLOAD_DIR).resolve(filename).normalize();
|
||||
Resource resource = new UrlResource(filePath.toUri());
|
||||
|
||||
if (resource.exists() && resource.isReadable()) {
|
||||
String contentType = "application/octet-stream";
|
||||
try {
|
||||
contentType = Files.probeContentType(filePath);
|
||||
if (contentType == null) {
|
||||
contentType = "application/pdf";
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
contentType = "application/pdf";
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(contentType))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + resource.getFilename() + "\"")
|
||||
.body(resource);
|
||||
} else {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
package net.shyshkin.study.fullstack.supportportal.backend.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.shyshkin.study.fullstack.supportportal.backend.domain.ServiceTile;
|
||||
import net.shyshkin.study.fullstack.supportportal.backend.domain.HttpResponse;
|
||||
import net.shyshkin.study.fullstack.supportportal.backend.service.ServiceTileService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/service-tiles")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@CrossOrigin
|
||||
public class ServiceTileResource {
|
||||
|
||||
private final ServiceTileService serviceTileService;
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAnyAuthority('user:create')")
|
||||
public ResponseEntity<ServiceTile> addServiceTile(
|
||||
@RequestParam String title,
|
||||
@RequestParam(required = false) String description,
|
||||
@RequestParam(required = false) Integer displayOrder) {
|
||||
|
||||
log.info("Adding new service tile with title: {}", title);
|
||||
ServiceTile serviceTile = serviceTileService.addServiceTile(title, description, displayOrder);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(serviceTile);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasAnyAuthority('user:update')")
|
||||
public ResponseEntity<ServiceTile> updateServiceTile(
|
||||
@PathVariable Long id,
|
||||
@RequestParam String title,
|
||||
@RequestParam(required = false) String description,
|
||||
@RequestParam(required = false) Integer displayOrder) {
|
||||
|
||||
log.info("Updating service tile with id: {}", id);
|
||||
ServiceTile serviceTile = serviceTileService.updateServiceTile(id, title, description, displayOrder);
|
||||
return ResponseEntity.ok(serviceTile);
|
||||
}
|
||||
|
||||
@GetMapping("/active")
|
||||
public ResponseEntity<List<ServiceTile>> getActiveServiceTiles() {
|
||||
log.info("Getting active service tiles");
|
||||
List<ServiceTile> serviceTiles = serviceTileService.getActiveServiceTiles();
|
||||
return ResponseEntity.ok(serviceTiles);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ServiceTile> getServiceTileById(@PathVariable Long id) {
|
||||
log.info("Getting service tile with id: {}", id);
|
||||
ServiceTile serviceTile = serviceTileService.getServiceTileById(id);
|
||||
return ResponseEntity.ok(serviceTile);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAnyAuthority('user:read')")
|
||||
public ResponseEntity<List<ServiceTile>> getAllServiceTiles() {
|
||||
log.info("Getting all service tiles");
|
||||
List<ServiceTile> serviceTiles = serviceTileService.getAllServiceTiles();
|
||||
return ResponseEntity.ok(serviceTiles);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("hasAnyAuthority('user:delete')")
|
||||
public ResponseEntity<HttpResponse> deleteServiceTile(@PathVariable Long id) {
|
||||
log.info("Deleting service tile with id: {}", id);
|
||||
serviceTileService.deleteServiceTile(id);
|
||||
return ResponseEntity.ok(
|
||||
HttpResponse.builder()
|
||||
.httpStatusCode(HttpStatus.OK.value())
|
||||
.httpStatus(HttpStatus.OK)
|
||||
.reason(HttpStatus.OK.getReasonPhrase())
|
||||
.message("Service tile deleted successfully")
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@PutMapping("/{id}/toggle-active")
|
||||
@PreAuthorize("hasAnyAuthority('user:update')")
|
||||
public ResponseEntity<ServiceTile> toggleActiveStatus(@PathVariable Long id) {
|
||||
log.info("Toggling active status for service tile with id: {}", id);
|
||||
ServiceTile serviceTile = serviceTileService.toggleActiveStatus(id);
|
||||
return ResponseEntity.ok(serviceTile);
|
||||
}
|
||||
|
||||
@PutMapping("/reorder")
|
||||
@PreAuthorize("hasAnyAuthority('user:update')")
|
||||
public ResponseEntity<HttpResponse> reorderServiceTiles(@RequestBody List<Long> orderedIds) {
|
||||
log.info("Reordering service tiles");
|
||||
serviceTileService.reorderServiceTiles(orderedIds);
|
||||
return ResponseEntity.ok(
|
||||
HttpResponse.builder()
|
||||
.httpStatusCode(HttpStatus.OK.value())
|
||||
.httpStatus(HttpStatus.OK)
|
||||
.reason(HttpStatus.OK.getReasonPhrase())
|
||||
.message("Service tiles reordered successfully")
|
||||
.build()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -30,6 +30,12 @@ public class CourseApplication extends BaseEntity {
|
||||
@Column(nullable = false)
|
||||
private String phone;
|
||||
|
||||
@Column(name = "resume_url")
|
||||
private String resumeUrl;
|
||||
|
||||
@Column(name = "resume_path")
|
||||
private String resumePath;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String qualification;
|
||||
|
||||
@ -38,12 +44,13 @@ public class CourseApplication extends BaseEntity {
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String coverLetter;
|
||||
|
||||
private String resumeUrl;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
private ApplicationStatus status = ApplicationStatus.PENDING;
|
||||
|
||||
public enum ApplicationStatus {
|
||||
PENDING, REVIEWED, SHORTLISTED, ACCEPTED, REJECTED, ENROLLED
|
||||
}
|
||||
public String getResumePath() {
|
||||
return resumePath != null ? resumePath : resumeUrl;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package net.shyshkin.study.fullstack.supportportal.backend.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
@Entity
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table(name = "hero_images")
|
||||
public class HeroImage implements Serializable {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Long id;
|
||||
|
||||
private String title;
|
||||
private String subtitle;
|
||||
private String description;
|
||||
private String imageUrl;
|
||||
private String imageFilename;
|
||||
|
||||
@JsonProperty("isActive")
|
||||
@Column(name = "is_active")
|
||||
private boolean isActive;
|
||||
|
||||
private Date uploadDate;
|
||||
private Date lastModified;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
uploadDate = new Date();
|
||||
lastModified = new Date();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
lastModified = new Date();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
package net.shyshkin.study.fullstack.supportportal.backend.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
@Entity
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table(name = "service_tiles")
|
||||
public class ServiceTile implements Serializable {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String title;
|
||||
|
||||
@Column(length = 1000)
|
||||
private String description;
|
||||
|
||||
@JsonProperty("isActive")
|
||||
@Column(name = "is_active")
|
||||
private boolean isActive;
|
||||
|
||||
@Column(name = "display_order")
|
||||
private Integer displayOrder;
|
||||
|
||||
private Date createdDate;
|
||||
private Date lastModified;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdDate = new Date();
|
||||
lastModified = new Date();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
lastModified = new Date();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package net.shyshkin.study.fullstack.supportportal.backend.exception.domain;
|
||||
|
||||
public class HeroImageNotFoundException extends RuntimeException {
|
||||
|
||||
public HeroImageNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package net.shyshkin.study.fullstack.supportportal.backend.exception.domain;
|
||||
|
||||
public class ServiceTileNotFoundException extends RuntimeException {
|
||||
|
||||
public ServiceTileNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
package net.shyshkin.study.fullstack.supportportal.backend.repository;
|
||||
|
||||
import net.shyshkin.study.fullstack.supportportal.backend.domain.HeroImage;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface HeroImageRepository extends JpaRepository<HeroImage, Long> {
|
||||
|
||||
@Query("SELECT h FROM HeroImage h WHERE h.isActive = true")
|
||||
Optional<HeroImage> findByIsActiveTrue();
|
||||
|
||||
Optional<HeroImage> findByImageFilename(String imageFilename);
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package net.shyshkin.study.fullstack.supportportal.backend.repository;
|
||||
|
||||
import net.shyshkin.study.fullstack.supportportal.backend.domain.ServiceTile;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface ServiceTileRepository extends JpaRepository<ServiceTile, Long> {
|
||||
|
||||
@Query("SELECT s FROM ServiceTile s WHERE s.isActive = true ORDER BY s.displayOrder ASC, s.id ASC")
|
||||
List<ServiceTile> findByIsActiveTrueOrderByDisplayOrder();
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package net.shyshkin.study.fullstack.supportportal.backend.service;
|
||||
|
||||
import net.shyshkin.study.fullstack.supportportal.backend.domain.HeroImage;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
public interface HeroImageService {
|
||||
|
||||
HeroImage addHeroImage(String title, String subtitle, String description, MultipartFile image) throws IOException;
|
||||
|
||||
HeroImage updateHeroImage(Long id, String title, String subtitle, String description, MultipartFile image) throws IOException;
|
||||
|
||||
HeroImage getActiveHeroImage();
|
||||
|
||||
HeroImage getHeroImageById(Long id);
|
||||
|
||||
List<HeroImage> getAllHeroImages();
|
||||
|
||||
void deleteHeroImage(Long id);
|
||||
|
||||
HeroImage setActiveHeroImage(Long id);
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package net.shyshkin.study.fullstack.supportportal.backend.service;
|
||||
|
||||
import net.shyshkin.study.fullstack.supportportal.backend.domain.ServiceTile;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ServiceTileService {
|
||||
|
||||
ServiceTile addServiceTile(String title, String description, Integer displayOrder);
|
||||
|
||||
ServiceTile updateServiceTile(Long id, String title, String description, Integer displayOrder);
|
||||
|
||||
List<ServiceTile> getActiveServiceTiles();
|
||||
|
||||
ServiceTile getServiceTileById(Long id);
|
||||
|
||||
List<ServiceTile> getAllServiceTiles();
|
||||
|
||||
void deleteServiceTile(Long id);
|
||||
|
||||
ServiceTile toggleActiveStatus(Long id);
|
||||
|
||||
void reorderServiceTiles(List<Long> orderedIds);
|
||||
}
|
||||
@ -0,0 +1,179 @@
|
||||
package net.shyshkin.study.fullstack.supportportal.backend.service.impl;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.shyshkin.study.fullstack.supportportal.backend.domain.HeroImage;
|
||||
import net.shyshkin.study.fullstack.supportportal.backend.exception.domain.HeroImageNotFoundException;
|
||||
import net.shyshkin.study.fullstack.supportportal.backend.repository.HeroImageRepository;
|
||||
import net.shyshkin.study.fullstack.supportportal.backend.service.HeroImageService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
|
||||
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
|
||||
import static net.shyshkin.study.fullstack.supportportal.backend.constant.FileConstant.*;
|
||||
import static org.springframework.http.MediaType.*;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@Transactional
|
||||
public class HeroImageServiceImpl implements HeroImageService {
|
||||
|
||||
private final HeroImageRepository heroImageRepository;
|
||||
|
||||
@Override
|
||||
public HeroImage addHeroImage(String title, String subtitle, String description, MultipartFile image) throws IOException {
|
||||
log.info("Adding new hero image with title: {}", title);
|
||||
|
||||
HeroImage heroImage = new HeroImage();
|
||||
heroImage.setTitle(title);
|
||||
heroImage.setSubtitle(subtitle);
|
||||
heroImage.setDescription(description);
|
||||
heroImage.setActive(false); // New images are inactive by default
|
||||
|
||||
if (image != null && !image.isEmpty()) {
|
||||
String filename = saveHeroImage(image);
|
||||
heroImage.setImageFilename(filename);
|
||||
heroImage.setImageUrl(getHeroImageUrl(filename));
|
||||
}
|
||||
|
||||
return heroImageRepository.save(heroImage);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HeroImage updateHeroImage(Long id, String title, String subtitle, String description, MultipartFile image) throws IOException {
|
||||
log.info("Updating hero image with id: {}", id);
|
||||
|
||||
HeroImage heroImage = getHeroImageById(id);
|
||||
heroImage.setTitle(title);
|
||||
heroImage.setSubtitle(subtitle);
|
||||
heroImage.setDescription(description);
|
||||
|
||||
if (image != null && !image.isEmpty()) {
|
||||
// Delete old image if exists
|
||||
if (heroImage.getImageFilename() != null) {
|
||||
deleteHeroImageFile(heroImage.getImageFilename());
|
||||
}
|
||||
|
||||
String filename = saveHeroImage(image);
|
||||
heroImage.setImageFilename(filename);
|
||||
heroImage.setImageUrl(getHeroImageUrl(filename));
|
||||
}
|
||||
|
||||
return heroImageRepository.save(heroImage);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public HeroImage getActiveHeroImage() {
|
||||
return heroImageRepository.findByIsActiveTrue()
|
||||
.orElseThrow(() -> new HeroImageNotFoundException("No active hero image found"));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public HeroImage getHeroImageById(Long id) {
|
||||
return heroImageRepository.findById(id)
|
||||
.orElseThrow(() -> new HeroImageNotFoundException("Hero image not found with id: " + id));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<HeroImage> getAllHeroImages() {
|
||||
return heroImageRepository.findAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteHeroImage(Long id) {
|
||||
log.info("Deleting hero image with id: {}", id);
|
||||
|
||||
HeroImage heroImage = getHeroImageById(id);
|
||||
|
||||
if (heroImage.isActive()) {
|
||||
throw new IllegalStateException("Cannot delete active hero image. Please set another image as active first.");
|
||||
}
|
||||
|
||||
if (heroImage.getImageFilename() != null) {
|
||||
deleteHeroImageFile(heroImage.getImageFilename());
|
||||
}
|
||||
|
||||
heroImageRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HeroImage setActiveHeroImage(Long id) {
|
||||
log.info("Setting hero image as active with id: {}", id);
|
||||
|
||||
// Deactivate current active image
|
||||
heroImageRepository.findByIsActiveTrue().ifPresent(currentActive -> {
|
||||
currentActive.setActive(false);
|
||||
heroImageRepository.save(currentActive);
|
||||
});
|
||||
|
||||
// Activate new image
|
||||
HeroImage heroImage = getHeroImageById(id);
|
||||
heroImage.setActive(true);
|
||||
return heroImageRepository.save(heroImage);
|
||||
}
|
||||
|
||||
private String saveHeroImage(MultipartFile image) throws IOException {
|
||||
if (!isImageFile(image)) {
|
||||
throw new IOException(image.getOriginalFilename() + NOT_AN_IMAGE_FILE);
|
||||
}
|
||||
|
||||
Path heroFolder = Paths.get(HERO_FOLDER).toAbsolutePath().normalize();
|
||||
if (!Files.exists(heroFolder)) {
|
||||
Files.createDirectories(heroFolder);
|
||||
log.info(DIRECTORY_CREATED + heroFolder);
|
||||
}
|
||||
|
||||
String filename = HERO_IMAGE_PREFIX + System.currentTimeMillis() + DOT + getFileExtension(image);
|
||||
Path targetLocation = heroFolder.resolve(filename);
|
||||
|
||||
Files.copy(image.getInputStream(), targetLocation, REPLACE_EXISTING);
|
||||
log.info(FILE_SAVED_IN_FILE_SYSTEM + filename);
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
||||
private void deleteHeroImageFile(String filename) {
|
||||
try {
|
||||
Path filePath = Paths.get(HERO_FOLDER).resolve(filename);
|
||||
Files.deleteIfExists(filePath);
|
||||
log.info("Deleted hero image file: {}", filename);
|
||||
} catch (IOException e) {
|
||||
log.error("Error deleting hero image file: {}", filename, e);
|
||||
}
|
||||
}
|
||||
|
||||
private String getHeroImageUrl(String filename) {
|
||||
return ServletUriComponentsBuilder.fromCurrentContextPath()
|
||||
.path(HERO_IMAGE_PATH + filename)
|
||||
.toUriString();
|
||||
}
|
||||
|
||||
private boolean isImageFile(MultipartFile file) {
|
||||
String contentType = file.getContentType();
|
||||
return contentType != null && (
|
||||
contentType.equals(IMAGE_JPEG_VALUE) ||
|
||||
contentType.equals(IMAGE_PNG_VALUE) ||
|
||||
contentType.equals(IMAGE_GIF_VALUE)
|
||||
);
|
||||
}
|
||||
|
||||
private String getFileExtension(MultipartFile file) {
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
if (originalFilename != null && originalFilename.contains(DOT)) {
|
||||
return originalFilename.substring(originalFilename.lastIndexOf(DOT) + 1);
|
||||
}
|
||||
return JPG_EXTENSION;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
package net.shyshkin.study.fullstack.supportportal.backend.service.impl;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.shyshkin.study.fullstack.supportportal.backend.domain.ServiceTile;
|
||||
import net.shyshkin.study.fullstack.supportportal.backend.exception.domain.ServiceTileNotFoundException;
|
||||
import net.shyshkin.study.fullstack.supportportal.backend.repository.ServiceTileRepository;
|
||||
import net.shyshkin.study.fullstack.supportportal.backend.service.ServiceTileService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@Transactional
|
||||
public class ServiceTileServiceImpl implements ServiceTileService {
|
||||
|
||||
private final ServiceTileRepository serviceTileRepository;
|
||||
|
||||
@Override
|
||||
public ServiceTile addServiceTile(String title, String description, Integer displayOrder) {
|
||||
log.info("Adding new service tile with title: {}", title);
|
||||
|
||||
ServiceTile serviceTile = new ServiceTile();
|
||||
serviceTile.setTitle(title);
|
||||
serviceTile.setDescription(description);
|
||||
serviceTile.setDisplayOrder(displayOrder != null ? displayOrder : 0);
|
||||
serviceTile.setActive(true); // New tiles are active by default
|
||||
|
||||
return serviceTileRepository.save(serviceTile);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceTile updateServiceTile(Long id, String title, String description, Integer displayOrder) {
|
||||
log.info("Updating service tile with id: {}", id);
|
||||
|
||||
ServiceTile serviceTile = getServiceTileById(id);
|
||||
serviceTile.setTitle(title);
|
||||
serviceTile.setDescription(description);
|
||||
|
||||
if (displayOrder != null) {
|
||||
serviceTile.setDisplayOrder(displayOrder);
|
||||
}
|
||||
|
||||
return serviceTileRepository.save(serviceTile);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<ServiceTile> getActiveServiceTiles() {
|
||||
return serviceTileRepository.findByIsActiveTrueOrderByDisplayOrder();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public ServiceTile getServiceTileById(Long id) {
|
||||
return serviceTileRepository.findById(id)
|
||||
.orElseThrow(() -> new ServiceTileNotFoundException("Service tile not found with id: " + id));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<ServiceTile> getAllServiceTiles() {
|
||||
return serviceTileRepository.findAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteServiceTile(Long id) {
|
||||
log.info("Deleting service tile with id: {}", id);
|
||||
|
||||
ServiceTile serviceTile = getServiceTileById(id);
|
||||
serviceTileRepository.delete(serviceTile);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceTile toggleActiveStatus(Long id) {
|
||||
log.info("Toggling active status for service tile with id: {}", id);
|
||||
|
||||
ServiceTile serviceTile = getServiceTileById(id);
|
||||
serviceTile.setActive(!serviceTile.isActive());
|
||||
|
||||
return serviceTileRepository.save(serviceTile);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reorderServiceTiles(List<Long> orderedIds) {
|
||||
log.info("Reordering {} service tiles", orderedIds.size());
|
||||
|
||||
for (int i = 0; i < orderedIds.size(); i++) {
|
||||
Long id = orderedIds.get(i);
|
||||
ServiceTile serviceTile = getServiceTileById(id);
|
||||
serviceTile.setDisplayOrder(i);
|
||||
serviceTileRepository.save(serviceTile);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -51,9 +51,9 @@ file:
|
||||
app:
|
||||
base-url: ${APP_BASE_URL:http://localhost:8080}
|
||||
# Fixed public URLs with correct wildcard patterns
|
||||
public-urls: /user/login,/user/register,/user/*/profile-image,/user/*/profile-image/**,/professors,/professors/**,/api/posts,/api/posts/*,/api/posts/posted,/api/posts/tag/*,/api/posts/tags/count,/api/files/**,/uploads/**,/professor/**,/api/events,/api/events/*,/api/public/**,/api/jobs/active,/api/job-applications,/api/courses/active,/api/courses/*,/api/course-applications,/api/upcoming-events/active,/api/milestones,/api/milestones/**,/api/testimonials,/api/testimonials/**
|
||||
public-urls: /user/login,/user/register,/user/*/profile-image,/user/*/profile-image/**,/professors,/professors/**,/api/posts,/api/posts/*,/api/posts/posted,/api/posts/tag/*,/api/posts/tags/count,/api/files/**,/uploads/**,/professor/**,/api/events,/api/events/*,/api/public/**,/api/jobs/active,/api/job-applications/**,/api/job-applications/resume/**,/api/courses/active,/api/courses/*,/api/course-applications,/api/upcoming-events/active,/api/milestones,/api/milestones/**,/api/testimonials,/api/testimonials/**,/hero/image/**,/hero/active/**,/hero/**,/service-tiles/active,/service-tiles/active/**
|
||||
cors:
|
||||
allowed-origins: http://localhost:4200,http://localhost:3000,https://maincmc.rootxwire.com,https://dashboad.cmctrauma.com,https://www.dashboad.cmctrauma.com,https://cmctrauma.com,https://www.cmctrauma.com,https://cmcbackend.rootxwire.com,https://cmcadminfrontend.rootxwire.com
|
||||
allowed-origins: http://localhost:4200,http://localhost:3000,https://maincmc.rootxwire.com,https://dashboard.cmctrauma.com,https://www.dashboard.cmctrauma.com,https://cmctrauma.com,https://www.cmctrauma.com,https://cmcbackend.rootxwire.com,https://cmcadminfrontend.rootxwire.com
|
||||
jwt:
|
||||
secret: custom_text
|
||||
|
||||
@ -69,7 +69,7 @@ file:
|
||||
app:
|
||||
base-url: https://cmcbackend.rootxwire.com
|
||||
cors:
|
||||
allowed-origins: https://maincmc.rootxwire.com,https://dashboad.cmctrauma.com,https://www.dashboad.cmctrauma.com,https://cmctrauma.com,https://www.cmctrauma.com,https://cmcbackend.rootxwire.com,https://cmcadminfrontend.rootxwire.com
|
||||
allowed-origins: https://maincmc.rootxwire.com,https://dashboard.cmctrauma.com,https://www.dashboard.cmctrauma.com,https://cmctrauma.com,https://www.cmctrauma.com,https://cmcbackend.rootxwire.com,https://cmcadminfrontend.rootxwire.com
|
||||
|
||||
---
|
||||
# Development file upload configuration with custom directory
|
||||
|
||||
5824
support-portal-frontend/package-lock.json
generated
5824
support-portal-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -11,6 +11,7 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "~12.2.0",
|
||||
"@angular/cdk": "^12.2.13",
|
||||
"@angular/common": "~12.2.0",
|
||||
"@angular/compiler": "~12.2.0",
|
||||
"@angular/core": "~12.2.0",
|
||||
@ -20,6 +21,7 @@
|
||||
"@angular/router": "~12.2.0",
|
||||
"@auth0/angular-jwt": "^3.0.1",
|
||||
"@josipv/angular-editor-k2": "^2.20.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"angular-notifier": "^10.0.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"ng-particles": "^2.1.11",
|
||||
@ -37,7 +39,7 @@
|
||||
"@angular/compiler-cli": "~12.2.0",
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/jasmine": "~3.8.0",
|
||||
"@types/node": "^12.11.1",
|
||||
"@types/node": "^12.20.55",
|
||||
"jasmine-core": "~3.8.0",
|
||||
"karma": "~6.3.0",
|
||||
"karma-chrome-launcher": "~3.1.0",
|
||||
|
||||
@ -23,6 +23,8 @@ import { MilestoneListComponent } from '../component/milestone/milestone-list/mi
|
||||
import { MilestoneFormComponent } from '../component/milestone/milestone-form/milestone-form.component';
|
||||
import { TestimonialFormComponent } from '../component/testimonial/testimonial-form/testimonial-form.component';
|
||||
import { TestimonialListComponent } from '../component/testimonial/testimonial-list/testimonial-list.component';
|
||||
import { HeroImageComponent } from '../component/hero-image/hero-image.component';
|
||||
import { ServiceTileComponent } from '../component/service-tile/service-tile.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: '', component: HomeComponent },
|
||||
@ -40,7 +42,8 @@ const routes: Routes = [
|
||||
{ path: 'education', component: EducationComponent, canActivate: [AuthenticationGuard] },
|
||||
{ path: 'userManagement', component: UserComponent, canActivate: [AuthenticationGuard] },
|
||||
{ path: 'professorManagement', component: ProfessorComponent, canActivate: [AuthenticationGuard] },
|
||||
|
||||
{ path: 'heroImage', component: HeroImageComponent, canActivate: [AuthenticationGuard] },
|
||||
{ path: 'serviceTiles', component: ServiceTileComponent, canActivate: [AuthenticationGuard] },
|
||||
{ path: 'milestone/list', component: MilestoneListComponent, canActivate: [AuthenticationGuard] },
|
||||
{ path: 'milestone/create', component: MilestoneFormComponent, canActivate: [AuthenticationGuard] },
|
||||
{ path: 'milestone/edit/:id', component: MilestoneFormComponent, canActivate: [AuthenticationGuard] },
|
||||
|
||||
@ -38,6 +38,8 @@ import { MilestoneFormComponent } from '../component/milestone/milestone-form/mi
|
||||
import { MilestoneListComponent } from '../component/milestone/milestone-list/milestone-list.component';
|
||||
import { TestimonialFormComponent } from '../component/testimonial/testimonial-form/testimonial-form.component';
|
||||
import { TestimonialListComponent } from '../component/testimonial/testimonial-list/testimonial-list.component';
|
||||
import { HeroImageComponent } from '../component/hero-image/hero-image.component';
|
||||
import { ServiceTileComponent } from '../component/service-tile/service-tile.component';
|
||||
// import { PagesModule } from '../pages/pages.module';
|
||||
|
||||
|
||||
@ -68,7 +70,9 @@ import { TestimonialListComponent } from '../component/testimonial/testimonial-l
|
||||
MilestoneFormComponent,
|
||||
MilestoneListComponent,
|
||||
TestimonialFormComponent,
|
||||
TestimonialListComponent
|
||||
TestimonialListComponent,
|
||||
HeroImageComponent,
|
||||
ServiceTileComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
|
||||
@ -36,6 +36,8 @@ import { EducationComponent } from './component/education/education.component';
|
||||
import { MilestoneFormComponent } from './component/milestone/milestone-form/milestone-form.component';
|
||||
import { TestimonialFormComponent } from './component/testimonial/testimonial-form/testimonial-form.component';
|
||||
import { TestimonialListComponent } from './component/testimonial/testimonial-list/testimonial-list.component';
|
||||
import { HeroImageComponent } from './component/hero-image/hero-image.component';
|
||||
import { ServiceTileComponent } from './component/service-tile/service-tile.component';
|
||||
// import { PagesModule } from './pages/pages.module';
|
||||
|
||||
|
||||
@ -45,6 +47,8 @@ import { TestimonialListComponent } from './component/testimonial/testimonial-li
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
//ServiceTileComponent,
|
||||
//HeroImageComponent,
|
||||
//TestimonialFormComponent,
|
||||
//TestimonialListComponent,
|
||||
//MilestoneListComponent,
|
||||
|
||||
@ -430,6 +430,7 @@
|
||||
/* Tables */
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
@ -476,6 +477,8 @@
|
||||
|
||||
.jobs-table tbody td,
|
||||
.applications-table tbody td {
|
||||
position: relative; /* Ensure positioning context */
|
||||
overflow: visible;
|
||||
padding: 16px 20px;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
@ -710,27 +713,94 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Resume Actions */
|
||||
.resume-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-resume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.btn-view {
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-view:hover {
|
||||
background: #eff6ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-download {
|
||||
border-color: #10b981;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.btn-download:hover {
|
||||
background: #d1fae5;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-resume i {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.no-resume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.no-resume i {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Update table to accommodate new column */
|
||||
.applications-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
min-width: 1000px; /* Increased from 800px */
|
||||
}
|
||||
|
||||
/* Dropdown */
|
||||
.dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
position: fixed; /* Changed from absolute to fixed */
|
||||
margin-top: 4px;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
min-width: 180px;
|
||||
z-index: 10;
|
||||
z-index: 9999; /* Very high z-index */
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown:hover .dropdown-menu {
|
||||
.dropdown.active .dropdown-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@ -767,6 +837,19 @@
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Make sure table allows overflow */
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
overflow-y: visible; /* Changed from hidden */
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.applications-table tbody td {
|
||||
position: relative;
|
||||
overflow: visible; /* Allow dropdown to overflow */
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
background: white;
|
||||
|
||||
@ -252,118 +252,136 @@
|
||||
</div>
|
||||
|
||||
<!-- Applications View -->
|
||||
<div *ngIf="showApplications && selectedJobForApplications" class="applications-container">
|
||||
<div class="applications-card">
|
||||
<div class="applications-header">
|
||||
<div class="header-info">
|
||||
<h3>Applications</h3>
|
||||
<p>{{ selectedJobForApplications.title }}</p>
|
||||
</div>
|
||||
<span class="application-count">{{ applications.length }} {{ applications.length === 1 ? 'Application' : 'Applications' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="applications-body">
|
||||
<div *ngIf="applications.length > 0" class="table-wrapper">
|
||||
<table class="applications-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Applicant</th>
|
||||
<th>Contact</th>
|
||||
<th>Experience</th>
|
||||
<th>Status</th>
|
||||
<th>Applied Date</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let application of applications">
|
||||
<td>
|
||||
<div class="applicant-info">
|
||||
<div class="applicant-avatar">
|
||||
<i class="fa fa-user"></i>
|
||||
</div>
|
||||
<span class="applicant-name">{{ application.fullName }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="contact-info">
|
||||
<div class="contact-item">
|
||||
<i class="fa fa-envelope"></i>
|
||||
<span>{{ application.email }}</span>
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<i class="fa fa-phone"></i>
|
||||
<span>{{ application.phone }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="experience-badge">{{ application.experience }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge" [ngClass]="getStatusBadgeClass(application.status || 'pending')">
|
||||
{{ application.status || 'PENDING' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="date-text">{{ application.createdDate | date:'MMM d, y' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<div class="dropdown">
|
||||
<button class="btn-action btn-status">
|
||||
<i class="fa fa-edit"></i>
|
||||
<span>Status</span>
|
||||
<i class="fa fa-chevron-down"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'PENDING')">
|
||||
<i class="fa fa-clock"></i>
|
||||
Pending
|
||||
</button>
|
||||
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'REVIEWED')">
|
||||
<i class="fa fa-eye"></i>
|
||||
Reviewed
|
||||
</button>
|
||||
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'SHORTLISTED')">
|
||||
<i class="fa fa-star"></i>
|
||||
Shortlisted
|
||||
</button>
|
||||
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'INTERVIEWED')">
|
||||
<i class="fa fa-comments"></i>
|
||||
Interviewed
|
||||
</button>
|
||||
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'HIRED')">
|
||||
<i class="fa fa-check-circle"></i>
|
||||
Hired
|
||||
</button>
|
||||
<button class="dropdown-item item-danger" (click)="updateApplicationStatus(application, 'REJECTED')">
|
||||
<i class="fa fa-times-circle"></i>
|
||||
Rejected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-action btn-delete" (click)="deleteApplication(application)">
|
||||
<i class="fa fa-trash"></i>
|
||||
<!-- Applications View -->
|
||||
<div *ngIf="showApplications && selectedJobForApplications" class="applications-container">
|
||||
<div class="applications-card">
|
||||
<div class="applications-header">
|
||||
<div class="header-info">
|
||||
<h3>Applications</h3>
|
||||
<p>{{ selectedJobForApplications.title }}</p>
|
||||
</div>
|
||||
<span class="application-count">{{ applications.length }} {{ applications.length === 1 ? 'Application' : 'Applications' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="applications-body">
|
||||
<div *ngIf="applications.length > 0" class="table-wrapper">
|
||||
<table class="applications-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Applicant</th>
|
||||
<th>Contact</th>
|
||||
<th>Experience</th>
|
||||
<th>Resume</th>
|
||||
<th>Status</th>
|
||||
<th>Applied Date</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let application of applications; let i = index">
|
||||
<td>
|
||||
<div class="applicant-info">
|
||||
<div class="applicant-avatar">
|
||||
<i class="fa fa-user"></i>
|
||||
</div>
|
||||
<span class="applicant-name">{{ application.fullName }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="contact-info">
|
||||
<div class="contact-item">
|
||||
<i class="fa fa-envelope"></i>
|
||||
<span>{{ application.email }}</span>
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<i class="fa fa-phone"></i>
|
||||
<span>{{ application.phone }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="experience-badge">{{ application.experience }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="resume-actions" *ngIf="hasResume(application)">
|
||||
<button class="btn-resume btn-view" (click)="viewResume(application)" title="View Resume">
|
||||
<i class="fa fa-eye"></i>
|
||||
<span>View</span>
|
||||
</button>
|
||||
<button class="btn-resume btn-download" (click)="downloadResume(application)" title="Download Resume">
|
||||
<i class="fa fa-download"></i>
|
||||
<span>Download</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="no-resume" *ngIf="!hasResume(application)">
|
||||
<i class="fa fa-file-excel"></i>
|
||||
<span>No Resume</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge" [ngClass]="getStatusBadgeClass(application.status || 'pending')">
|
||||
{{ application.status || 'PENDING' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="date-text">{{ application.createdDate | date:'MMM d, y' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<div class="dropdown" [class.active]="activeDropdownIndex === i">
|
||||
<button class="btn-action btn-status" (click)="toggleDropdown(i, $event)">
|
||||
<i class="fa fa-edit"></i>
|
||||
<span>Status</span>
|
||||
<i class="fa fa-chevron-down"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'PENDING')">
|
||||
<i class="fa fa-clock"></i>
|
||||
Pending
|
||||
</button>
|
||||
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'REVIEWED')">
|
||||
<i class="fa fa-eye"></i>
|
||||
Reviewed
|
||||
</button>
|
||||
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'SHORTLISTED')">
|
||||
<i class="fa fa-star"></i>
|
||||
Shortlisted
|
||||
</button>
|
||||
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'INTERVIEWED')">
|
||||
<i class="fa fa-comments"></i>
|
||||
Interviewed
|
||||
</button>
|
||||
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'HIRED')">
|
||||
<i class="fa fa-check-circle"></i>
|
||||
Hired
|
||||
</button>
|
||||
<button class="dropdown-item item-danger" (click)="updateApplicationStatus(application, 'REJECTED')">
|
||||
<i class="fa fa-times-circle"></i>
|
||||
Rejected
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-action btn-delete" (click)="deleteApplication(application)">
|
||||
<i class="fa fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Empty State for Applications -->
|
||||
<div *ngIf="applications.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="fa fa-inbox"></i>
|
||||
</div>
|
||||
<h3>No applications yet</h3>
|
||||
<p>This job hasn't received any applications</p>
|
||||
</div>
|
||||
<!-- Empty State for Applications -->
|
||||
<div *ngIf="applications.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="fa fa-inbox"></i>
|
||||
</div>
|
||||
<h3>No applications yet</h3>
|
||||
<p>This job hasn't received any applications</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Jobs List -->
|
||||
<div *ngIf="!showJobForm && !showApplications" class="jobs-list-container">
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// career.component.ts
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Component, OnInit, HostListener } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { CareerService, Job, JobApplication } from 'src/app/service/career.service';
|
||||
|
||||
@ -20,6 +20,9 @@ export class CareerComponent implements OnInit {
|
||||
// Filter for applications
|
||||
selectedJobForApplications: Job | null = null;
|
||||
|
||||
// Dropdown management
|
||||
activeDropdownIndex: number | null = null;
|
||||
|
||||
constructor(
|
||||
private careerService: CareerService,
|
||||
private fb: FormBuilder
|
||||
@ -34,7 +37,7 @@ export class CareerComponent implements OnInit {
|
||||
description: ['', Validators.required],
|
||||
requirements: [''],
|
||||
responsibilities: [''],
|
||||
isActive: [true] // Default to true
|
||||
isActive: [true]
|
||||
});
|
||||
}
|
||||
|
||||
@ -59,7 +62,6 @@ export class CareerComponent implements OnInit {
|
||||
this.showJobForm = true;
|
||||
this.resetJobForm();
|
||||
|
||||
// Ensure isActive is true for new jobs
|
||||
setTimeout(() => {
|
||||
this.jobForm.patchValue({
|
||||
isActive: true
|
||||
@ -84,7 +86,7 @@ export class CareerComponent implements OnInit {
|
||||
description: job.description,
|
||||
requirements: job.requirements ? job.requirements.join(', ') : '',
|
||||
responsibilities: job.responsibilities ? job.responsibilities.join(', ') : '',
|
||||
isActive: job.isActive // This will use the actual job's active status
|
||||
isActive: job.isActive
|
||||
});
|
||||
this.editing = true;
|
||||
this.showJobForm = true;
|
||||
@ -94,18 +96,8 @@ export class CareerComponent implements OnInit {
|
||||
if (this.jobForm.valid) {
|
||||
const jobData = this.jobForm.value;
|
||||
|
||||
console.log('=== ANGULAR DEBUG ===');
|
||||
console.log('Form value:', this.jobForm.value);
|
||||
console.log('isActive form control value:', this.jobForm.get('isActive')?.value);
|
||||
console.log('isActive in jobData:', jobData.isActive);
|
||||
console.log('Type of isActive:', typeof jobData.isActive);
|
||||
|
||||
// Ensure isActive is properly set as boolean
|
||||
jobData.isActive = this.jobForm.get('isActive')?.value === true;
|
||||
|
||||
console.log('isActive after boolean conversion:', jobData.isActive);
|
||||
|
||||
// Convert comma-separated strings to arrays
|
||||
jobData.requirements = jobData.requirements
|
||||
? jobData.requirements.split(',').map((req: string) => req.trim()).filter((req: string) => req.length > 0)
|
||||
: [];
|
||||
@ -113,8 +105,6 @@ export class CareerComponent implements OnInit {
|
||||
? jobData.responsibilities.split(',').map((resp: string) => resp.trim()).filter((resp: string) => resp.length > 0)
|
||||
: [];
|
||||
|
||||
console.log('Final jobData being sent:', jobData);
|
||||
|
||||
if (this.editing && this.selectedJob) {
|
||||
this.careerService.updateJob(this.selectedJob.id!, jobData).subscribe(() => {
|
||||
this.loadJobs();
|
||||
@ -142,13 +132,11 @@ export class CareerComponent implements OnInit {
|
||||
this.selectedJob = null;
|
||||
this.editing = false;
|
||||
|
||||
// Explicitly set default values after reset
|
||||
this.jobForm.patchValue({
|
||||
isActive: true
|
||||
});
|
||||
}
|
||||
|
||||
// Application management
|
||||
viewApplications(job: Job) {
|
||||
this.selectedJobForApplications = job;
|
||||
this.showApplications = true;
|
||||
@ -160,12 +148,39 @@ export class CareerComponent implements OnInit {
|
||||
hideApplications() {
|
||||
this.showApplications = false;
|
||||
this.selectedJobForApplications = null;
|
||||
this.loadApplications(); // Load all applications
|
||||
this.loadApplications();
|
||||
}
|
||||
|
||||
toggleDropdown(index: number, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (this.activeDropdownIndex === index) {
|
||||
this.activeDropdownIndex = null;
|
||||
} else {
|
||||
this.activeDropdownIndex = index;
|
||||
|
||||
setTimeout(() => {
|
||||
const button = event.currentTarget as HTMLElement;
|
||||
const dropdown = button.nextElementSibling as HTMLElement;
|
||||
|
||||
if (dropdown) {
|
||||
const rect = button.getBoundingClientRect();
|
||||
dropdown.style.top = `${rect.bottom + 4}px`;
|
||||
dropdown.style.left = `${rect.left}px`;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('document:click')
|
||||
closeDropdown() {
|
||||
this.activeDropdownIndex = null;
|
||||
}
|
||||
|
||||
updateApplicationStatus(application: JobApplication, status: string) {
|
||||
this.careerService.updateApplicationStatus(application.id!, status).subscribe(() => {
|
||||
// Reload applications for the selected job
|
||||
this.activeDropdownIndex = null;
|
||||
|
||||
if (this.selectedJobForApplications) {
|
||||
this.viewApplications(this.selectedJobForApplications);
|
||||
} else {
|
||||
@ -177,6 +192,8 @@ export class CareerComponent implements OnInit {
|
||||
deleteApplication(application: JobApplication) {
|
||||
if (confirm('Are you sure you want to delete this application?')) {
|
||||
this.careerService.deleteApplication(application.id!).subscribe(() => {
|
||||
this.activeDropdownIndex = null;
|
||||
|
||||
if (this.selectedJobForApplications) {
|
||||
this.viewApplications(this.selectedJobForApplications);
|
||||
} else {
|
||||
@ -202,4 +219,62 @@ export class CareerComponent implements OnInit {
|
||||
if (!jobId) return 0;
|
||||
return this.applications.filter(app => app.job?.id === jobId).length;
|
||||
}
|
||||
|
||||
// Helper method to extract filename from URL
|
||||
extractFilename(url: string): string {
|
||||
if (!url) return '';
|
||||
|
||||
// If it's a full URL, extract the filename
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
const parts = url.split('/');
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
// If it's already just a filename, return as is
|
||||
return url;
|
||||
}
|
||||
|
||||
downloadResume(application: JobApplication) {
|
||||
const resumePath = application.resumePath || application.resumeUrl;
|
||||
if (resumePath) {
|
||||
const filename = this.extractFilename(resumePath);
|
||||
|
||||
this.careerService.downloadResume(filename).subscribe(
|
||||
(blob) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${application.fullName}_Resume.pdf`;
|
||||
link.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
(error) => {
|
||||
console.error('Error downloading resume:', error);
|
||||
alert('Failed to download resume');
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
viewResume(application: JobApplication) {
|
||||
const resumePath = application.resumePath || application.resumeUrl;
|
||||
if (resumePath) {
|
||||
const filename = this.extractFilename(resumePath);
|
||||
|
||||
this.careerService.downloadResume(filename).subscribe(
|
||||
(blob) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
(error) => {
|
||||
console.error('Error viewing resume:', error);
|
||||
alert('Failed to view resume');
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
hasResume(application: JobApplication): boolean {
|
||||
return !!(application.resumePath || application.resumeUrl);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,638 @@
|
||||
/* Hero Image Layout */
|
||||
.hero-image-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
width: 260px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.hero-image-content {
|
||||
margin-left: 260px;
|
||||
flex: 1;
|
||||
padding: 32px;
|
||||
width: calc(100% - 260px);
|
||||
max-width: 1600px;
|
||||
}
|
||||
|
||||
/* Header Section */
|
||||
.hero-image-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0 0 4px 0;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Search Box */
|
||||
.search-box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-box i {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 10px 16px 10px 42px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
width: 300px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-refresh {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.btn-refresh {
|
||||
background: white;
|
||||
color: #6b7280;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.btn-refresh:hover {
|
||||
background: #f9fafb;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Hero Images Grid */
|
||||
.hero-images-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.hero-image-card {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.hero-image-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.hero-image-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
background: #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-image-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.active-badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: #10b981;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.hero-image-info {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.hero-image-info h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.hero-image-info .subtitle {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hero-image-info .description {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-image-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: white;
|
||||
color: #6b7280;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: #eff6ff;
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-activate:hover {
|
||||
background: #fef3c7;
|
||||
border-color: #f59e0b;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #fef2f2;
|
||||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading-state {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 20px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-spinner i {
|
||||
font-size: 36px;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.loading-state p {
|
||||
font-size: 16px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
background: white;
|
||||
border: 2px dashed #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 80px 20px;
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin: 0 auto 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.empty-icon i {
|
||||
font-size: 48px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 16px;
|
||||
color: #6b7280;
|
||||
margin: 0 0 24px 0;
|
||||
}
|
||||
|
||||
.empty-state .btn-primary {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px 32px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: #e5e7eb;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px 32px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 20px 32px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* Hero Image Detail */
|
||||
.hero-image-detail-card {
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-image-preview-large {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-image-preview-large img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.hero-image-details {
|
||||
padding: 24px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.detail-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #1a1a1a;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Form Styles */
|
||||
.form-section {
|
||||
margin: 24px 0;
|
||||
padding: 20px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-group label i {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
transition: all 0.2s ease;
|
||||
background: white;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
/* File Upload */
|
||||
.file-upload-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.file-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.file-label:hover {
|
||||
border-color: #3b82f6;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
|
||||
.file-label i {
|
||||
font-size: 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.file-label span {
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-text {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.current-image-preview {
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.current-image-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 992px) {
|
||||
.hero-image-content {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.hero-image-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero-images-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-image-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.hero-images-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal-header,
|
||||
.modal-body,
|
||||
.modal-footer {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.hero-image-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-actions > * {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,312 @@
|
||||
<div class="hero-image-layout">
|
||||
<!-- Sidebar -->
|
||||
<app-menu class="sidebar"></app-menu>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="hero-image-content">
|
||||
<!-- Header Section -->
|
||||
<div class="hero-image-header">
|
||||
<div class="header-left">
|
||||
<h1 class="page-title">Hero Image Management</h1>
|
||||
<p class="page-subtitle">Manage homepage hero section images</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="search-box">
|
||||
<i class="fa fa-search"></i>
|
||||
<input
|
||||
name="searchTerm"
|
||||
#searchTerm="ngModel"
|
||||
class="search-input"
|
||||
type="search"
|
||||
placeholder="Search hero images..."
|
||||
ngModel
|
||||
(ngModelChange)="searchHeroImages(searchTerm.value)">
|
||||
</div>
|
||||
<button *ngIf="isManager" class="btn-primary" data-bs-toggle="modal" data-bs-target="#addHeroImageModal">
|
||||
<i class="fa fa-plus"></i>
|
||||
<span>New Hero Image</span>
|
||||
</button>
|
||||
<button class="btn-refresh" (click)="getHeroImages(true)">
|
||||
<i class="fas fa-sync" [ngClass]="{'fa-spin': refreshing }"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero Images Grid -->
|
||||
<div class="hero-images-grid" *ngIf="heroImages && heroImages.length > 0">
|
||||
<div *ngFor="let heroImage of heroImages" class="hero-image-card" (click)="heroImage && onSelectHeroImage(heroImage)">
|
||||
<div class="hero-image-preview" *ngIf="heroImage">
|
||||
<img [src]="heroImage?.imageUrl"
|
||||
[alt]="heroImage?.title || 'Hero Image'"
|
||||
(error)="onImageError($event)"
|
||||
loading="lazy">
|
||||
<div class="active-badge" *ngIf="heroImage?.isActive">
|
||||
<i class="fa fa-check-circle"></i>
|
||||
<span>Active</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-image-info" *ngIf="heroImage">
|
||||
<h3>{{ heroImage?.title || 'Untitled' }}</h3>
|
||||
<p class="subtitle" *ngIf="heroImage?.subtitle">{{ heroImage.subtitle }}</p>
|
||||
<p class="description" *ngIf="heroImage?.description">{{ heroImage.description }}</p>
|
||||
<div class="hero-image-actions">
|
||||
<button class="btn-action btn-edit" (click)="onEditHeroImage(heroImage); $event.stopPropagation()" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn-action btn-activate"
|
||||
*ngIf="!heroImage?.isActive"
|
||||
(click)="onSetActiveHeroImage(heroImage); $event.stopPropagation()"
|
||||
title="Set as Active">
|
||||
<i class="fas fa-star"></i>
|
||||
</button>
|
||||
<button *ngIf="isAdmin && !heroImage?.isActive"
|
||||
class="btn-action btn-delete"
|
||||
(click)="onDeleteHeroImage(heroImage); $event.stopPropagation()"
|
||||
title="Delete">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div *ngIf="refreshing" class="loading-state">
|
||||
<div class="loading-spinner">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
<p>Loading hero images...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div *ngIf="!refreshing && (!heroImages || heroImages.length === 0)" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="fa fa-image"></i>
|
||||
</div>
|
||||
<h3>No hero images yet</h3>
|
||||
<p>Get started by creating your first hero image for the homepage</p>
|
||||
<button *ngIf="isManager" class="btn-primary" data-bs-toggle="modal" data-bs-target="#addHeroImageModal">
|
||||
<i class="fa fa-plus"></i>
|
||||
<span>Create Your First Hero Image</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden Modal Triggers -->
|
||||
<button [hidden]="true" type="button" id="openHeroImageInfo" data-bs-toggle="modal" data-bs-target="#viewHeroImageModal"></button>
|
||||
<button [hidden]="true" type="button" id="openHeroImageEdit" data-bs-toggle="modal" data-bs-target="#editHeroImageModal"></button>
|
||||
|
||||
<!-- View Hero Image Modal -->
|
||||
<div class="modal fade" id="viewHeroImageModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content" *ngIf="selectedHeroImage">
|
||||
<div class="modal-header">
|
||||
<h3>Hero Image Details</h3>
|
||||
<button class="modal-close" data-bs-dismiss="modal">
|
||||
<i class="fa fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="hero-image-detail-card">
|
||||
<div class="hero-image-preview-large">
|
||||
<img [src]="selectedHeroImage.imageUrl" [alt]="selectedHeroImage.title">
|
||||
<div class="active-badge" *ngIf="selectedHeroImage.isActive">
|
||||
<i class="fa fa-check-circle"></i>
|
||||
<span>Active</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-image-details">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Title:</span>
|
||||
<span class="detail-value">{{ selectedHeroImage.title }}</span>
|
||||
</div>
|
||||
<div class="detail-item" *ngIf="selectedHeroImage.subtitle">
|
||||
<span class="detail-label">Subtitle:</span>
|
||||
<span class="detail-value">{{ selectedHeroImage.subtitle }}</span>
|
||||
</div>
|
||||
<div class="detail-item" *ngIf="selectedHeroImage.description">
|
||||
<span class="detail-label">Description:</span>
|
||||
<span class="detail-value">{{ selectedHeroImage.description }}</span>
|
||||
</div>
|
||||
<div class="detail-item" *ngIf="selectedHeroImage.uploadDate">
|
||||
<span class="detail-label">Uploaded:</span>
|
||||
<span class="detail-value">{{ selectedHeroImage.uploadDate | date:'MMM d, y h:mm a' }}</span>
|
||||
</div>
|
||||
<div class="detail-item" *ngIf="selectedHeroImage.lastModified">
|
||||
<span class="detail-label">Last Modified:</span>
|
||||
<span class="detail-value">{{ selectedHeroImage.lastModified | date:'MMM d, y h:mm a' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Hero Image Modal -->
|
||||
<div *ngIf="isManager" class="modal fade" id="addHeroImageModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Add New Hero Image</h3>
|
||||
<button class="modal-close" data-bs-dismiss="modal">
|
||||
<i class="fa fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form #newHeroImageForm="ngForm" (ngSubmit)="onAddNewHeroImage(newHeroImageForm)">
|
||||
|
||||
<div class="form-section">
|
||||
<h4 class="section-title">Image Information</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">
|
||||
<i class="fa fa-heading"></i>
|
||||
Title *
|
||||
</label>
|
||||
<input type="text" id="title" name="title" class="form-input" ngModel required placeholder="Enter title">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="subtitle">
|
||||
<i class="fa fa-align-left"></i>
|
||||
Subtitle
|
||||
</label>
|
||||
<input type="text" id="subtitle" name="subtitle" class="form-input" ngModel placeholder="Enter subtitle">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">
|
||||
<i class="fa fa-align-left"></i>
|
||||
Description
|
||||
</label>
|
||||
<textarea id="description" name="description" class="form-textarea" rows="3" ngModel placeholder="Enter description"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<i class="fa fa-image"></i>
|
||||
Upload Image *
|
||||
</label>
|
||||
<div class="file-upload-wrapper">
|
||||
<input type="file"
|
||||
id="newHeroImage"
|
||||
accept="image/*"
|
||||
name="heroImage"
|
||||
(change)="onImageChange($any($event).target.files)"
|
||||
class="file-input"
|
||||
required>
|
||||
<label for="newHeroImage" class="file-label">
|
||||
<i class="fa fa-cloud-upload-alt"></i>
|
||||
<span>{{ heroImageFileName || 'Choose image (required)' }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<small class="form-text text-muted" *ngIf="!heroImageFile">
|
||||
Please upload an image file (JPG, PNG, or GIF)
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button class="btn-primary"
|
||||
type="button"
|
||||
(click)="newHeroImageForm.ngSubmit.emit()"
|
||||
[disabled]="newHeroImageForm.invalid || !heroImageFile">
|
||||
<i class="fa fa-save"></i>
|
||||
Create Hero Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Hero Image Modal -->
|
||||
<div class="modal fade" id="editHeroImageModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content" *ngIf="selectedHeroImage">
|
||||
<div class="modal-header">
|
||||
<h3>Edit Hero Image</h3>
|
||||
<button class="modal-close" data-bs-dismiss="modal">
|
||||
<i class="fa fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form #editHeroImageForm="ngForm" (ngSubmit)="onUpdateHeroImage(editHeroImageForm)">
|
||||
|
||||
<div class="form-section">
|
||||
<h4 class="section-title">Image Information</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="editTitle">
|
||||
<i class="fa fa-heading"></i>
|
||||
Title *
|
||||
</label>
|
||||
<input type="text" id="editTitle" name="title" class="form-input" [(ngModel)]="selectedHeroImage.title" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="editSubtitle">
|
||||
<i class="fa fa-align-left"></i>
|
||||
Subtitle
|
||||
</label>
|
||||
<input type="text" id="editSubtitle" name="subtitle" class="form-input" [(ngModel)]="selectedHeroImage.subtitle">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="editDescription">
|
||||
<i class="fa fa-align-left"></i>
|
||||
Description
|
||||
</label>
|
||||
<textarea id="editDescription" name="description" class="form-textarea" rows="3" [(ngModel)]="selectedHeroImage.description"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<i class="fa fa-image"></i>
|
||||
Current Image
|
||||
</label>
|
||||
<div class="current-image-preview">
|
||||
<img [src]="selectedHeroImage.imageUrl" [alt]="selectedHeroImage.title">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<i class="fa fa-image"></i>
|
||||
Upload New Image (Optional)
|
||||
</label>
|
||||
<div class="file-upload-wrapper">
|
||||
<input type="file"
|
||||
id="editHeroImage"
|
||||
accept="image/*"
|
||||
name="heroImage"
|
||||
(change)="onImageChange($any($event).target.files)"
|
||||
class="file-input">
|
||||
<label for="editHeroImage" class="file-label">
|
||||
<i class="fa fa-cloud-upload-alt"></i>
|
||||
<span>{{ heroImageFileName || 'Choose new image' }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button class="btn-primary" (click)="editHeroImageForm.ngSubmit.emit()" [disabled]="editHeroImageForm.invalid">
|
||||
<i class="fa fa-save"></i>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,25 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { HeroImageComponent } from './hero-image.component';
|
||||
|
||||
describe('HeroImageComponent', () => {
|
||||
let component: HeroImageComponent;
|
||||
let fixture: ComponentFixture<HeroImageComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ HeroImageComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(HeroImageComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,269 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { HeroImage } from '../../model/hero-image.model';
|
||||
import { NotificationService } from '../../service/notification.service';
|
||||
import { NotificationType } from '../../notification/notification-type';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { NgForm } from '@angular/forms';
|
||||
import { CustomHttpResponse } from '../../dto/custom-http-response';
|
||||
import { SubSink } from 'subsink';
|
||||
import { User } from 'src/app/model/user';
|
||||
import { Role } from 'src/app/enum/role.enum';
|
||||
import { AuthenticationService } from 'src/app/service/authentication.service';
|
||||
import { HeroImageService } from 'src/app/service/hero-image.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-hero-image',
|
||||
templateUrl: './hero-image.component.html',
|
||||
styleUrls: ['./hero-image.component.css']
|
||||
})
|
||||
export class HeroImageComponent implements OnInit, OnDestroy {
|
||||
private titleSubject = new BehaviorSubject<string>('Hero Images');
|
||||
public titleAction$ = this.titleSubject.asObservable();
|
||||
public loggedInUser: User;
|
||||
|
||||
public heroImages: HeroImage[] = [];
|
||||
public selectedHeroImage: HeroImage | null = null;
|
||||
public refreshing: boolean = false;
|
||||
private subs = new SubSink();
|
||||
|
||||
public heroImageFile: File | null = null;
|
||||
public heroImageFileName: string | null = null;
|
||||
|
||||
constructor(
|
||||
private heroImageService: HeroImageService,
|
||||
private notificationService: NotificationService,
|
||||
private authenticationService: AuthenticationService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.getHeroImages(false); // Don't show notification on initial load
|
||||
this.loggedInUser = this.authenticationService.getUserFromLocalStorage();
|
||||
this.setupModalEventListeners();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.unsubscribe();
|
||||
}
|
||||
|
||||
private setupModalEventListeners(): void {
|
||||
const editModal = document.getElementById('editHeroImageModal');
|
||||
if (editModal) {
|
||||
editModal.addEventListener('hidden.bs.modal', () => {
|
||||
this.clearEditHeroImageData();
|
||||
});
|
||||
}
|
||||
|
||||
const addModal = document.getElementById('addHeroImageModal');
|
||||
if (addModal) {
|
||||
addModal.addEventListener('hidden.bs.modal', () => {
|
||||
this.clearNewHeroImageData();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private clearNewHeroImageData(): void {
|
||||
this.heroImageFile = null;
|
||||
this.heroImageFileName = null;
|
||||
}
|
||||
|
||||
private clearEditHeroImageData(): void {
|
||||
this.heroImageFile = null;
|
||||
this.heroImageFileName = null;
|
||||
this.selectedHeroImage = null;
|
||||
}
|
||||
|
||||
private invalidateVariables(): void {
|
||||
this.heroImageFile = null;
|
||||
this.heroImageFileName = null;
|
||||
}
|
||||
|
||||
public getHeroImages(showNotification: boolean): void {
|
||||
this.refreshing = true;
|
||||
this.subs.sink = this.heroImageService.getAllHeroImages().subscribe(
|
||||
heroImages => {
|
||||
console.log('Raw hero images response:', heroImages);
|
||||
|
||||
// Filter out any null or undefined items
|
||||
this.heroImages = (heroImages || []).filter(img => img !== null && img !== undefined);
|
||||
|
||||
console.log('Filtered hero images:', this.heroImages);
|
||||
console.log('Hero images count:', this.heroImages.length);
|
||||
|
||||
this.heroImageService.addHeroImagesToLocalStorage(this.heroImages);
|
||||
|
||||
// Only show notification if explicitly requested (e.g., manual refresh)
|
||||
if (showNotification && this.heroImages.length > 0) {
|
||||
this.notificationService.notify(NotificationType.SUCCESS, `Loaded ${this.heroImages.length} hero image${this.heroImages.length > 1 ? 's' : ''}`);
|
||||
}
|
||||
this.refreshing = false;
|
||||
},
|
||||
(errorResponse: HttpErrorResponse) => {
|
||||
console.error('Error loading hero images:', errorResponse);
|
||||
this.sendErrorNotification(errorResponse.error?.message || 'Failed to load hero images');
|
||||
this.refreshing = false;
|
||||
this.heroImages = []; // Set to empty array on error
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public onSelectHeroImage(heroImage: HeroImage): void {
|
||||
this.selectedHeroImage = JSON.parse(JSON.stringify(heroImage));
|
||||
this.clickButton('openHeroImageInfo');
|
||||
}
|
||||
|
||||
public onImageChange(fileList: FileList): void {
|
||||
if (fileList && fileList.length > 0) {
|
||||
this.heroImageFileName = fileList[0].name;
|
||||
this.heroImageFile = fileList[0];
|
||||
}
|
||||
}
|
||||
|
||||
private sendErrorNotification(message: string): void {
|
||||
this.sendNotification(NotificationType.ERROR, message);
|
||||
}
|
||||
|
||||
private sendNotification(type: NotificationType, message: string): void {
|
||||
this.notificationService.notify(type, message ? message : 'An error occurred. Please try again');
|
||||
}
|
||||
|
||||
public onAddNewHeroImage(heroImageForm: NgForm): void {
|
||||
const formData = this.createHeroImageFormData(heroImageForm.value, this.heroImageFile);
|
||||
this.subs.sink = this.heroImageService.addHeroImage(formData).subscribe(
|
||||
(heroImage: HeroImage) => {
|
||||
this.getHeroImages(false);
|
||||
heroImageForm.reset();
|
||||
this.clearNewHeroImageData();
|
||||
// Close modal using Bootstrap modal API
|
||||
const modalElement = document.getElementById('addHeroImageModal');
|
||||
if (modalElement) {
|
||||
const modal = (window as any).bootstrap.Modal.getInstance(modalElement);
|
||||
if (modal) {
|
||||
modal.hide();
|
||||
}
|
||||
}
|
||||
},
|
||||
(errorResponse: HttpErrorResponse) => {
|
||||
this.sendErrorNotification(errorResponse.error.message);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public searchHeroImages(searchTerm: string): void {
|
||||
if (!searchTerm) {
|
||||
this.heroImages = this.heroImageService.getHeroImagesFromLocalStorage();
|
||||
return;
|
||||
}
|
||||
|
||||
const matchHeroImages: HeroImage[] = [];
|
||||
searchTerm = searchTerm.toLowerCase();
|
||||
for (const heroImage of this.heroImageService.getHeroImagesFromLocalStorage()) {
|
||||
if (
|
||||
(heroImage.title && heroImage.title.toLowerCase().includes(searchTerm)) ||
|
||||
(heroImage.subtitle && heroImage.subtitle.toLowerCase().includes(searchTerm)) ||
|
||||
(heroImage.description && heroImage.description.toLowerCase().includes(searchTerm))
|
||||
) {
|
||||
matchHeroImages.push(heroImage);
|
||||
}
|
||||
}
|
||||
this.heroImages = matchHeroImages;
|
||||
}
|
||||
|
||||
private clickButton(buttonId: string): void {
|
||||
document.getElementById(buttonId)?.click();
|
||||
}
|
||||
|
||||
public onEditHeroImage(heroImage: HeroImage): void {
|
||||
this.selectedHeroImage = JSON.parse(JSON.stringify(heroImage));
|
||||
this.clickButton('openHeroImageEdit');
|
||||
}
|
||||
|
||||
public onUpdateHeroImage(form: NgForm): void {
|
||||
if (form.invalid || !this.selectedHeroImage) {
|
||||
this.notificationService.notify(NotificationType.ERROR, 'Please fill out all required fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = this.createHeroImageFormData(this.selectedHeroImage, this.heroImageFile);
|
||||
|
||||
this.subs.add(this.heroImageService.updateHeroImage(this.selectedHeroImage.id!, formData).subscribe(
|
||||
(heroImage: HeroImage) => {
|
||||
this.getHeroImages(false);
|
||||
this.invalidateVariables();
|
||||
// Close modal using Bootstrap modal API
|
||||
const modalElement = document.getElementById('editHeroImageModal');
|
||||
if (modalElement) {
|
||||
const modal = (window as any).bootstrap.Modal.getInstance(modalElement);
|
||||
if (modal) {
|
||||
modal.hide();
|
||||
}
|
||||
}
|
||||
},
|
||||
(errorResponse: HttpErrorResponse) => {
|
||||
this.sendErrorNotification(errorResponse.error.message);
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
private createHeroImageFormData(heroImage: any, imageFile: File | null): FormData {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('title', heroImage.title || '');
|
||||
formData.append('subtitle', heroImage.subtitle || '');
|
||||
formData.append('description', heroImage.description || '');
|
||||
|
||||
if (imageFile) {
|
||||
formData.append('image', imageFile);
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
public onDeleteHeroImage(heroImage: HeroImage): void {
|
||||
if (heroImage.isActive) {
|
||||
this.notificationService.notify(NotificationType.ERROR, 'Cannot delete active hero image. Please set another image as active first.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`Are you sure you want to delete this hero image?`)) {
|
||||
this.subs.sink = this.heroImageService.deleteHeroImage(heroImage.id!).subscribe(
|
||||
(response: CustomHttpResponse) => {
|
||||
this.getHeroImages(false);
|
||||
this.invalidateVariables();
|
||||
this.notificationService.notify(NotificationType.SUCCESS, response.message);
|
||||
},
|
||||
(errorResponse: HttpErrorResponse) => {
|
||||
this.sendErrorNotification(errorResponse.error.message);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public onSetActiveHeroImage(heroImage: HeroImage): void {
|
||||
if (confirm(`Set this hero image as active?`)) {
|
||||
this.subs.sink = this.heroImageService.setActiveHeroImage(heroImage.id!).subscribe(
|
||||
(response: HeroImage) => {
|
||||
this.getHeroImages(false);
|
||||
// Silently refresh without notification
|
||||
},
|
||||
(errorResponse: HttpErrorResponse) => {
|
||||
this.sendErrorNotification(errorResponse.error.message);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public get isAdmin(): boolean {
|
||||
return this.loggedInUser && (this.loggedInUser.role === Role.ADMIN || this.loggedInUser.role === Role.SUPER_ADMIN);
|
||||
}
|
||||
|
||||
public get isManager(): boolean {
|
||||
return this.isAdmin || (this.loggedInUser && this.loggedInUser.role === Role.MANAGER);
|
||||
}
|
||||
|
||||
public onImageError(event: any): void {
|
||||
// Set a placeholder image when image fails to load
|
||||
event.target.src = 'assets/images/placeholder.jpg';
|
||||
console.error('Failed to load hero image');
|
||||
}
|
||||
}
|
||||
@ -73,6 +73,28 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!--Hero Image-->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" routerLink="/dashboard/heroImage" routerLinkActive="active"
|
||||
(click)="changeTitle('Hero Image')">
|
||||
<span class="nav-icon">
|
||||
<i class="fa fa-home"></i>
|
||||
</span>
|
||||
<span class="nav-text">Hero Image</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Service Tiles --> <!-- ← ADD THIS -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" routerLink="/dashboard/serviceTiles" routerLinkActive="active"
|
||||
(click)="changeTitle('Service Tiles')">
|
||||
<span class="nav-icon">
|
||||
<i class="fa fa-th-large"></i>
|
||||
</span>
|
||||
<span class="nav-text">Service Tiles</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Events -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" routerLink="/dashboard/events" routerLinkActive="active"
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Modal } from 'bootstrap';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { Professor } from '../../model/Professor';
|
||||
import { NotificationService } from '../../service/notification.service';
|
||||
@ -77,6 +78,19 @@ export class ProfessorComponent implements OnInit, OnDestroy {
|
||||
public newProfessorAwards: Award[] = [];
|
||||
public selectedProfessorAwards: Award[] = [];
|
||||
|
||||
private closeModal(modalId: string): void {
|
||||
const modalElement = document.getElementById(modalId);
|
||||
if (!modalElement) return;
|
||||
|
||||
const modalInstance = Modal.getInstance(modalElement) || new Modal(modalElement);
|
||||
modalInstance.hide();
|
||||
|
||||
// Force-remove leftover background overlays if any remain
|
||||
document.body.classList.remove('modal-open');
|
||||
document.querySelectorAll('.modal-backdrop').forEach(b => b.remove());
|
||||
}
|
||||
|
||||
|
||||
constructor(
|
||||
private professorService: ProfessorService,
|
||||
private notificationService: NotificationService,
|
||||
@ -274,7 +288,7 @@ export class ProfessorComponent implements OnInit, OnDestroy {
|
||||
const formData = this.createExtendedProfessorFormData(professorForm.value, this.profileImage);
|
||||
this.subs.sink = this.professorService.addProfessor(formData).subscribe(
|
||||
(professor: Professor) => {
|
||||
this.clickButton('new-professor-close');
|
||||
this.closeModal('addProfessorModal');
|
||||
this.getProfessors(false);
|
||||
professorForm.reset();
|
||||
this.notificationService.notify(NotificationType.SUCCESS, `Professor ${professor.firstName} added successfully`);
|
||||
@ -354,7 +368,7 @@ export class ProfessorComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.subs.add(this.professorService.updateProfessor(this.selectedProfessor.professorId, formData).subscribe(
|
||||
(professor: Professor) => {
|
||||
this.clickButton('closeEditProfessorButton');
|
||||
this.closeModal('editProfessorModal');
|
||||
this.getProfessors(false);
|
||||
this.invalidateVariables(); // Only clear profile image related data
|
||||
this.notificationService.notify(NotificationType.SUCCESS, `Professor ${professor.firstName} updated successfully`);
|
||||
|
||||
@ -0,0 +1,558 @@
|
||||
/* Service Tile Layout */
|
||||
.service-tile-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
width: 260px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.service-tile-content {
|
||||
margin-left: 260px;
|
||||
flex: 1;
|
||||
padding: 32px;
|
||||
width: calc(100% - 260px);
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
/* Header Section */
|
||||
.service-tile-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0 0 4px 0;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Search Box */
|
||||
.search-box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-box i {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 10px 16px 10px 42px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
width: 300px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-refresh {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.btn-refresh {
|
||||
background: white;
|
||||
color: #6b7280;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.btn-refresh:hover {
|
||||
background: #f9fafb;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Service Tiles List */
|
||||
.service-tiles-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.service-tile-card {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.service-tile-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.service-tile-card.inactive {
|
||||
opacity: 0.6;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
/* Service Tile Content */
|
||||
.service-tile-content-area {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.service-tile-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.service-tile-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.service-tile-info h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.service-tile-info .description {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.order-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
/* Status Badge */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.service-tile-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: white;
|
||||
color: #6b7280;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.btn-toggle:hover {
|
||||
background: #fef3c7;
|
||||
border-color: #f59e0b;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: #eff6ff;
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #fef2f2;
|
||||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading-state {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 20px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-spinner i {
|
||||
font-size: 36px;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.loading-state p {
|
||||
font-size: 16px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
background: white;
|
||||
border: 2px dashed #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 80px 20px;
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin: 0 auto 24px;
|
||||
background: #3b82f6;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.empty-icon i {
|
||||
font-size: 48px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 16px;
|
||||
color: #6b7280;
|
||||
margin: 0 0 24px 0;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px 32px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: #e5e7eb;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px 32px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 20px 32px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* Service Tile Detail */
|
||||
.service-tile-detail-card {
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.detail-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #1a1a1a;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Form Styles */
|
||||
.form-section {
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-group label i {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
transition: all 0.2s ease;
|
||||
background: white;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.form-text {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 992px) {
|
||||
.service-tile-content {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.service-tile-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.service-tile-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.service-tile-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.service-tile-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.modal-header,
|
||||
.modal-body,
|
||||
.modal-footer {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.service-tile-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-actions > * {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,204 @@
|
||||
<div class="service-tile-layout">
|
||||
<app-menu class="sidebar"></app-menu>
|
||||
|
||||
<div class="service-tile-content">
|
||||
<div class="service-tile-header">
|
||||
<div class="header-left">
|
||||
<h1 class="page-title">Service Tiles Management</h1>
|
||||
<p class="page-subtitle">Manage homepage service tiles</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="search-box">
|
||||
<i class="fa fa-search"></i>
|
||||
<input name="searchTerm" #searchTerm="ngModel" class="search-input"
|
||||
type="search" ngModel placeholder="Search service tiles..."
|
||||
(ngModelChange)="searchServiceTiles(searchTerm.value)">
|
||||
</div>
|
||||
|
||||
<button *ngIf="isManager" class="btn-primary" (click)="openAddModal()">
|
||||
<i class="fa fa-plus"></i> New Service Tile
|
||||
</button>
|
||||
|
||||
<button class="btn-refresh" (click)="getServiceTiles(true)">
|
||||
<i class="fas fa-sync" [ngClass]="{'fa-spin': refreshing }"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="service-tiles-list" *ngIf="serviceTiles?.length > 0">
|
||||
<div *ngFor="let serviceTile of serviceTiles" class="service-tile-card"
|
||||
[class.inactive]="!serviceTile?.isActive">
|
||||
|
||||
<div class="service-tile-content-area"
|
||||
(click)="onSelectServiceTile(serviceTile)">
|
||||
<div class="service-tile-info">
|
||||
<div class="service-tile-header-row">
|
||||
<h3>{{ serviceTile?.title }}</h3>
|
||||
<span class="status-badge"
|
||||
[class.status-active]="serviceTile?.isActive"
|
||||
[class.status-inactive]="!serviceTile?.isActive">
|
||||
<i class="fa"
|
||||
[class.fa-check-circle]="serviceTile?.isActive"
|
||||
[class.fa-times-circle]="!serviceTile?.isActive"></i>
|
||||
{{ serviceTile?.isActive ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="description" *ngIf="serviceTile?.description">
|
||||
{{ serviceTile.description }}</p>
|
||||
<div class="order-badge">Order: {{ serviceTile?.displayOrder }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="service-tile-actions">
|
||||
<button class="btn-action btn-toggle"
|
||||
(click)="onToggleActive(serviceTile); $event.stopPropagation()">
|
||||
<i class="fas"
|
||||
[class.fa-toggle-on]="serviceTile?.isActive"
|
||||
[class.fa-toggle-off]="!serviceTile?.isActive"></i>
|
||||
</button>
|
||||
|
||||
<button class="btn-action btn-edit"
|
||||
(click)="onEditServiceTile(serviceTile); $event.stopPropagation()">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
|
||||
<button *ngIf="isAdmin" class="btn-action btn-delete"
|
||||
(click)="onDeleteServiceTile(serviceTile); $event.stopPropagation()">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="refreshing" class="loading-state">
|
||||
<div class="loading-spinner">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
<p>Loading service tiles...</p>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!refreshing && (!serviceTiles || serviceTiles.length === 0)"
|
||||
class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="fa fa-th-large"></i>
|
||||
</div>
|
||||
<h3>No service tiles yet</h3>
|
||||
<p>Get started by creating your first service tile</p>
|
||||
<button *ngIf="isManager" class="btn-primary" (click)="openAddModal()">
|
||||
<i class="fa fa-plus"></i> Create Your First Service Tile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- View Modal -->
|
||||
<div class="modal fade" #viewServiceTileModal tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content" *ngIf="selectedServiceTile">
|
||||
<div class="modal-header">
|
||||
<h3>Service Tile Details</h3>
|
||||
<button type="button" class="modal-close" data-bs-dismiss="modal">
|
||||
<i class="fa fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="service-tile-detail-card">
|
||||
<div class="detail-item"><span class="detail-label">Title:</span>
|
||||
<span class="detail-value">{{ selectedServiceTile.title }}</span>
|
||||
</div>
|
||||
<div class="detail-item"><span
|
||||
class="detail-label">Description:</span>
|
||||
<span class="detail-value">{{ selectedServiceTile.description }}</span>
|
||||
</div>
|
||||
<div class="detail-item"><span class="detail-label">Display
|
||||
Order:</span>
|
||||
<span class="detail-value">{{ selectedServiceTile.displayOrder }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Modal -->
|
||||
<div class="modal fade" #addServiceTileModal tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<form class="modal-content" #newServiceTileForm="ngForm"
|
||||
(ngSubmit)="onAddNewServiceTile(newServiceTileForm)">
|
||||
<div class="modal-header">
|
||||
<h3>Add New Service Tile</h3>
|
||||
<button type="button" class="modal-close" data-bs-dismiss="modal">
|
||||
<i class="fa fa-times"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-section">
|
||||
<div class="form-group">
|
||||
<label>Title *</label>
|
||||
<input class="form-input" name="title" ngModel required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description *</label>
|
||||
<textarea class="form-textarea" name="description" ngModel required></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Display Order</label>
|
||||
<input type="number" class="form-input" name="displayOrder"
|
||||
ngModel value="0">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" type="button"
|
||||
data-bs-dismiss="modal">Cancel</button>
|
||||
<button class="btn-primary" type="submit"
|
||||
[disabled]="newServiceTileForm.invalid">
|
||||
<i class="fa fa-save"></i> Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<div class="modal fade" #editServiceTileModal tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<form class="modal-content" #editServiceTileForm="ngForm"
|
||||
*ngIf="selectedServiceTile"
|
||||
(ngSubmit)="onUpdateServiceTile(editServiceTileForm)">
|
||||
<div class="modal-header">
|
||||
<h3>Edit Service Tile</h3>
|
||||
<button type="button" class="modal-close" data-bs-dismiss="modal">
|
||||
<i class="fa fa-times"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-section">
|
||||
<div class="form-group">
|
||||
<label>Title *</label>
|
||||
<input class="form-input" name="title" [(ngModel)]="selectedServiceTile.title" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description *</label>
|
||||
<textarea class="form-textarea" name="description" [(ngModel)]="selectedServiceTile.description" required></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Display Order</label>
|
||||
<input type="number" class="form-input" name="displayOrder"
|
||||
[(ngModel)]="selectedServiceTile.displayOrder">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" type="button"
|
||||
data-bs-dismiss="modal">Cancel</button>
|
||||
<button class="btn-primary" type="submit"
|
||||
[disabled]="editServiceTileForm.invalid">
|
||||
<i class="fa fa-save"></i> Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,25 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ServiceTileComponent } from './service-tile.component';
|
||||
|
||||
describe('ServiceTileComponent', () => {
|
||||
let component: ServiceTileComponent;
|
||||
let fixture: ComponentFixture<ServiceTileComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ ServiceTileComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ServiceTileComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,185 @@
|
||||
import { Component, OnDestroy, OnInit, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { ServiceTile } from '../../model/service-tile.model';
|
||||
import { NotificationService } from '../../service/notification.service';
|
||||
import { NotificationType } from '../../notification/notification-type';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { NgForm } from '@angular/forms';
|
||||
import { CustomHttpResponse } from '../../dto/custom-http-response';
|
||||
import { SubSink } from 'subsink';
|
||||
import { User } from 'src/app/model/user';
|
||||
import { Role } from 'src/app/enum/role.enum';
|
||||
import { AuthenticationService } from 'src/app/service/authentication.service';
|
||||
import { ServiceTileService } from 'src/app/service/service-tile.service';
|
||||
import { Modal } from 'bootstrap';
|
||||
|
||||
@Component({
|
||||
selector: 'app-service-tile',
|
||||
templateUrl: './service-tile.component.html',
|
||||
styleUrls: ['./service-tile.component.css']
|
||||
})
|
||||
export class ServiceTileComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
|
||||
private titleSubject = new BehaviorSubject<string>('Service Tiles');
|
||||
public titleAction$ = this.titleSubject.asObservable();
|
||||
public loggedInUser: User;
|
||||
|
||||
public serviceTiles: ServiceTile[] = [];
|
||||
public selectedServiceTile: ServiceTile | null = null;
|
||||
public refreshing: boolean = false;
|
||||
private subs = new SubSink();
|
||||
|
||||
// ✅ Modal references (No hidden button clicks anymore)
|
||||
@ViewChild('addServiceTileModal') addServiceTileModal!: ElementRef;
|
||||
@ViewChild('editServiceTileModal') editServiceTileModal!: ElementRef;
|
||||
@ViewChild('viewServiceTileModal') viewServiceTileModal!: ElementRef;
|
||||
|
||||
private addModal!: Modal;
|
||||
private editModal!: Modal;
|
||||
private viewModal!: Modal;
|
||||
|
||||
constructor(
|
||||
private serviceTileService: ServiceTileService,
|
||||
private notificationService: NotificationService,
|
||||
private authenticationService: AuthenticationService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.getServiceTiles(false);
|
||||
this.loggedInUser = this.authenticationService.getUserFromLocalStorage();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// ✅ Initialize Bootstrap modal instances
|
||||
this.addModal = new Modal(this.addServiceTileModal.nativeElement);
|
||||
this.editModal = new Modal(this.editServiceTileModal.nativeElement);
|
||||
this.viewModal = new Modal(this.viewServiceTileModal.nativeElement);
|
||||
|
||||
}
|
||||
openAddModal(): void {
|
||||
this.addModal.show();
|
||||
}
|
||||
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.unsubscribe();
|
||||
}
|
||||
|
||||
public getServiceTiles(showNotification: boolean): void {
|
||||
this.refreshing = true;
|
||||
this.subs.sink = this.serviceTileService.getAllServiceTiles().subscribe(
|
||||
serviceTiles => {
|
||||
this.serviceTiles = (serviceTiles || []).filter(tile => tile);
|
||||
this.serviceTiles.sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0));
|
||||
this.serviceTileService.addServiceTilesToLocalStorage(this.serviceTiles);
|
||||
|
||||
if (showNotification && this.serviceTiles.length > 0) {
|
||||
this.notificationService.notify(NotificationType.SUCCESS, `Loaded ${this.serviceTiles.length} service tiles`);
|
||||
}
|
||||
this.refreshing = false;
|
||||
},
|
||||
(errorResponse: HttpErrorResponse) => {
|
||||
this.sendErrorNotification(errorResponse.error?.message || 'Failed to load service tiles');
|
||||
this.refreshing = false;
|
||||
this.serviceTiles = [];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public onSelectServiceTile(serviceTile: ServiceTile): void {
|
||||
this.selectedServiceTile = { ...serviceTile };
|
||||
this.viewModal.show(); // ✅ Show view modal
|
||||
}
|
||||
|
||||
public onAddNewServiceTile(form: NgForm): void {
|
||||
if (form.invalid) return;
|
||||
|
||||
const formData = this.createServiceTileFormData(form.value);
|
||||
this.subs.sink = this.serviceTileService.addServiceTile(formData).subscribe(
|
||||
serviceTile => {
|
||||
this.getServiceTiles(false);
|
||||
this.addModal.hide(); // ✅ Close modal cleanly
|
||||
form.reset();
|
||||
},
|
||||
(errorResponse: HttpErrorResponse) => {
|
||||
this.sendErrorNotification(errorResponse.error.message);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public onEditServiceTile(serviceTile: ServiceTile): void {
|
||||
this.selectedServiceTile = { ...serviceTile };
|
||||
this.editModal.show(); // ✅ Show edit modal
|
||||
}
|
||||
|
||||
public onUpdateServiceTile(form: NgForm): void {
|
||||
if (!this.selectedServiceTile || form.invalid) {
|
||||
this.notificationService.notify(NotificationType.ERROR, 'Please fill out all required fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = this.createServiceTileFormData(this.selectedServiceTile);
|
||||
|
||||
this.subs.sink = this.serviceTileService.updateServiceTile(this.selectedServiceTile.id!, formData).subscribe(
|
||||
() => {
|
||||
this.getServiceTiles(false);
|
||||
this.editModal.hide(); // ✅ Close modal cleanly
|
||||
},
|
||||
(errorResponse: HttpErrorResponse) => {
|
||||
this.sendErrorNotification(errorResponse.error.message);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public onDeleteServiceTile(serviceTile: ServiceTile): void {
|
||||
if (!confirm(`Are you sure you want to delete "${serviceTile.title}"?`)) return;
|
||||
|
||||
this.subs.sink = this.serviceTileService.deleteServiceTile(serviceTile.id!).subscribe(
|
||||
(response: CustomHttpResponse) => {
|
||||
this.getServiceTiles(false);
|
||||
this.notificationService.notify(NotificationType.SUCCESS, response.message);
|
||||
},
|
||||
(errorResponse: HttpErrorResponse) => {
|
||||
this.sendErrorNotification(errorResponse.error.message);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public onToggleActive(serviceTile: ServiceTile): void {
|
||||
this.subs.sink = this.serviceTileService.toggleActiveStatus(serviceTile.id!).subscribe(
|
||||
() => this.getServiceTiles(false),
|
||||
error => this.sendErrorNotification(error.error.message)
|
||||
);
|
||||
}
|
||||
|
||||
public searchServiceTiles(searchTerm: string): void {
|
||||
if (!searchTerm) {
|
||||
this.serviceTiles = this.serviceTileService.getServiceTilesFromLocalStorage();
|
||||
return;
|
||||
}
|
||||
|
||||
searchTerm = searchTerm.toLowerCase();
|
||||
this.serviceTiles = this.serviceTileService.getServiceTilesFromLocalStorage()
|
||||
.filter(t => t.title?.toLowerCase().includes(searchTerm) || t.description?.toLowerCase().includes(searchTerm));
|
||||
}
|
||||
|
||||
private createServiceTileFormData(serviceTile: any): FormData {
|
||||
const formData = new FormData();
|
||||
formData.append('title', serviceTile.title || '');
|
||||
formData.append('description', serviceTile.description || '');
|
||||
formData.append('displayOrder', (serviceTile.displayOrder || 0).toString());
|
||||
return formData;
|
||||
}
|
||||
|
||||
private sendErrorNotification(message: string): void {
|
||||
this.notificationService.notify(NotificationType.ERROR, message || 'An error occurred. Please try again');
|
||||
}
|
||||
|
||||
public get isAdmin(): boolean {
|
||||
return this.loggedInUser && [Role.ADMIN, Role.SUPER_ADMIN].includes(this.loggedInUser.role);
|
||||
}
|
||||
|
||||
public get isManager(): boolean {
|
||||
return this.isAdmin || (this.loggedInUser && this.loggedInUser.role === Role.MANAGER);
|
||||
}
|
||||
}
|
||||
11
support-portal-frontend/src/app/model/hero-image.model.ts
Normal file
11
support-portal-frontend/src/app/model/hero-image.model.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export interface HeroImage {
|
||||
id?: number;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
imageFilename: string;
|
||||
isActive: boolean;
|
||||
uploadDate?: Date;
|
||||
lastModified?: Date;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
export interface ServiceTile {
|
||||
id?: number;
|
||||
title: string;
|
||||
description: string;
|
||||
isActive: boolean;
|
||||
displayOrder?: number;
|
||||
createdDate?: Date;
|
||||
lastModified?: Date;
|
||||
}
|
||||
@ -27,9 +27,10 @@ export interface JobApplication {
|
||||
experience: string;
|
||||
coverLetter?: string;
|
||||
resumeUrl?: string;
|
||||
resumePath?: string; // Add this property
|
||||
status?: string;
|
||||
job?: Job;
|
||||
createdDate?: string; // Add this line
|
||||
createdDate?: string;
|
||||
updatedDate?: string;
|
||||
}
|
||||
|
||||
@ -91,4 +92,12 @@ export class CareerService {
|
||||
deleteApplication(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.applicationsApiUrl}/${id}`);
|
||||
}
|
||||
|
||||
// Resume download
|
||||
downloadResume(filename: string): Observable<Blob> {
|
||||
return this.http.get(`${environment.apiUrl}/uploads/${filename}`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { HeroImage } from '../model/hero-image.model';
|
||||
import { CustomHttpResponse } from '../dto/custom-http-response';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class HeroImageService {
|
||||
private host = environment.apiUrl;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
public addHeroImage(formData: FormData): Observable<HeroImage> {
|
||||
return this.http.post<any>(`${this.host}/hero`, formData).pipe(
|
||||
map(img => this.transformHeroImage(img))
|
||||
);
|
||||
}
|
||||
|
||||
public updateHeroImage(id: number, formData: FormData): Observable<HeroImage> {
|
||||
return this.http.put<any>(`${this.host}/hero/${id}`, formData).pipe(
|
||||
map(img => this.transformHeroImage(img))
|
||||
);
|
||||
}
|
||||
|
||||
public getActiveHeroImage(): Observable<HeroImage> {
|
||||
return this.http.get<any>(`${this.host}/hero/active`).pipe(
|
||||
map(img => this.transformHeroImage(img))
|
||||
);
|
||||
}
|
||||
|
||||
public getHeroImageById(id: number): Observable<HeroImage> {
|
||||
return this.http.get<any>(`${this.host}/hero/${id}`).pipe(
|
||||
map(img => this.transformHeroImage(img))
|
||||
);
|
||||
}
|
||||
|
||||
public getAllHeroImages(): Observable<HeroImage[]> {
|
||||
return this.http.get<any[]>(`${this.host}/hero`).pipe(
|
||||
map(images => images.map(img => this.transformHeroImage(img)))
|
||||
);
|
||||
}
|
||||
|
||||
public deleteHeroImage(id: number): Observable<CustomHttpResponse> {
|
||||
return this.http.delete<CustomHttpResponse>(`${this.host}/hero/${id}`);
|
||||
}
|
||||
|
||||
public setActiveHeroImage(id: number): Observable<HeroImage> {
|
||||
return this.http.put<any>(`${this.host}/hero/${id}/activate`, {}).pipe(
|
||||
map(img => this.transformHeroImage(img))
|
||||
);
|
||||
}
|
||||
|
||||
// Transform backend response to match frontend model
|
||||
private transformHeroImage(img: any): HeroImage {
|
||||
return {
|
||||
...img,
|
||||
isActive: img.isActive !== undefined ? img.isActive : img.active
|
||||
};
|
||||
}
|
||||
|
||||
// Local storage methods
|
||||
public addHeroImagesToLocalStorage(heroImages: HeroImage[]): void {
|
||||
localStorage.setItem('heroImages', JSON.stringify(heroImages));
|
||||
}
|
||||
|
||||
public getHeroImagesFromLocalStorage(): HeroImage[] {
|
||||
const heroImages = localStorage.getItem('heroImages');
|
||||
return heroImages ? JSON.parse(heroImages) : [];
|
||||
}
|
||||
|
||||
public getActiveHeroImageFromLocalStorage(): HeroImage | null {
|
||||
const heroImages = this.getHeroImagesFromLocalStorage();
|
||||
return heroImages.find(img => img.isActive) || null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { ServiceTile } from '../model/service-tile.model';
|
||||
import { CustomHttpResponse } from '../dto/custom-http-response';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ServiceTileService {
|
||||
private host = environment.apiUrl;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
public addServiceTile(formData: FormData): Observable<ServiceTile> {
|
||||
return this.http.post<any>(`${this.host}/service-tiles`, formData).pipe(
|
||||
map(tile => this.transformServiceTile(tile))
|
||||
);
|
||||
}
|
||||
|
||||
public updateServiceTile(id: number, formData: FormData): Observable<ServiceTile> {
|
||||
return this.http.put<any>(`${this.host}/service-tiles/${id}`, formData).pipe(
|
||||
map(tile => this.transformServiceTile(tile))
|
||||
);
|
||||
}
|
||||
|
||||
public getActiveServiceTiles(): Observable<ServiceTile[]> {
|
||||
return this.http.get<any[]>(`${this.host}/service-tiles/active`).pipe(
|
||||
map(tiles => tiles.map(tile => this.transformServiceTile(tile)))
|
||||
);
|
||||
}
|
||||
|
||||
public getServiceTileById(id: number): Observable<ServiceTile> {
|
||||
return this.http.get<any>(`${this.host}/service-tiles/${id}`).pipe(
|
||||
map(tile => this.transformServiceTile(tile))
|
||||
);
|
||||
}
|
||||
|
||||
public getAllServiceTiles(): Observable<ServiceTile[]> {
|
||||
return this.http.get<any[]>(`${this.host}/service-tiles`).pipe(
|
||||
map(tiles => tiles.map(tile => this.transformServiceTile(tile)))
|
||||
);
|
||||
}
|
||||
|
||||
public deleteServiceTile(id: number): Observable<CustomHttpResponse> {
|
||||
return this.http.delete<CustomHttpResponse>(`${this.host}/service-tiles/${id}`);
|
||||
}
|
||||
|
||||
public toggleActiveStatus(id: number): Observable<ServiceTile> {
|
||||
return this.http.put<any>(`${this.host}/service-tiles/${id}/toggle-active`, {}).pipe(
|
||||
map(tile => this.transformServiceTile(tile))
|
||||
);
|
||||
}
|
||||
|
||||
public reorderServiceTiles(orderedIds: number[]): Observable<CustomHttpResponse> {
|
||||
return this.http.put<CustomHttpResponse>(`${this.host}/service-tiles/reorder`, orderedIds);
|
||||
}
|
||||
|
||||
// Transform backend response to match frontend model
|
||||
private transformServiceTile(tile: any): ServiceTile {
|
||||
return {
|
||||
...tile,
|
||||
isActive: tile.isActive !== undefined ? tile.isActive : tile.active
|
||||
};
|
||||
}
|
||||
|
||||
// Local storage methods
|
||||
public addServiceTilesToLocalStorage(serviceTiles: ServiceTile[]): void {
|
||||
localStorage.setItem('serviceTiles', JSON.stringify(serviceTiles));
|
||||
}
|
||||
|
||||
public getServiceTilesFromLocalStorage(): ServiceTile[] {
|
||||
const serviceTiles = localStorage.getItem('serviceTiles');
|
||||
return serviceTiles ? JSON.parse(serviceTiles) : [];
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
@ -22,6 +21,10 @@
|
||||
"lib": [
|
||||
"es2018",
|
||||
"dom"
|
||||
],
|
||||
"types": [
|
||||
"jasmine",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
|
||||
Reference in New Issue
Block a user