Compare commits
25 Commits
6d46457973
...
feature/my
| Author | SHA1 | Date | |
|---|---|---|---|
| 39e49a40cb | |||
| a10803ee73 | |||
| 3856524152 | |||
| fc973e86d2 | |||
| 3e96bd4fb8 | |||
| 08d3a8b9f4 | |||
| a65bf2dfaa | |||
| 8eb755abdb | |||
| 0f9473595d | |||
| a8b54c1209 | |||
| 0a60f016e9 | |||
| b97e57e070 | |||
| 152ea94034 | |||
| 9348e456a7 | |||
| 96f56fd1ca | |||
| fdab880de2 | |||
| 4286934d9d | |||
| 1aa92b7501 | |||
| d1173ed400 | |||
| 8eeed12d0b | |||
| a9cc0a9122 | |||
| 311ca61dea | |||
| c228fee79c | |||
| 71af6e4268 | |||
| 0df3fc6ae3 |
@ -4,7 +4,7 @@ public class FileConstant {
|
|||||||
|
|
||||||
public static final String USER_IMAGE_PATH = "/user/image/";
|
public static final String USER_IMAGE_PATH = "/user/image/";
|
||||||
public static final String JPG_EXTENSION = "jpg";
|
public static final String JPG_EXTENSION = "jpg";
|
||||||
public static final String USER_FOLDER = System.getProperty("user.home") + "/supportportal/user/";
|
public static final String USER_FOLDER = "/app/uploads/user/";
|
||||||
public static final String DIRECTORY_CREATED = "Created directory for: ";
|
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 DEFAULT_USER_IMAGE_URI_PATTERN = "/user/%s/profile-image";
|
||||||
public static final String USER_IMAGE_FILENAME = "avatar.jpg";
|
public static final String USER_IMAGE_FILENAME = "avatar.jpg";
|
||||||
@ -14,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 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/";
|
public static final String TEMP_PROFILE_IMAGE_BASE_URL = "https://robohash.org/";
|
||||||
|
|
||||||
// ✅ Professor-specific constants
|
// Professor-specific constants
|
||||||
public static final String PROFESSOR_IMAGE_PATH = "/professor/image/";
|
public static final String PROFESSOR_IMAGE_PATH = "/professor/image/";
|
||||||
public static final String PROFESSOR_FOLDER = System.getProperty("user.home") + "/supportportal/professor/";
|
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 DEFAULT_PROFESSOR_IMAGE_URI_PATTERN = "/professor/%s/profile-image";
|
||||||
public static final String PROFESSOR_IMAGE_FILENAME = "avatar.jpg";
|
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_";
|
||||||
|
}
|
||||||
@ -33,6 +33,17 @@ public class CourseController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all past/inactive courses (for public display)
|
||||||
|
@GetMapping("/past")
|
||||||
|
public ResponseEntity<List<Course>> getPastCourses() {
|
||||||
|
try {
|
||||||
|
List<Course> courses = courseRepository.findAllByIsActiveFalse();
|
||||||
|
return ResponseEntity.ok(courses);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get all courses (for admin)
|
// Get all courses (for admin)
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<Course> getAllCourses() {
|
public List<Course> getAllCourses() {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// EventController.java - FIXED
|
||||||
package net.shyshkin.study.fullstack.supportportal.backend.controller;
|
package net.shyshkin.study.fullstack.supportportal.backend.controller;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@ -69,17 +70,17 @@ public class EventController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additional endpoint to get upcoming events
|
// Upcoming events - ACTIVE events ordered by date ASC
|
||||||
@GetMapping("/upcoming")
|
@GetMapping("/upcoming")
|
||||||
public ResponseEntity<List<Event>> getUpcomingEvents() {
|
public ResponseEntity<List<Event>> getUpcomingEvents() {
|
||||||
List<Event> events = eventRepository.findByIsActiveTrueOrderByDateAsc();
|
List<Event> events = eventRepository.findByIsActiveTrueOrderByDateAsc();
|
||||||
return new ResponseEntity<>(events, HttpStatus.OK);
|
return new ResponseEntity<>(events, HttpStatus.OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additional endpoint to get past events
|
// FIXED: Past events - INACTIVE events ordered by date DESC
|
||||||
@GetMapping("/past")
|
@GetMapping("/past")
|
||||||
public ResponseEntity<List<Event>> getPastEvents() {
|
public ResponseEntity<List<Event>> getPastEvents() {
|
||||||
List<Event> events = eventRepository.findByIsActiveTrueOrderByDateDesc();
|
List<Event> events = eventRepository.findByIsActiveFalseOrderByDateDesc();
|
||||||
return new ResponseEntity<>(events, HttpStatus.OK);
|
return new ResponseEntity<>(events, HttpStatus.OK);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -11,13 +11,18 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.nio.file.StandardCopyOption;
|
import java.nio.file.StandardCopyOption;
|
||||||
import java.util.HashMap;
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/files")
|
@RequestMapping("/api/files")
|
||||||
@CrossOrigin(origins = "*")
|
@CrossOrigin(origins = {
|
||||||
|
"https://cmcbackend.rootxwire.com",
|
||||||
|
"https://maincmc.rootxwire.com",
|
||||||
|
"https://cmctrauma.com"
|
||||||
|
})
|
||||||
public class FileController {
|
public class FileController {
|
||||||
|
|
||||||
@Value("${file.upload.directory:uploads}")
|
@Value("${file.upload.directory:uploads}")
|
||||||
@ -26,48 +31,86 @@ public class FileController {
|
|||||||
@Value("${app.base-url:http://localhost:8080}")
|
@Value("${app.base-url:http://localhost:8080}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
||||||
|
private static final List<String> ALLOWED_EXTENSIONS = Arrays.asList(
|
||||||
|
".jpg", ".jpeg", ".png", ".gif", ".webp",
|
||||||
|
".pdf", ".doc", ".docx"
|
||||||
|
);
|
||||||
|
|
||||||
|
private static final List<String> ALLOWED_CONTENT_TYPES = Arrays.asList(
|
||||||
|
"image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp",
|
||||||
|
"application/pdf",
|
||||||
|
"application/msword",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
);
|
||||||
|
|
||||||
|
private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
@PostMapping("/upload")
|
@PostMapping("/upload")
|
||||||
public ResponseEntity<?> uploadFile(@RequestParam("file") MultipartFile file) {
|
public ResponseEntity<?> uploadFile(@RequestParam("file") MultipartFile file) {
|
||||||
System.out.println("=== FILE UPLOAD DEBUG ===");
|
|
||||||
System.out.println("File name: " + file.getOriginalFilename());
|
|
||||||
System.out.println("Content type: " + file.getContentType());
|
|
||||||
System.out.println("Base URL: " + baseUrl);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 1. Check empty
|
||||||
if (file.isEmpty()) {
|
if (file.isEmpty()) {
|
||||||
return ResponseEntity.badRequest().body("File is empty");
|
return ResponseEntity.badRequest().body("File is empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updated validation to accept both images and documents
|
// 2. Check file size
|
||||||
String contentType = file.getContentType();
|
if (file.getSize() > MAX_FILE_SIZE) {
|
||||||
if (!isValidFileType(contentType)) {
|
return ResponseEntity.badRequest().body("File size exceeds 5MB limit");
|
||||||
return ResponseEntity.badRequest().body("Invalid file type. Only images and documents (PDF, DOC, DOCX) are allowed.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create upload directory if it doesn't exist
|
// 3. Get and sanitize original filename
|
||||||
|
String originalFilename = file.getOriginalFilename();
|
||||||
|
if (originalFilename == null || originalFilename.isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().body("Invalid filename");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent path traversal - only take the actual filename
|
||||||
|
String sanitizedFilename = Paths.get(originalFilename).getFileName().toString();
|
||||||
|
|
||||||
|
// 4. Validate extension (cannot be spoofed by client unlike Content-Type)
|
||||||
|
String extension = getExtension(sanitizedFilename);
|
||||||
|
if (extension == null || !ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body("Invalid file type. Allowed: JPG, PNG, GIF, WEBP, PDF, DOC, DOCX");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Validate Content-Type (secondary check)
|
||||||
|
String contentType = file.getContentType();
|
||||||
|
if (!ALLOWED_CONTENT_TYPES.contains(contentType)) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body("Invalid content type.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Validate Content-Type matches extension
|
||||||
|
if (!isContentTypeMatchingExtension(contentType, extension)) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body("File type mismatch detected.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Create upload directory if needed
|
||||||
Path uploadPath = Paths.get(uploadDirectory);
|
Path uploadPath = Paths.get(uploadDirectory);
|
||||||
if (!Files.exists(uploadPath)) {
|
if (!Files.exists(uploadPath)) {
|
||||||
Files.createDirectories(uploadPath);
|
Files.createDirectories(uploadPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique filename
|
// 8. Generate safe unique filename
|
||||||
String originalFilename = file.getOriginalFilename();
|
String uniqueFilename = UUID.randomUUID().toString() + extension.toLowerCase();
|
||||||
String fileExtension = originalFilename.substring(originalFilename.lastIndexOf("."));
|
|
||||||
String uniqueFilename = UUID.randomUUID().toString() + fileExtension;
|
|
||||||
|
|
||||||
// Save file
|
// 9. Resolve path safely (prevent path traversal)
|
||||||
Path filePath = uploadPath.resolve(uniqueFilename);
|
Path filePath = uploadPath.resolve(uniqueFilename).normalize();
|
||||||
|
if (!filePath.startsWith(uploadPath.normalize())) {
|
||||||
|
return ResponseEntity.badRequest().body("Invalid file path");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Save file
|
||||||
Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
|
Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
|
||||||
// FIXED: Return full URL with base URL
|
|
||||||
String fileUrl = baseUrl + "/uploads/" + uniqueFilename;
|
String fileUrl = baseUrl + "/uploads/" + uniqueFilename;
|
||||||
Map<String, String> response = new HashMap<>();
|
|
||||||
response.put("url", fileUrl);
|
|
||||||
response.put("filename", uniqueFilename);
|
|
||||||
|
|
||||||
System.out.println("File uploaded successfully. URL: " + fileUrl);
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"url", fileUrl,
|
||||||
return ResponseEntity.ok(response);
|
"filename", uniqueFilename
|
||||||
|
));
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
@ -75,32 +118,25 @@ public class FileController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updated validation method
|
|
||||||
private boolean isValidFileType(String contentType) {
|
|
||||||
return contentType != null && (
|
|
||||||
// Image types
|
|
||||||
contentType.equals("image/jpeg") ||
|
|
||||||
contentType.equals("image/jpg") ||
|
|
||||||
contentType.equals("image/png") ||
|
|
||||||
contentType.equals("image/gif") ||
|
|
||||||
contentType.equals("image/webp") ||
|
|
||||||
// Document types for resumes
|
|
||||||
contentType.equals("application/pdf") ||
|
|
||||||
contentType.equals("application/msword") ||
|
|
||||||
contentType.equals("application/vnd.openxmlformats-officedocument.wordprocessingml.document")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/images/{filename}")
|
@DeleteMapping("/images/{filename}")
|
||||||
public ResponseEntity<?> deleteImage(@PathVariable String filename) {
|
public ResponseEntity<?> deleteImage(@PathVariable String filename) {
|
||||||
try {
|
try {
|
||||||
Path filePath = Paths.get(uploadDirectory).resolve(filename);
|
// Prevent path traversal
|
||||||
|
String sanitized = Paths.get(filename).getFileName().toString();
|
||||||
|
|
||||||
|
Path uploadPath = Paths.get(uploadDirectory).normalize();
|
||||||
|
Path filePath = uploadPath.resolve(sanitized).normalize();
|
||||||
|
|
||||||
|
if (!filePath.startsWith(uploadPath)) {
|
||||||
|
return ResponseEntity.badRequest().body("Invalid filename");
|
||||||
|
}
|
||||||
|
|
||||||
if (!Files.exists(filePath)) {
|
if (!Files.exists(filePath)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
Files.delete(filePath);
|
Files.delete(filePath);
|
||||||
return ResponseEntity.ok().body(Map.of("message", "File deleted successfully"));
|
return ResponseEntity.ok(Map.of("message", "File deleted successfully"));
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
@ -111,30 +147,67 @@ public class FileController {
|
|||||||
@GetMapping("/images/{filename}")
|
@GetMapping("/images/{filename}")
|
||||||
public ResponseEntity<byte[]> getImage(@PathVariable String filename) {
|
public ResponseEntity<byte[]> getImage(@PathVariable String filename) {
|
||||||
try {
|
try {
|
||||||
Path filePath = Paths.get(uploadDirectory).resolve(filename);
|
// Prevent path traversal
|
||||||
|
String sanitized = Paths.get(filename).getFileName().toString();
|
||||||
|
|
||||||
|
Path uploadPath = Paths.get(uploadDirectory).normalize();
|
||||||
|
Path filePath = uploadPath.resolve(sanitized).normalize();
|
||||||
|
|
||||||
|
if (!filePath.startsWith(uploadPath)) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
|
||||||
if (!Files.exists(filePath)) {
|
if (!Files.exists(filePath)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
byte[] imageBytes = Files.readAllBytes(filePath);
|
// Validate it's an allowed file type before serving
|
||||||
|
String ext = getExtension(sanitized);
|
||||||
|
if (ext == null || !ALLOWED_EXTENSIONS.contains(ext.toLowerCase())) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] fileBytes = Files.readAllBytes(filePath);
|
||||||
String contentType = Files.probeContentType(filePath);
|
String contentType = Files.probeContentType(filePath);
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.header("Content-Type", contentType != null ? contentType : "application/octet-stream")
|
.header("Content-Type", contentType != null ? contentType : "application/octet-stream")
|
||||||
.body(imageBytes);
|
.header("Content-Disposition", "inline; filename=\"" + sanitized + "\"")
|
||||||
|
.body(fileBytes);
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isValidImageType(String contentType) {
|
// --- Helpers ---
|
||||||
return contentType != null && (
|
|
||||||
contentType.equals("image/jpeg") ||
|
private String getExtension(String filename) {
|
||||||
contentType.equals("image/jpg") ||
|
int dotIndex = filename.lastIndexOf(".");
|
||||||
contentType.equals("image/png") ||
|
if (dotIndex < 0 || dotIndex == filename.length() - 1) return null;
|
||||||
contentType.equals("image/gif") ||
|
return filename.substring(dotIndex);
|
||||||
contentType.equals("image/webp")
|
}
|
||||||
);
|
|
||||||
|
private boolean isContentTypeMatchingExtension(String contentType, String extension) {
|
||||||
|
String ext = extension.toLowerCase();
|
||||||
|
switch (ext) {
|
||||||
|
case ".jpg":
|
||||||
|
case ".jpeg":
|
||||||
|
return contentType.equals("image/jpeg") || contentType.equals("image/jpg");
|
||||||
|
case ".png":
|
||||||
|
return contentType.equals("image/png");
|
||||||
|
case ".gif":
|
||||||
|
return contentType.equals("image/gif");
|
||||||
|
case ".webp":
|
||||||
|
return contentType.equals("image/webp");
|
||||||
|
case ".pdf":
|
||||||
|
return contentType.equals("application/pdf");
|
||||||
|
case ".doc":
|
||||||
|
return contentType.equals("application/msword");
|
||||||
|
case ".docx":
|
||||||
|
return contentType.equals("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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 net.shyshkin.study.fullstack.supportportal.backend.repository.JobApplicationRepository;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
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.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import com.amazonaws.services.secretsmanager.model.ResourceNotFoundException;
|
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;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@ -26,6 +34,9 @@ public class JobApplicationController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private JobRepository jobRepository;
|
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)
|
// Get all applications (for admin)
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<JobApplication> getAllApplications() {
|
public List<JobApplication> getAllApplications() {
|
||||||
@ -92,4 +103,68 @@ public class JobApplicationController {
|
|||||||
})
|
})
|
||||||
.orElse(ResponseEntity.notFound().build());
|
.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.springframework.http.HttpStatus.OK;
|
import static org.springframework.http.HttpStatus.OK;
|
||||||
@ -33,19 +34,19 @@ public class ProfessorResource {
|
|||||||
|
|
||||||
@PostMapping("register")
|
@PostMapping("register")
|
||||||
public Professor register(@RequestBody Professor professor) {
|
public Professor register(@RequestBody Professor professor) {
|
||||||
return professorService.register(professor.getFirstName(), professor.getLastName(), professor.getEmail(), professor.getDepartment(), professor.getPosition());
|
return professorService.register(professor.getFirstName(), professor.getLastName(),
|
||||||
|
professor.getEmail(), professor.getDepartment(), professor.getPosition());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("add")
|
@PostMapping("add")
|
||||||
public ResponseEntity<Professor> addNewProfessor(@Valid ProfessorDto professorDto) {
|
public ResponseEntity<Professor> addNewProfessor(@Valid ProfessorDto professorDto) {
|
||||||
log.debug("Professor DTO: {}", professorDto);
|
log.debug("Professor DTO: {}", professorDto);
|
||||||
Professor professor = professorService.addNewProfessor(professorDto);
|
Professor professor = professorService.addNewProfessor(professorDto);
|
||||||
return ResponseEntity.ok(professor);
|
return ResponseEntity.ok(professor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@PutMapping("{professorId}")
|
@PutMapping("{professorId}")
|
||||||
public Professor updateProfessor(@PathVariable UUID professorId, @Valid ProfessorDto professorDto) {
|
public Professor updateProfessor(@PathVariable UUID professorId, @Valid ProfessorDto professorDto) {
|
||||||
log.debug("Professor DTO: {}", professorDto);
|
log.debug("Professor DTO: {}", professorDto);
|
||||||
return professorService.updateProfessor(professorId, professorDto);
|
return professorService.updateProfessor(professorId, professorDto);
|
||||||
}
|
}
|
||||||
@ -77,12 +78,14 @@ public class ProfessorResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("{professorId}/profile-image")
|
@PutMapping("{professorId}/profile-image")
|
||||||
public Professor updateProfileImage(@PathVariable UUID professorId, @RequestParam MultipartFile profileImage) {
|
public Professor updateProfileImage(@PathVariable UUID professorId,
|
||||||
|
@RequestParam MultipartFile profileImage) {
|
||||||
return professorService.updateProfileImage(professorId, profileImage);
|
return professorService.updateProfileImage(professorId, profileImage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(path = "{professorId}/profile-image/{filename}", produces = MediaType.IMAGE_JPEG_VALUE)
|
@GetMapping(path = "{professorId}/profile-image/{filename}", produces = MediaType.IMAGE_JPEG_VALUE)
|
||||||
public byte[] getProfileImageByProfessorId(@PathVariable UUID professorId, @PathVariable String filename) {
|
public byte[] getProfileImageByProfessorId(@PathVariable UUID professorId,
|
||||||
|
@PathVariable String filename) {
|
||||||
return professorService.getImageByProfessorId(professorId, filename);
|
return professorService.getImageByProfessorId(professorId, filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,4 +93,24 @@ public class ProfessorResource {
|
|||||||
public byte[] getDefaultProfileImage(@PathVariable UUID professorId) {
|
public byte[] getDefaultProfileImage(@PathVariable UUID professorId) {
|
||||||
return professorService.getDefaultProfileImage(professorId);
|
return professorService.getDefaultProfileImage(professorId);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Bulk-update displayOrder.
|
||||||
|
*
|
||||||
|
* Accepts a JSON array of professor UUIDs in the desired display order.
|
||||||
|
* The array index becomes each professor's displayOrder value.
|
||||||
|
*
|
||||||
|
* Example request body:
|
||||||
|
* ["uuid-A", "uuid-B", "uuid-C"]
|
||||||
|
*
|
||||||
|
* Returns the full professor list sorted by the new displayOrder.
|
||||||
|
*
|
||||||
|
* Requires ADMIN or MANAGER role (configure in your SecurityConfig).
|
||||||
|
*/
|
||||||
|
@PutMapping("order")
|
||||||
|
public ResponseEntity<List<Professor>> updateDisplayOrder(@RequestBody List<UUID> orderedIds) {
|
||||||
|
log.info("Updating display order for {} professors", orderedIds.size());
|
||||||
|
List<Professor> updated = professorService.updateDisplayOrder(orderedIds);
|
||||||
|
return ResponseEntity.ok(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,140 @@
|
|||||||
|
package net.shyshkin.study.fullstack.supportportal.backend.resource;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.HttpResponse;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.Publication;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.service.PublicationService;
|
||||||
|
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("/publications")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
@CrossOrigin
|
||||||
|
public class PublicationResource {
|
||||||
|
|
||||||
|
private final PublicationService publicationService;
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@PreAuthorize("hasAnyAuthority('user:create')")
|
||||||
|
public ResponseEntity<Publication> addPublication(
|
||||||
|
@RequestParam String title,
|
||||||
|
@RequestParam String authors,
|
||||||
|
@RequestParam Integer year,
|
||||||
|
@RequestParam String journal,
|
||||||
|
@RequestParam(required = false) String doi,
|
||||||
|
@RequestParam(required = false) String category,
|
||||||
|
@RequestParam(required = false) String abstractText,
|
||||||
|
@RequestParam(required = false) String publicationDate,
|
||||||
|
@RequestParam(required = false) String keywords,
|
||||||
|
@RequestParam(required = false) Integer displayOrder) {
|
||||||
|
|
||||||
|
log.info("Adding new publication: {}", title);
|
||||||
|
Publication publication = publicationService.addPublication(
|
||||||
|
title, authors, year, journal, doi, category, abstractText, publicationDate, keywords, displayOrder
|
||||||
|
);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(publication);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@PreAuthorize("hasAnyAuthority('user:update')")
|
||||||
|
public ResponseEntity<Publication> updatePublication(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestParam String title,
|
||||||
|
@RequestParam String authors,
|
||||||
|
@RequestParam Integer year,
|
||||||
|
@RequestParam String journal,
|
||||||
|
@RequestParam(required = false) String doi,
|
||||||
|
@RequestParam(required = false) String category,
|
||||||
|
@RequestParam(required = false) String abstractText,
|
||||||
|
@RequestParam(required = false) String publicationDate,
|
||||||
|
@RequestParam(required = false) String keywords,
|
||||||
|
@RequestParam(required = false) Integer displayOrder) {
|
||||||
|
|
||||||
|
log.info("Updating publication with id: {}", id);
|
||||||
|
Publication publication = publicationService.updatePublication(
|
||||||
|
id, title, authors, year, journal, doi, category, abstractText, publicationDate, keywords, displayOrder
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(publication);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/active")
|
||||||
|
public ResponseEntity<List<Publication>> getActivePublications() {
|
||||||
|
log.info("Getting active publications");
|
||||||
|
List<Publication> publications = publicationService.getActivePublications();
|
||||||
|
return ResponseEntity.ok(publications);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<Publication> getPublicationById(@PathVariable Long id) {
|
||||||
|
log.info("Getting publication with id: {}", id);
|
||||||
|
Publication publication = publicationService.getPublicationById(id);
|
||||||
|
return ResponseEntity.ok(publication);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@PreAuthorize("hasAnyAuthority('user:read')")
|
||||||
|
public ResponseEntity<List<Publication>> getAllPublications() {
|
||||||
|
log.info("Getting all publications");
|
||||||
|
List<Publication> publications = publicationService.getAllPublications();
|
||||||
|
return ResponseEntity.ok(publications);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/category/{category}")
|
||||||
|
public ResponseEntity<List<Publication>> getPublicationsByCategory(@PathVariable String category) {
|
||||||
|
log.info("Getting publications by category: {}", category);
|
||||||
|
List<Publication> publications = publicationService.getPublicationsByCategory(category);
|
||||||
|
return ResponseEntity.ok(publications);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/year/{year}")
|
||||||
|
public ResponseEntity<List<Publication>> getPublicationsByYear(@PathVariable Integer year) {
|
||||||
|
log.info("Getting publications by year: {}", year);
|
||||||
|
List<Publication> publications = publicationService.getPublicationsByYear(year);
|
||||||
|
return ResponseEntity.ok(publications);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@PreAuthorize("hasAnyAuthority('user:delete')")
|
||||||
|
public ResponseEntity<HttpResponse> deletePublication(@PathVariable Long id) {
|
||||||
|
log.info("Deleting publication with id: {}", id);
|
||||||
|
publicationService.deletePublication(id);
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
HttpResponse.builder()
|
||||||
|
.httpStatusCode(HttpStatus.OK.value())
|
||||||
|
.httpStatus(HttpStatus.OK)
|
||||||
|
.reason(HttpStatus.OK.getReasonPhrase())
|
||||||
|
.message("Publication deleted successfully")
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/toggle-active")
|
||||||
|
@PreAuthorize("hasAnyAuthority('user:update')")
|
||||||
|
public ResponseEntity<Publication> toggleActiveStatus(@PathVariable Long id) {
|
||||||
|
log.info("Toggling active status for publication with id: {}", id);
|
||||||
|
Publication publication = publicationService.toggleActiveStatus(id);
|
||||||
|
return ResponseEntity.ok(publication);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/reorder")
|
||||||
|
@PreAuthorize("hasAnyAuthority('user:update')")
|
||||||
|
public ResponseEntity<HttpResponse> reorderPublications(@RequestBody List<Long> orderedIds) {
|
||||||
|
log.info("Reordering publications");
|
||||||
|
publicationService.reorderPublications(orderedIds);
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
HttpResponse.builder()
|
||||||
|
.httpStatusCode(HttpStatus.OK.value())
|
||||||
|
.httpStatus(HttpStatus.OK)
|
||||||
|
.reason(HttpStatus.OK.getReasonPhrase())
|
||||||
|
.message("Publications reordered successfully")
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
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.enumeration.ServiceTileCategory;
|
||||||
|
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 ServiceTileCategory category,
|
||||||
|
@RequestParam(required = false) Integer displayOrder) {
|
||||||
|
|
||||||
|
log.info("Adding new service tile with title: {} and category: {}", title, category);
|
||||||
|
ServiceTile serviceTile = serviceTileService.addServiceTile(title, description, category, 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 ServiceTileCategory category,
|
||||||
|
@RequestParam(required = false) Integer displayOrder) {
|
||||||
|
|
||||||
|
log.info("Updating service tile with id: {}", id);
|
||||||
|
ServiceTile serviceTile = serviceTileService.updateServiceTile(id, title, description, category, 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,7 +22,7 @@ public class Course extends BaseEntity {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT", nullable = false)
|
@Column(columnDefinition = "TEXT")
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
|
|||||||
@ -30,6 +30,12 @@ public class CourseApplication extends BaseEntity {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String phone;
|
private String phone;
|
||||||
|
|
||||||
|
@Column(name = "resume_url")
|
||||||
|
private String resumeUrl;
|
||||||
|
|
||||||
|
@Column(name = "resume_path")
|
||||||
|
private String resumePath;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String qualification;
|
private String qualification;
|
||||||
|
|
||||||
@ -38,12 +44,13 @@ public class CourseApplication extends BaseEntity {
|
|||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String coverLetter;
|
private String coverLetter;
|
||||||
|
|
||||||
private String resumeUrl;
|
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
private ApplicationStatus status = ApplicationStatus.PENDING;
|
private ApplicationStatus status = ApplicationStatus.PENDING;
|
||||||
|
|
||||||
public enum ApplicationStatus {
|
public enum ApplicationStatus {
|
||||||
PENDING, REVIEWED, SHORTLISTED, ACCEPTED, REJECTED, ENROLLED
|
PENDING, REVIEWED, SHORTLISTED, ACCEPTED, REJECTED, ENROLLED
|
||||||
}
|
}
|
||||||
|
public String getResumePath() {
|
||||||
|
return resumePath != null ? resumePath : resumeUrl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -19,7 +19,7 @@ public class Event {
|
|||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@Column(nullable = false, unique = true)
|
@Column
|
||||||
private String code;
|
private String code;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
@ -77,10 +77,14 @@ public class Event {
|
|||||||
@Column(name = "is_deleted", nullable = false)
|
@Column(name = "is_deleted", nullable = false)
|
||||||
private Boolean isDeleted = false;
|
private Boolean isDeleted = false;
|
||||||
|
|
||||||
// NEW FIELD: Book Seat Link
|
// Registration/Booking Link
|
||||||
@Column(name = "book_seat_link", length = 500)
|
@Column(name = "book_seat_link", length = 500)
|
||||||
private String bookSeatLink;
|
private String bookSeatLink;
|
||||||
|
|
||||||
|
// Additional Information Link
|
||||||
|
@Column(name = "learn_more_link", length = 500)
|
||||||
|
private String learnMoreLink;
|
||||||
|
|
||||||
@ManyToMany
|
@ManyToMany
|
||||||
@JoinTable(
|
@JoinTable(
|
||||||
name = "event_professors",
|
name = "event_professors",
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,8 +2,6 @@ package net.shyshkin.study.fullstack.supportportal.backend.domain;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
import org.hibernate.annotations.Fetch;
|
|
||||||
import org.hibernate.annotations.FetchMode;
|
|
||||||
import org.hibernate.annotations.Type;
|
import org.hibernate.annotations.Type;
|
||||||
|
|
||||||
import javax.persistence.*;
|
import javax.persistence.*;
|
||||||
@ -46,42 +44,47 @@ public class Professor implements Serializable {
|
|||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
private ProfessorCategory category;
|
private ProfessorCategory category;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controls display order within each category section on the public frontend.
|
||||||
|
* Lower values appear first. Defaults to 0 (new professors appear at the top).
|
||||||
|
* Admins can drag-and-drop rows in the management UI to reorder.
|
||||||
|
*/
|
||||||
|
@Column(nullable = false, columnDefinition = "INT DEFAULT 0")
|
||||||
|
@Builder.Default
|
||||||
|
private Integer displayOrder = 0;
|
||||||
|
|
||||||
// Additional fields for Next.js integration
|
// Additional fields for Next.js integration
|
||||||
private String phone;
|
private String phone;
|
||||||
private String specialty;
|
private String specialty;
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String certification;
|
private String certification;
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String training;
|
private String training;
|
||||||
|
|
||||||
private String experience;
|
private String experience;
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
private String designation;
|
private String designation;
|
||||||
|
|
||||||
@ElementCollection(fetch = FetchType.EAGER)
|
@ElementCollection(fetch = FetchType.EAGER)
|
||||||
@CollectionTable(name = "professor_work_days", joinColumns = @JoinColumn(name = "professor_id"))
|
@CollectionTable(name = "professor_work_days", joinColumns = @JoinColumn(name = "professor_id"))
|
||||||
@Column(name = "work_day")
|
@Column(name = "work_day")
|
||||||
private List<String> workDays;
|
private List<String> workDays;
|
||||||
|
|
||||||
// Use Set instead of List to avoid MultipleBagFetchException
|
@OneToMany(mappedBy = "professor", cascade = CascadeType.ALL, fetch = FetchType.EAGER, orphanRemoval = true)
|
||||||
// Sets can be eagerly loaded together without issues
|
|
||||||
@OneToMany(mappedBy = "professor", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
|
|
||||||
private Set<ProfessorSkill> skills;
|
private Set<ProfessorSkill> skills;
|
||||||
|
|
||||||
// Use Set instead of List to avoid MultipleBagFetchException
|
@OneToMany(mappedBy = "professor", cascade = CascadeType.ALL, fetch = FetchType.EAGER, orphanRemoval = true)
|
||||||
@OneToMany(mappedBy = "professor", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
|
|
||||||
private Set<ProfessorAward> awards;
|
private Set<ProfessorAward> awards;
|
||||||
|
|
||||||
@ManyToMany(mappedBy = "professors")
|
@ManyToMany(mappedBy = "professors")
|
||||||
@JsonIgnore // Keep this as @JsonIgnore to avoid circular references
|
@JsonIgnore
|
||||||
private List<Post> posts;
|
private List<Post> posts;
|
||||||
|
|
||||||
// Convenience method to get full name
|
|
||||||
public String getName() {
|
public String getName() {
|
||||||
return firstName + " " + lastName;
|
return firstName + " " + lastName;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,5 +3,9 @@ package net.shyshkin.study.fullstack.supportportal.backend.domain;
|
|||||||
public enum ProfessorCategory {
|
public enum ProfessorCategory {
|
||||||
FACULTY,
|
FACULTY,
|
||||||
SUPPORT_TEAM,
|
SUPPORT_TEAM,
|
||||||
TRAINEE_FELLOW
|
TRAINEE_FELLOW,
|
||||||
|
RESIGNED,
|
||||||
|
GUIDES,
|
||||||
|
FRIENDS,
|
||||||
|
PATRONS
|
||||||
}
|
}
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
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 = "publications")
|
||||||
|
public class Publication implements Serializable {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(nullable = false, updatable = false)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT", nullable = false)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String authors; // Stored as comma-separated string
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Integer year;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT", nullable = false)
|
||||||
|
private String journal;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String doi;
|
||||||
|
|
||||||
|
@Column(length = 100)
|
||||||
|
private String category;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "LONGTEXT")
|
||||||
|
private String abstractText;
|
||||||
|
|
||||||
|
@Column(length = 100)
|
||||||
|
private String publicationDate;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String keywords; // Stored as comma-separated string
|
||||||
|
|
||||||
|
@JsonProperty("isActive")
|
||||||
|
@Column(name = "is_active")
|
||||||
|
private boolean isActive;
|
||||||
|
|
||||||
|
@Column(name = "display_order")
|
||||||
|
private Integer displayOrder;
|
||||||
|
|
||||||
|
@Temporal(TemporalType.TIMESTAMP)
|
||||||
|
private Date createdDate;
|
||||||
|
|
||||||
|
@Temporal(TemporalType.TIMESTAMP)
|
||||||
|
private Date lastModified;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdDate = new Date();
|
||||||
|
lastModified = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
lastModified = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
package net.shyshkin.study.fullstack.supportportal.backend.domain;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.enumeration.ServiceTileCategory;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false)
|
||||||
|
private ServiceTileCategory category;
|
||||||
|
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -45,5 +45,6 @@ public class User implements Serializable {
|
|||||||
private String[] authorities;
|
private String[] authorities;
|
||||||
private boolean isActive;
|
private boolean isActive;
|
||||||
private boolean isNotLocked;
|
private boolean isNotLocked;
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,5 +3,6 @@ package net.shyshkin.study.fullstack.supportportal.backend.domain;
|
|||||||
public enum WorkingStatus {
|
public enum WorkingStatus {
|
||||||
ACTIVE,
|
ACTIVE,
|
||||||
ON_LEAVE,
|
ON_LEAVE,
|
||||||
RETIRED
|
RETIRED,
|
||||||
|
INACTIVE
|
||||||
}
|
}
|
||||||
@ -17,7 +17,6 @@ public class CourseDto {
|
|||||||
@NotNull
|
@NotNull
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@NotNull
|
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
|
|||||||
@ -6,18 +6,12 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import net.shyshkin.study.fullstack.supportportal.backend.domain.WorkingStatus;
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.WorkingStatus;
|
||||||
import net.shyshkin.study.fullstack.supportportal.backend.domain.ProfessorCategory;
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.ProfessorCategory;
|
||||||
// Add these imports at the top
|
|
||||||
import net.shyshkin.study.fullstack.supportportal.backend.domain.dto.SkillDto;
|
|
||||||
import net.shyshkin.study.fullstack.supportportal.backend.domain.dto.AwardDto;
|
|
||||||
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import javax.validation.constraints.Email;
|
|
||||||
import javax.validation.constraints.NotEmpty;
|
import javax.validation.constraints.NotEmpty;
|
||||||
import javax.validation.constraints.NotNull;
|
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@ -34,10 +28,13 @@ public class ProfessorDto {
|
|||||||
private String officeLocation;
|
private String officeLocation;
|
||||||
private WorkingStatus status;
|
private WorkingStatus status;
|
||||||
private ProfessorCategory category;
|
private ProfessorCategory category;
|
||||||
private LocalDateTime joinDate;
|
|
||||||
|
// Received as ISO string from multipart form e.g. "2026-04-22T23:17:58.831Z"
|
||||||
|
private String joinDate;
|
||||||
|
|
||||||
private MultipartFile profileImage;
|
private MultipartFile profileImage;
|
||||||
|
|
||||||
// Additional fields for Next.js integration
|
// Additional fields
|
||||||
private String phone;
|
private String phone;
|
||||||
private String specialty;
|
private String specialty;
|
||||||
private String certification;
|
private String certification;
|
||||||
|
|||||||
@ -0,0 +1,16 @@
|
|||||||
|
package net.shyshkin.study.fullstack.supportportal.backend.enumeration;
|
||||||
|
|
||||||
|
public enum ServiceTileCategory {
|
||||||
|
TRAUMA_CARE("Trauma Care"),
|
||||||
|
ACUTE_CARE_SURGERY("Acute Care Surgery");
|
||||||
|
|
||||||
|
private final String displayName;
|
||||||
|
|
||||||
|
ServiceTileCategory(String displayName) {
|
||||||
|
this.displayName = displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDisplayName() {
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 PublicationNotFoundException extends RuntimeException {
|
||||||
|
|
||||||
|
public PublicationNotFoundException(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,12 +10,12 @@ import org.mapstruct.Named;
|
|||||||
@Mapper(componentModel = "spring")
|
@Mapper(componentModel = "spring")
|
||||||
public interface ProfessorMapper {
|
public interface ProfessorMapper {
|
||||||
|
|
||||||
// @Mapping(target = "professorId", ignore = true) // Auto-generated
|
@Mapping(target = "joinDate", ignore = true) // Handled in service
|
||||||
@Mapping(target = "joinDate", expression = "java(java.time.LocalDateTime.now())") // Default value
|
|
||||||
@Mapping(target = "status", source = "status", qualifiedByName = "stringToWorkingStatus")
|
@Mapping(target = "status", source = "status", qualifiedByName = "stringToWorkingStatus")
|
||||||
Professor toEntity(ProfessorDto professorDto);
|
Professor toEntity(ProfessorDto professorDto);
|
||||||
|
|
||||||
@Mapping(target = "profileImage", ignore = true) // Ignore profileImage mapping
|
@Mapping(target = "profileImage", ignore = true)
|
||||||
|
@Mapping(target = "joinDate", expression = "java(professor.getJoinDate() != null ? professor.getJoinDate().toString() + 'Z' : null)")
|
||||||
@Mapping(target = "status", source = "status", qualifiedByName = "workingStatusToString")
|
@Mapping(target = "status", source = "status", qualifiedByName = "workingStatusToString")
|
||||||
ProfessorDto toDto(Professor professor);
|
ProfessorDto toDto(Professor professor);
|
||||||
|
|
||||||
@ -28,6 +28,4 @@ public interface ProfessorMapper {
|
|||||||
default String workingStatusToString(WorkingStatus status) {
|
default String workingStatusToString(WorkingStatus status) {
|
||||||
return status == null ? null : status.name();
|
return status == null ? null : status.name();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@ -2,18 +2,27 @@ package net.shyshkin.study.fullstack.supportportal.backend.mapper;
|
|||||||
|
|
||||||
import net.shyshkin.study.fullstack.supportportal.backend.domain.User;
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.User;
|
||||||
import net.shyshkin.study.fullstack.supportportal.backend.domain.dto.UserDto;
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.dto.UserDto;
|
||||||
|
import org.mapstruct.AfterMapping;
|
||||||
import org.mapstruct.Mapper;
|
import org.mapstruct.Mapper;
|
||||||
import org.mapstruct.Mapping;
|
import org.mapstruct.Mapping;
|
||||||
|
import org.mapstruct.MappingTarget;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Mapper(componentModel = "spring")
|
||||||
@Mapper(componentModel = "spring", imports = {LocalDateTime.class})
|
|
||||||
public interface UserMapper {
|
public interface UserMapper {
|
||||||
|
|
||||||
@Mapping(target = "isNotLocked", source = "notLocked")
|
@Mapping(target = "isNotLocked", source = "notLocked")
|
||||||
@Mapping(target = "isActive", source = "active")
|
@Mapping(target = "isActive", source = "active")
|
||||||
@Mapping(target = "joinDate", expression = "java(LocalDateTime.now())")
|
@Mapping(target = "joinDate", ignore = true)
|
||||||
@Mapping(target = "role", source = "role", resultType = String.class)
|
@Mapping(target = "role", source = "role", resultType = String.class)
|
||||||
@Mapping(target = "authorities", source = "role.authorities")
|
@Mapping(target = "authorities", source = "role.authorities")
|
||||||
User toEntity(UserDto userDto);
|
User toEntity(UserDto userDto);
|
||||||
}
|
|
||||||
|
@AfterMapping
|
||||||
|
default void setJoinDate(@MappingTarget User user) {
|
||||||
|
if (user.getJoinDate() == null) {
|
||||||
|
user.setJoinDate(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
// CourseRepository.java
|
// CourseRepository.java - Add this method to your existing repository
|
||||||
package net.shyshkin.study.fullstack.supportportal.backend.repository;
|
package net.shyshkin.study.fullstack.supportportal.backend.repository;
|
||||||
|
|
||||||
import net.shyshkin.study.fullstack.supportportal.backend.domain.Course;
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.Course;
|
||||||
@ -9,7 +9,13 @@ import java.util.List;
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface CourseRepository extends JpaRepository<Course, Long> {
|
public interface CourseRepository extends JpaRepository<Course, Long> {
|
||||||
|
|
||||||
|
// Get all active courses
|
||||||
List<Course> findAllByIsActiveTrue();
|
List<Course> findAllByIsActiveTrue();
|
||||||
|
|
||||||
|
// Get all past/inactive courses - ADD THIS METHOD
|
||||||
|
List<Course> findAllByIsActiveFalse();
|
||||||
|
|
||||||
List<Course> findAllByCategory(String category);
|
List<Course> findAllByCategory(String category);
|
||||||
List<Course> findAllByLevel(String level);
|
List<Course> findAllByLevel(String level);
|
||||||
}
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// EventRepository.java - FIXED
|
||||||
package net.shyshkin.study.fullstack.supportportal.backend.repository;
|
package net.shyshkin.study.fullstack.supportportal.backend.repository;
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
@ -14,8 +15,8 @@ public interface EventRepository extends JpaRepository<Event, Long> {
|
|||||||
// Find active events ordered by date ascending (for upcoming events)
|
// Find active events ordered by date ascending (for upcoming events)
|
||||||
List<Event> findByIsActiveTrueOrderByDateAsc();
|
List<Event> findByIsActiveTrueOrderByDateAsc();
|
||||||
|
|
||||||
// Find active events ordered by date descending (for past events)
|
// FIXED: Find INACTIVE events ordered by date descending (for past events)
|
||||||
List<Event> findByIsActiveTrueOrderByDateDesc();
|
List<Event> findByIsActiveFalseOrderByDateDesc();
|
||||||
|
|
||||||
// Find events by year
|
// Find events by year
|
||||||
List<Event> findByYearAndIsActiveTrue(String year);
|
List<Event> findByYearAndIsActiveTrue(String year);
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
@ -3,9 +3,11 @@ package net.shyshkin.study.fullstack.supportportal.backend.repository;
|
|||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@ -25,15 +27,35 @@ public interface ProfessorRepository extends JpaRepository<Professor, Long> {
|
|||||||
|
|
||||||
@Query("SELECT p FROM Professor p WHERE p.professorId = :professorId")
|
@Query("SELECT p FROM Professor p WHERE p.professorId = :professorId")
|
||||||
Optional<Professor> findByProfessorId(@Param("professorId") UUID professorId);
|
Optional<Professor> findByProfessorId(@Param("professorId") UUID professorId);
|
||||||
|
|
||||||
@Query("SELECT p FROM Professor p WHERE p.status = :status")
|
// ── Status / category filters, ordered by displayOrder then lastName ────────
|
||||||
|
|
||||||
|
@Query("SELECT p FROM Professor p WHERE p.status = :status ORDER BY p.displayOrder ASC, p.lastName ASC")
|
||||||
Page<Professor> findByStatus(@Param("status") WorkingStatus status, Pageable pageable);
|
Page<Professor> findByStatus(@Param("status") WorkingStatus status, Pageable pageable);
|
||||||
|
|
||||||
@Query("SELECT p FROM Professor p WHERE p.category = :category")
|
@Query("SELECT p FROM Professor p WHERE p.category = :category ORDER BY p.displayOrder ASC, p.lastName ASC")
|
||||||
Page<Professor> findByCategory(@Param("category") ProfessorCategory category, Pageable pageable);
|
Page<Professor> findByCategory(@Param("category") ProfessorCategory category, Pageable pageable);
|
||||||
|
|
||||||
@Query("SELECT p FROM Professor p WHERE p.status = :status AND p.category = :category")
|
@Query("SELECT p FROM Professor p WHERE p.status = :status AND p.category = :category ORDER BY p.displayOrder ASC, p.lastName ASC")
|
||||||
Page<Professor> findByStatusAndCategory(@Param("status") WorkingStatus status,
|
Page<Professor> findByStatusAndCategory(@Param("status") WorkingStatus status,
|
||||||
@Param("category") ProfessorCategory category,
|
@Param("category") ProfessorCategory category,
|
||||||
Pageable pageable);
|
Pageable pageable);
|
||||||
|
|
||||||
|
// ── Bulk display-order update ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets displayOrder for a single professor identified by professorId.
|
||||||
|
* Used by the bulk-reorder service method.
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE Professor p SET p.displayOrder = :displayOrder WHERE p.professorId = :professorId")
|
||||||
|
void updateDisplayOrder(@Param("professorId") UUID professorId,
|
||||||
|
@Param("displayOrder") int displayOrder);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all professors ordered by displayOrder ASC, then lastName ASC.
|
||||||
|
* Used by the admin reorder endpoint to return the updated list.
|
||||||
|
*/
|
||||||
|
@Query("SELECT p FROM Professor p ORDER BY p.displayOrder ASC, p.lastName ASC")
|
||||||
|
List<Professor> findAllOrderedByDisplayOrder();
|
||||||
}
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package net.shyshkin.study.fullstack.supportportal.backend.repository;
|
||||||
|
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.Publication;
|
||||||
|
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 PublicationRepository extends JpaRepository<Publication, Long> {
|
||||||
|
|
||||||
|
@Query("SELECT p FROM Publication p WHERE p.isActive = true ORDER BY p.year DESC, p.displayOrder ASC, p.id DESC")
|
||||||
|
List<Publication> findByIsActiveTrueOrderByYearDesc();
|
||||||
|
|
||||||
|
@Query("SELECT p FROM Publication p ORDER BY p.year DESC, p.displayOrder ASC, p.id DESC")
|
||||||
|
List<Publication> findAllOrderByYearDesc();
|
||||||
|
|
||||||
|
List<Publication> findByCategory(String category);
|
||||||
|
|
||||||
|
List<Publication> findByYear(Integer year);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import org.springframework.data.domain.Page;
|
|||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface ProfessorService {
|
public interface ProfessorService {
|
||||||
@ -30,15 +31,20 @@ public interface ProfessorService {
|
|||||||
byte[] getImageByProfessorId(UUID professorId, String filename);
|
byte[] getImageByProfessorId(UUID professorId, String filename);
|
||||||
|
|
||||||
byte[] getDefaultProfileImage(UUID professorId);
|
byte[] getDefaultProfileImage(UUID professorId);
|
||||||
|
|
||||||
// Existing method for active professors
|
|
||||||
Page<Professor> findActiveProfessors(Pageable pageable);
|
Page<Professor> findActiveProfessors(Pageable pageable);
|
||||||
|
|
||||||
// New methods for category-based filtering
|
|
||||||
Page<Professor> findByCategory(ProfessorCategory category, Pageable pageable);
|
Page<Professor> findByCategory(ProfessorCategory category, Pageable pageable);
|
||||||
|
|
||||||
Page<Professor> findActiveProfessorsByCategory(ProfessorCategory category, Pageable pageable);
|
Page<Professor> findActiveProfessorsByCategory(ProfessorCategory category, Pageable pageable);
|
||||||
|
|
||||||
// Method to find professor with details
|
|
||||||
Professor findProfessorWithDetailsById(UUID professorId);
|
Professor findProfessorWithDetailsById(UUID professorId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-update displayOrder for all professors.
|
||||||
|
*
|
||||||
|
* @param orderedIds Professor UUIDs in the desired display order (index 0 = first).
|
||||||
|
* @return All professors sorted by their new displayOrder.
|
||||||
|
*/
|
||||||
|
List<Professor> updateDisplayOrder(List<UUID> orderedIds);
|
||||||
}
|
}
|
||||||
@ -8,7 +8,6 @@ import net.shyshkin.study.fullstack.supportportal.backend.domain.ProfessorCatego
|
|||||||
import net.shyshkin.study.fullstack.supportportal.backend.domain.ProfessorSkill;
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.ProfessorSkill;
|
||||||
import net.shyshkin.study.fullstack.supportportal.backend.domain.dto.ProfessorDto;
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.dto.ProfessorDto;
|
||||||
import net.shyshkin.study.fullstack.supportportal.backend.domain.WorkingStatus;
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.WorkingStatus;
|
||||||
import net.shyshkin.study.fullstack.supportportal.backend.exception.domain.EmailExistsException;
|
|
||||||
import net.shyshkin.study.fullstack.supportportal.backend.exception.domain.NotAnImageFileException;
|
import net.shyshkin.study.fullstack.supportportal.backend.exception.domain.NotAnImageFileException;
|
||||||
import net.shyshkin.study.fullstack.supportportal.backend.exception.domain.ProfessorNotFoundException;
|
import net.shyshkin.study.fullstack.supportportal.backend.exception.domain.ProfessorNotFoundException;
|
||||||
import net.shyshkin.study.fullstack.supportportal.backend.mapper.ProfessorMapper;
|
import net.shyshkin.study.fullstack.supportportal.backend.mapper.ProfessorMapper;
|
||||||
@ -18,23 +17,25 @@ import org.springframework.core.ParameterizedTypeReference;
|
|||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.http.RequestEntity;
|
import org.springframework.http.RequestEntity;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
|
||||||
import javax.annotation.PostConstruct;
|
import javax.annotation.PostConstruct;
|
||||||
import javax.transaction.Transactional;
|
import javax.transaction.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.IntStream;
|
||||||
|
|
||||||
import static net.shyshkin.study.fullstack.supportportal.backend.constant.FileConstant.*;
|
import static net.shyshkin.study.fullstack.supportportal.backend.constant.FileConstant.*;
|
||||||
import static org.springframework.http.MediaType.*;
|
import static org.springframework.http.MediaType.*;
|
||||||
@ -45,7 +46,6 @@ import static org.springframework.http.MediaType.*;
|
|||||||
public class ProfessorServiceImpl implements ProfessorService {
|
public class ProfessorServiceImpl implements ProfessorService {
|
||||||
|
|
||||||
public static final String EMAIL_NOT_FOUND_MSG = "Professor with email `%s` not found";
|
public static final String EMAIL_NOT_FOUND_MSG = "Professor with email `%s` not found";
|
||||||
public static final String EMAIL_EXISTS_MSG = "Professor with email `%s` is already registered";
|
|
||||||
public static final String PROFESSOR_NOT_FOUND_MSG = "Professor not found";
|
public static final String PROFESSOR_NOT_FOUND_MSG = "Professor not found";
|
||||||
|
|
||||||
private final ProfessorRepository professorRepository;
|
private final ProfessorRepository professorRepository;
|
||||||
@ -64,6 +64,19 @@ public class ProfessorServiceImpl implements ProfessorService {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private LocalDateTime parseJoinDate(String joinDate) {
|
||||||
|
if (joinDate == null || joinDate.isBlank()) {
|
||||||
|
return LocalDateTime.now();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return ZonedDateTime.parse(joinDate, DateTimeFormatter.ISO_DATE_TIME)
|
||||||
|
.toLocalDateTime();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Could not parse joinDate '{}', defaulting to now()", joinDate);
|
||||||
|
return LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public Professor register(String firstName, String lastName, String email, String department, String position) {
|
public Professor register(String firstName, String lastName, String email, String department, String position) {
|
||||||
@ -74,19 +87,15 @@ public class ProfessorServiceImpl implements ProfessorService {
|
|||||||
.department(department)
|
.department(department)
|
||||||
.position(position)
|
.position(position)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
return addNewProfessor(professorDto);
|
return addNewProfessor(professorDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ FIXED: Changed from DEFAULT_USER_IMAGE_URI_PATTERN to DEFAULT_PROFESSOR_IMAGE_URI_PATTERN
|
|
||||||
private String generateDefaultProfileImageUrl(UUID professorId) {
|
private String generateDefaultProfileImageUrl(UUID professorId) {
|
||||||
return ServletUriComponentsBuilder.fromCurrentContextPath()
|
return ServletUriComponentsBuilder.fromCurrentContextPath()
|
||||||
.path(String.format(DEFAULT_PROFESSOR_IMAGE_URI_PATTERN, professorId))
|
.path(String.format(DEFAULT_PROFESSOR_IMAGE_URI_PATTERN, professorId))
|
||||||
.toUriString();
|
.toUriString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ FIXED: Changed from DEFAULT_USER_IMAGE_URI_PATTERN to DEFAULT_PROFESSOR_IMAGE_URI_PATTERN
|
|
||||||
// ✅ FIXED: Changed from USER_IMAGE_FILENAME to PROFESSOR_IMAGE_FILENAME
|
|
||||||
private String generateProfileImageUrl(UUID professorId) {
|
private String generateProfileImageUrl(UUID professorId) {
|
||||||
return ServletUriComponentsBuilder.fromCurrentContextPath()
|
return ServletUriComponentsBuilder.fromCurrentContextPath()
|
||||||
.path(String.format(DEFAULT_PROFESSOR_IMAGE_URI_PATTERN, professorId))
|
.path(String.format(DEFAULT_PROFESSOR_IMAGE_URI_PATTERN, professorId))
|
||||||
@ -103,7 +112,7 @@ public class ProfessorServiceImpl implements ProfessorService {
|
|||||||
public Professor findByEmail(String email) {
|
public Professor findByEmail(String email) {
|
||||||
return professorRepository
|
return professorRepository
|
||||||
.findByEmail(email)
|
.findByEmail(email)
|
||||||
.orElseThrow(() -> new EmailExistsException(String.format(EMAIL_NOT_FOUND_MSG, email)));
|
.orElseThrow(() -> new ProfessorNotFoundException(String.format(EMAIL_NOT_FOUND_MSG, email)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -134,7 +143,6 @@ public class ProfessorServiceImpl implements ProfessorService {
|
|||||||
.orElseThrow(() -> new ProfessorNotFoundException(PROFESSOR_NOT_FOUND_MSG));
|
.orElseThrow(() -> new ProfessorNotFoundException(PROFESSOR_NOT_FOUND_MSG));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ FIXED: Changed from USER_IMAGE_FILENAME to PROFESSOR_IMAGE_FILENAME
|
|
||||||
private void saveProfileImage(Professor professor, MultipartFile profileImage) {
|
private void saveProfileImage(Professor professor, MultipartFile profileImage) {
|
||||||
if (profileImage == null) return;
|
if (profileImage == null) return;
|
||||||
|
|
||||||
@ -162,19 +170,20 @@ public class ProfessorServiceImpl implements ProfessorService {
|
|||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public Professor addNewProfessor(ProfessorDto professorDto) {
|
public Professor addNewProfessor(ProfessorDto professorDto) {
|
||||||
validateNewEmail(professorDto.getEmail());
|
|
||||||
|
|
||||||
Professor professor = professorMapper.toEntity(professorDto);
|
Professor professor = professorMapper.toEntity(professorDto);
|
||||||
|
|
||||||
// Set a unique identifier for the professor
|
|
||||||
professor.setProfessorId(generateUuid());
|
professor.setProfessorId(generateUuid());
|
||||||
professor.setJoinDate(LocalDateTime.now());
|
professor.setJoinDate(parseJoinDate(professorDto.getJoinDate()));
|
||||||
professor.setProfileImageUrl(generateDefaultProfileImageUrl(professor.getProfessorId()));
|
professor.setProfileImageUrl(generateDefaultProfileImageUrl(professor.getProfessorId()));
|
||||||
|
|
||||||
// Save the professor first to get the ID
|
// New professors get displayOrder = 0 by default (appear first).
|
||||||
|
// Admins can re-order via the drag-and-drop endpoint.
|
||||||
|
if (professor.getDisplayOrder() == null) {
|
||||||
|
professor.setDisplayOrder(0);
|
||||||
|
}
|
||||||
|
|
||||||
Professor savedProfessor = professorRepository.save(professor);
|
Professor savedProfessor = professorRepository.save(professor);
|
||||||
|
|
||||||
// Handle skills if provided
|
|
||||||
if (professorDto.getSkills() != null && !professorDto.getSkills().isEmpty()) {
|
if (professorDto.getSkills() != null && !professorDto.getSkills().isEmpty()) {
|
||||||
Set<ProfessorSkill> skills = professorDto.getSkills().stream()
|
Set<ProfessorSkill> skills = professorDto.getSkills().stream()
|
||||||
.filter(skillDto -> skillDto.getName() != null && !skillDto.getName().trim().isEmpty())
|
.filter(skillDto -> skillDto.getName() != null && !skillDto.getName().trim().isEmpty())
|
||||||
@ -187,7 +196,6 @@ public class ProfessorServiceImpl implements ProfessorService {
|
|||||||
savedProfessor.setSkills(skills);
|
savedProfessor.setSkills(skills);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle awards if provided
|
|
||||||
if (professorDto.getAwards() != null && !professorDto.getAwards().isEmpty()) {
|
if (professorDto.getAwards() != null && !professorDto.getAwards().isEmpty()) {
|
||||||
Set<ProfessorAward> awards = professorDto.getAwards().stream()
|
Set<ProfessorAward> awards = professorDto.getAwards().stream()
|
||||||
.filter(awardDto -> awardDto.getTitle() != null && !awardDto.getTitle().trim().isEmpty())
|
.filter(awardDto -> awardDto.getTitle() != null && !awardDto.getTitle().trim().isEmpty())
|
||||||
@ -202,10 +210,8 @@ public class ProfessorServiceImpl implements ProfessorService {
|
|||||||
savedProfessor.setAwards(awards);
|
savedProfessor.setAwards(awards);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save again to persist the relationships
|
|
||||||
Professor finalProfessor = professorRepository.save(savedProfessor);
|
Professor finalProfessor = professorRepository.save(savedProfessor);
|
||||||
|
|
||||||
// Handle profile image if provided
|
|
||||||
if (professorDto.getProfileImage() != null) {
|
if (professorDto.getProfileImage() != null) {
|
||||||
saveProfileImage(finalProfessor, professorDto.getProfileImage());
|
saveProfileImage(finalProfessor, professorDto.getProfileImage());
|
||||||
}
|
}
|
||||||
@ -217,11 +223,8 @@ public class ProfessorServiceImpl implements ProfessorService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public Professor updateProfessor(UUID professorId, ProfessorDto professorDto) {
|
public Professor updateProfessor(UUID professorId, ProfessorDto professorDto) {
|
||||||
Professor professor = professorRepository.findByProfessorId(professorId)
|
Professor professor = professorRepository.findByProfessorId(professorId)
|
||||||
.orElseThrow(() -> new RuntimeException("Professor not found with id: " + professorId));
|
.orElseThrow(() -> new ProfessorNotFoundException("Professor not found with id: " + professorId));
|
||||||
|
|
||||||
validateUpdateEmail(professorId, professorDto.getEmail());
|
|
||||||
|
|
||||||
// Update basic fields
|
|
||||||
professor.setFirstName(professorDto.getFirstName());
|
professor.setFirstName(professorDto.getFirstName());
|
||||||
professor.setLastName(professorDto.getLastName());
|
professor.setLastName(professorDto.getLastName());
|
||||||
professor.setEmail(professorDto.getEmail());
|
professor.setEmail(professorDto.getEmail());
|
||||||
@ -230,8 +233,6 @@ public class ProfessorServiceImpl implements ProfessorService {
|
|||||||
professor.setOfficeLocation(professorDto.getOfficeLocation());
|
professor.setOfficeLocation(professorDto.getOfficeLocation());
|
||||||
professor.setStatus(professorDto.getStatus());
|
professor.setStatus(professorDto.getStatus());
|
||||||
professor.setCategory(professorDto.getCategory());
|
professor.setCategory(professorDto.getCategory());
|
||||||
|
|
||||||
// Update extended fields
|
|
||||||
professor.setPhone(professorDto.getPhone());
|
professor.setPhone(professorDto.getPhone());
|
||||||
professor.setSpecialty(professorDto.getSpecialty());
|
professor.setSpecialty(professorDto.getSpecialty());
|
||||||
professor.setCertification(professorDto.getCertification());
|
professor.setCertification(professorDto.getCertification());
|
||||||
@ -240,20 +241,19 @@ public class ProfessorServiceImpl implements ProfessorService {
|
|||||||
professor.setDescription(professorDto.getDescription());
|
professor.setDescription(professorDto.getDescription());
|
||||||
professor.setDesignation(professorDto.getDesignation());
|
professor.setDesignation(professorDto.getDesignation());
|
||||||
professor.setWorkDays(professorDto.getWorkDays());
|
professor.setWorkDays(professorDto.getWorkDays());
|
||||||
|
// displayOrder is intentionally NOT updated here — only via the reorder endpoint.
|
||||||
|
|
||||||
if (professorDto.getJoinDate() != null) {
|
if (professorDto.getJoinDate() != null && !professorDto.getJoinDate().isBlank()) {
|
||||||
professor.setJoinDate(professorDto.getJoinDate());
|
professor.setJoinDate(parseJoinDate(professorDto.getJoinDate()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a final reference for lambda expressions
|
|
||||||
final Professor professorRef = professor;
|
final Professor professorRef = professor;
|
||||||
|
|
||||||
// Update skills - clear existing and add new ones
|
if (professor.getSkills() == null) professor.setSkills(new HashSet<>());
|
||||||
if (professor.getSkills() != null) {
|
professor.getSkills().clear();
|
||||||
professor.getSkills().clear();
|
|
||||||
}
|
|
||||||
if (professorDto.getSkills() != null && !professorDto.getSkills().isEmpty()) {
|
if (professorDto.getSkills() != null && !professorDto.getSkills().isEmpty()) {
|
||||||
Set<ProfessorSkill> skills = professorDto.getSkills().stream()
|
Set<ProfessorSkill> newSkills = professorDto.getSkills().stream()
|
||||||
.filter(skillDto -> skillDto.getName() != null && !skillDto.getName().trim().isEmpty())
|
.filter(skillDto -> skillDto.getName() != null && !skillDto.getName().trim().isEmpty())
|
||||||
.map(skillDto -> ProfessorSkill.builder()
|
.map(skillDto -> ProfessorSkill.builder()
|
||||||
.name(skillDto.getName().trim())
|
.name(skillDto.getName().trim())
|
||||||
@ -261,18 +261,14 @@ public class ProfessorServiceImpl implements ProfessorService {
|
|||||||
.professor(professorRef)
|
.professor(professorRef)
|
||||||
.build())
|
.build())
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
if (professor.getSkills() == null) {
|
professor.getSkills().addAll(newSkills);
|
||||||
professor.setSkills(new HashSet<>());
|
|
||||||
}
|
|
||||||
professor.getSkills().addAll(skills);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update awards - clear existing and add new ones
|
if (professor.getAwards() == null) professor.setAwards(new HashSet<>());
|
||||||
if (professor.getAwards() != null) {
|
professor.getAwards().clear();
|
||||||
professor.getAwards().clear();
|
|
||||||
}
|
|
||||||
if (professorDto.getAwards() != null && !professorDto.getAwards().isEmpty()) {
|
if (professorDto.getAwards() != null && !professorDto.getAwards().isEmpty()) {
|
||||||
Set<ProfessorAward> awards = professorDto.getAwards().stream()
|
Set<ProfessorAward> newAwards = professorDto.getAwards().stream()
|
||||||
.filter(awardDto -> awardDto.getTitle() != null && !awardDto.getTitle().trim().isEmpty())
|
.filter(awardDto -> awardDto.getTitle() != null && !awardDto.getTitle().trim().isEmpty())
|
||||||
.map(awardDto -> ProfessorAward.builder()
|
.map(awardDto -> ProfessorAward.builder()
|
||||||
.title(awardDto.getTitle().trim())
|
.title(awardDto.getTitle().trim())
|
||||||
@ -282,15 +278,11 @@ public class ProfessorServiceImpl implements ProfessorService {
|
|||||||
.professor(professorRef)
|
.professor(professorRef)
|
||||||
.build())
|
.build())
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
if (professor.getAwards() == null) {
|
professor.getAwards().addAll(newAwards);
|
||||||
professor.setAwards(new HashSet<>());
|
|
||||||
}
|
|
||||||
professor.getAwards().addAll(awards);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Professor savedProfessor = professorRepository.save(professor);
|
Professor savedProfessor = professorRepository.save(professor);
|
||||||
|
|
||||||
// Handle profile image if provided
|
|
||||||
if (professorDto.getProfileImage() != null) {
|
if (professorDto.getProfileImage() != null) {
|
||||||
saveProfileImage(savedProfessor, professorDto.getProfileImage());
|
saveProfileImage(savedProfessor, professorDto.getProfileImage());
|
||||||
}
|
}
|
||||||
@ -337,21 +329,18 @@ public class ProfessorServiceImpl implements ProfessorService {
|
|||||||
return responseEntity.getBody();
|
return responseEntity.getBody();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateNewEmail(String email) {
|
/**
|
||||||
if (professorRepository.existsByEmail(email)) {
|
* Persists a new displayOrder for every professor in the given list.
|
||||||
throw new EmailExistsException(String.format(EMAIL_EXISTS_MSG, email));
|
* The list position (index) becomes the professor's displayOrder value.
|
||||||
}
|
*
|
||||||
}
|
* @param orderedIds UUIDs in desired display order; index 0 → displayOrder=0, etc.
|
||||||
|
* @return All professors sorted by their updated displayOrder.
|
||||||
private Professor validateUpdateEmail(UUID professorId, String email) {
|
*/
|
||||||
Objects.requireNonNull(professorId);
|
@Override
|
||||||
|
@Transactional
|
||||||
Professor currentProfessor = findByProfessorId(professorId);
|
public List<Professor> updateDisplayOrder(List<UUID> orderedIds) {
|
||||||
|
IntStream.range(0, orderedIds.size())
|
||||||
if (!Objects.equals(currentProfessor.getEmail(), email) && professorRepository.existsByEmail(email)) {
|
.forEach(i -> professorRepository.updateDisplayOrder(orderedIds.get(i), i));
|
||||||
throw new EmailExistsException(String.format(EMAIL_EXISTS_MSG, email));
|
return professorRepository.findAllOrderedByDisplayOrder();
|
||||||
}
|
|
||||||
|
|
||||||
return currentProfessor;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package net.shyshkin.study.fullstack.supportportal.backend.service;
|
||||||
|
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.Publication;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface PublicationService {
|
||||||
|
|
||||||
|
Publication addPublication(String title, String authors, Integer year, String journal,
|
||||||
|
String doi, String category, String abstractText,
|
||||||
|
String publicationDate, String keywords, Integer displayOrder);
|
||||||
|
|
||||||
|
Publication updatePublication(Long id, String title, String authors, Integer year,
|
||||||
|
String journal, String doi, String category,
|
||||||
|
String abstractText, String publicationDate,
|
||||||
|
String keywords, Integer displayOrder);
|
||||||
|
|
||||||
|
List<Publication> getActivePublications();
|
||||||
|
|
||||||
|
Publication getPublicationById(Long id);
|
||||||
|
|
||||||
|
List<Publication> getAllPublications();
|
||||||
|
|
||||||
|
List<Publication> getPublicationsByCategory(String category);
|
||||||
|
|
||||||
|
List<Publication> getPublicationsByYear(Integer year);
|
||||||
|
|
||||||
|
void deletePublication(Long id);
|
||||||
|
|
||||||
|
Publication toggleActiveStatus(Long id);
|
||||||
|
|
||||||
|
void reorderPublications(List<Long> orderedIds);
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package net.shyshkin.study.fullstack.supportportal.backend.service;
|
||||||
|
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.ServiceTile;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.enumeration.ServiceTileCategory;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface ServiceTileService {
|
||||||
|
|
||||||
|
ServiceTile addServiceTile(String title, String description, ServiceTileCategory category, Integer displayOrder);
|
||||||
|
|
||||||
|
ServiceTile updateServiceTile(Long id, String title, String description, ServiceTileCategory category, 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,129 @@
|
|||||||
|
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.Publication;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.exception.domain.PublicationNotFoundException;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.repository.PublicationRepository;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.service.PublicationService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional
|
||||||
|
public class PublicationServiceImpl implements PublicationService {
|
||||||
|
|
||||||
|
private final PublicationRepository publicationRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Publication addPublication(String title, String authors, Integer year, String journal,
|
||||||
|
String doi, String category, String abstractText,
|
||||||
|
String publicationDate, String keywords, Integer displayOrder) {
|
||||||
|
log.info("Adding new publication: {}", title);
|
||||||
|
|
||||||
|
Publication publication = new Publication();
|
||||||
|
publication.setTitle(title);
|
||||||
|
publication.setAuthors(authors);
|
||||||
|
publication.setYear(year);
|
||||||
|
publication.setJournal(journal);
|
||||||
|
publication.setDoi(doi);
|
||||||
|
publication.setCategory(category);
|
||||||
|
publication.setAbstractText(abstractText);
|
||||||
|
publication.setPublicationDate(publicationDate);
|
||||||
|
publication.setKeywords(keywords);
|
||||||
|
publication.setDisplayOrder(displayOrder != null ? displayOrder : 0);
|
||||||
|
publication.setActive(true);
|
||||||
|
|
||||||
|
return publicationRepository.save(publication);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Publication updatePublication(Long id, String title, String authors, Integer year,
|
||||||
|
String journal, String doi, String category,
|
||||||
|
String abstractText, String publicationDate,
|
||||||
|
String keywords, Integer displayOrder) {
|
||||||
|
log.info("Updating publication with id: {}", id);
|
||||||
|
|
||||||
|
Publication publication = getPublicationById(id);
|
||||||
|
publication.setTitle(title);
|
||||||
|
publication.setAuthors(authors);
|
||||||
|
publication.setYear(year);
|
||||||
|
publication.setJournal(journal);
|
||||||
|
publication.setDoi(doi);
|
||||||
|
publication.setCategory(category);
|
||||||
|
publication.setAbstractText(abstractText);
|
||||||
|
publication.setPublicationDate(publicationDate);
|
||||||
|
publication.setKeywords(keywords);
|
||||||
|
|
||||||
|
if (displayOrder != null) {
|
||||||
|
publication.setDisplayOrder(displayOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return publicationRepository.save(publication);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Publication> getActivePublications() {
|
||||||
|
return publicationRepository.findByIsActiveTrueOrderByYearDesc();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Publication getPublicationById(Long id) {
|
||||||
|
return publicationRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new PublicationNotFoundException("Publication not found with id: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Publication> getAllPublications() {
|
||||||
|
return publicationRepository.findAllOrderByYearDesc();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Publication> getPublicationsByCategory(String category) {
|
||||||
|
return publicationRepository.findByCategory(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Publication> getPublicationsByYear(Integer year) {
|
||||||
|
return publicationRepository.findByYear(year);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deletePublication(Long id) {
|
||||||
|
log.info("Deleting publication with id: {}", id);
|
||||||
|
|
||||||
|
Publication publication = getPublicationById(id);
|
||||||
|
publicationRepository.delete(publication);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Publication toggleActiveStatus(Long id) {
|
||||||
|
log.info("Toggling active status for publication with id: {}", id);
|
||||||
|
|
||||||
|
Publication publication = getPublicationById(id);
|
||||||
|
publication.setActive(!publication.isActive());
|
||||||
|
|
||||||
|
return publicationRepository.save(publication);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void reorderPublications(List<Long> orderedIds) {
|
||||||
|
log.info("Reordering {} publications", orderedIds.size());
|
||||||
|
|
||||||
|
for (int i = 0; i < orderedIds.size(); i++) {
|
||||||
|
Long id = orderedIds.get(i);
|
||||||
|
Publication publication = getPublicationById(id);
|
||||||
|
publication.setDisplayOrder(i);
|
||||||
|
publicationRepository.save(publication);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
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.enumeration.ServiceTileCategory;
|
||||||
|
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, ServiceTileCategory category, Integer displayOrder) {
|
||||||
|
log.info("Adding new service tile with title: {} and category: {}", title, category);
|
||||||
|
|
||||||
|
ServiceTile serviceTile = new ServiceTile();
|
||||||
|
serviceTile.setTitle(title);
|
||||||
|
serviceTile.setDescription(description);
|
||||||
|
serviceTile.setCategory(category);
|
||||||
|
serviceTile.setDisplayOrder(displayOrder != null ? displayOrder : 0);
|
||||||
|
serviceTile.setActive(true);
|
||||||
|
|
||||||
|
return serviceTileRepository.save(serviceTile);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ServiceTile updateServiceTile(Long id, String title, String description, ServiceTileCategory category, Integer displayOrder) {
|
||||||
|
log.info("Updating service tile with id: {}", id);
|
||||||
|
|
||||||
|
ServiceTile serviceTile = getServiceTileById(id);
|
||||||
|
serviceTile.setTitle(title);
|
||||||
|
serviceTile.setDescription(description);
|
||||||
|
serviceTile.setCategory(category);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
spring:
|
spring:
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:mysql://localhost:3306/support_portal?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
|
url: jdbc:mysql://127.0.0.1:3306/support_portal?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
|
||||||
username: root
|
username: root
|
||||||
password: root
|
password: root
|
||||||
|
|
||||||
|
|||||||
@ -51,9 +51,9 @@ file:
|
|||||||
app:
|
app:
|
||||||
base-url: ${APP_BASE_URL:http://localhost:8080}
|
base-url: ${APP_BASE_URL:http://localhost:8080}
|
||||||
# Fixed public URLs with correct wildcard patterns
|
# 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/**,/publications/active/**,/publications/*/**,/publications/category/**,/publications/year/**
|
||||||
cors:
|
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:
|
jwt:
|
||||||
secret: custom_text
|
secret: custom_text
|
||||||
|
|
||||||
@ -69,7 +69,7 @@ file:
|
|||||||
app:
|
app:
|
||||||
base-url: https://cmcbackend.rootxwire.com
|
base-url: https://cmcbackend.rootxwire.com
|
||||||
cors:
|
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
|
# Development file upload configuration with custom directory
|
||||||
|
|||||||
@ -31,14 +31,9 @@
|
|||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.css"
|
"src/styles.css"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
],
|
],
|
||||||
"scripts": [
|
"scripts": [
|
||||||
"node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
|
"node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
|
||||||
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
@ -51,8 +46,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "6kb",
|
"maximumWarning": "10kb",
|
||||||
"maximumError": "15kb"
|
"maximumError": "20kb"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fileReplacements": [
|
"fileReplacements": [
|
||||||
@ -138,4 +133,4 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultProject": "support-portal-frontend"
|
"defaultProject": "support-portal-frontend"
|
||||||
}
|
}
|
||||||
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,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "~12.2.0",
|
"@angular/animations": "~12.2.0",
|
||||||
|
"@angular/cdk": "^12.2.13",
|
||||||
"@angular/common": "~12.2.0",
|
"@angular/common": "~12.2.0",
|
||||||
"@angular/compiler": "~12.2.0",
|
"@angular/compiler": "~12.2.0",
|
||||||
"@angular/core": "~12.2.0",
|
"@angular/core": "~12.2.0",
|
||||||
@ -20,6 +21,7 @@
|
|||||||
"@angular/router": "~12.2.0",
|
"@angular/router": "~12.2.0",
|
||||||
"@auth0/angular-jwt": "^3.0.1",
|
"@auth0/angular-jwt": "^3.0.1",
|
||||||
"@josipv/angular-editor-k2": "^2.20.0",
|
"@josipv/angular-editor-k2": "^2.20.0",
|
||||||
|
"@popperjs/core": "^2.11.8",
|
||||||
"angular-notifier": "^10.0.0",
|
"angular-notifier": "^10.0.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"ng-particles": "^2.1.11",
|
"ng-particles": "^2.1.11",
|
||||||
@ -37,7 +39,7 @@
|
|||||||
"@angular/compiler-cli": "~12.2.0",
|
"@angular/compiler-cli": "~12.2.0",
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
"@types/jasmine": "~3.8.0",
|
"@types/jasmine": "~3.8.0",
|
||||||
"@types/node": "^12.11.1",
|
"@types/node": "^12.20.55",
|
||||||
"jasmine-core": "~3.8.0",
|
"jasmine-core": "~3.8.0",
|
||||||
"karma": "~6.3.0",
|
"karma": "~6.3.0",
|
||||||
"karma-chrome-launcher": "~3.1.0",
|
"karma-chrome-launcher": "~3.1.0",
|
||||||
|
|||||||
@ -23,6 +23,9 @@ import { MilestoneListComponent } from '../component/milestone/milestone-list/mi
|
|||||||
import { MilestoneFormComponent } from '../component/milestone/milestone-form/milestone-form.component';
|
import { MilestoneFormComponent } from '../component/milestone/milestone-form/milestone-form.component';
|
||||||
import { TestimonialFormComponent } from '../component/testimonial/testimonial-form/testimonial-form.component';
|
import { TestimonialFormComponent } from '../component/testimonial/testimonial-form/testimonial-form.component';
|
||||||
import { TestimonialListComponent } from '../component/testimonial/testimonial-list/testimonial-list.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 { PublicationsComponent } from '../component/publications/publications.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: '', component: HomeComponent },
|
{ path: '', component: HomeComponent },
|
||||||
@ -40,7 +43,9 @@ const routes: Routes = [
|
|||||||
{ path: 'education', component: EducationComponent, canActivate: [AuthenticationGuard] },
|
{ path: 'education', component: EducationComponent, canActivate: [AuthenticationGuard] },
|
||||||
{ path: 'userManagement', component: UserComponent, canActivate: [AuthenticationGuard] },
|
{ path: 'userManagement', component: UserComponent, canActivate: [AuthenticationGuard] },
|
||||||
{ path: 'professorManagement', component: ProfessorComponent, canActivate: [AuthenticationGuard] },
|
{ path: 'professorManagement', component: ProfessorComponent, canActivate: [AuthenticationGuard] },
|
||||||
|
{ path: 'heroImage', component: HeroImageComponent, canActivate: [AuthenticationGuard] },
|
||||||
|
{ path: 'serviceTiles', component: ServiceTileComponent, canActivate: [AuthenticationGuard] },
|
||||||
|
{ path: 'publications', component: PublicationsComponent, canActivate: [AuthenticationGuard] }, // ← ADD THIS
|
||||||
{ path: 'milestone/list', component: MilestoneListComponent, canActivate: [AuthenticationGuard] },
|
{ path: 'milestone/list', component: MilestoneListComponent, canActivate: [AuthenticationGuard] },
|
||||||
{ path: 'milestone/create', component: MilestoneFormComponent, canActivate: [AuthenticationGuard] },
|
{ path: 'milestone/create', component: MilestoneFormComponent, canActivate: [AuthenticationGuard] },
|
||||||
{ path: 'milestone/edit/:id', component: MilestoneFormComponent, canActivate: [AuthenticationGuard] },
|
{ path: 'milestone/edit/:id', component: MilestoneFormComponent, canActivate: [AuthenticationGuard] },
|
||||||
|
|||||||
@ -38,6 +38,9 @@ import { MilestoneFormComponent } from '../component/milestone/milestone-form/mi
|
|||||||
import { MilestoneListComponent } from '../component/milestone/milestone-list/milestone-list.component';
|
import { MilestoneListComponent } from '../component/milestone/milestone-list/milestone-list.component';
|
||||||
import { TestimonialFormComponent } from '../component/testimonial/testimonial-form/testimonial-form.component';
|
import { TestimonialFormComponent } from '../component/testimonial/testimonial-form/testimonial-form.component';
|
||||||
import { TestimonialListComponent } from '../component/testimonial/testimonial-list/testimonial-list.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 { PublicationsComponent } from '../component/publications/publications.component';
|
||||||
// import { PagesModule } from '../pages/pages.module';
|
// import { PagesModule } from '../pages/pages.module';
|
||||||
|
|
||||||
|
|
||||||
@ -68,7 +71,10 @@ import { TestimonialListComponent } from '../component/testimonial/testimonial-l
|
|||||||
MilestoneFormComponent,
|
MilestoneFormComponent,
|
||||||
MilestoneListComponent,
|
MilestoneListComponent,
|
||||||
TestimonialFormComponent,
|
TestimonialFormComponent,
|
||||||
TestimonialListComponent
|
TestimonialListComponent,
|
||||||
|
HeroImageComponent,
|
||||||
|
ServiceTileComponent,
|
||||||
|
PublicationsComponent
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
|||||||
@ -36,6 +36,9 @@ import { EducationComponent } from './component/education/education.component';
|
|||||||
import { MilestoneFormComponent } from './component/milestone/milestone-form/milestone-form.component';
|
import { MilestoneFormComponent } from './component/milestone/milestone-form/milestone-form.component';
|
||||||
import { TestimonialFormComponent } from './component/testimonial/testimonial-form/testimonial-form.component';
|
import { TestimonialFormComponent } from './component/testimonial/testimonial-form/testimonial-form.component';
|
||||||
import { TestimonialListComponent } from './component/testimonial/testimonial-list/testimonial-list.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 { PublicationsComponent } from './component/publications/publications.component';
|
||||||
// import { PagesModule } from './pages/pages.module';
|
// import { PagesModule } from './pages/pages.module';
|
||||||
|
|
||||||
|
|
||||||
@ -45,6 +48,9 @@ import { TestimonialListComponent } from './component/testimonial/testimonial-li
|
|||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
AppComponent,
|
AppComponent,
|
||||||
|
//PublicationsComponent,
|
||||||
|
//ServiceTileComponent,
|
||||||
|
//HeroImageComponent,
|
||||||
//TestimonialFormComponent,
|
//TestimonialFormComponent,
|
||||||
//TestimonialListComponent,
|
//TestimonialListComponent,
|
||||||
//MilestoneListComponent,
|
//MilestoneListComponent,
|
||||||
|
|||||||
@ -430,6 +430,7 @@
|
|||||||
/* Tables */
|
/* Tables */
|
||||||
.table-wrapper {
|
.table-wrapper {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
overflow-y: visible;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
@ -476,6 +477,8 @@
|
|||||||
|
|
||||||
.jobs-table tbody td,
|
.jobs-table tbody td,
|
||||||
.applications-table tbody td {
|
.applications-table tbody td {
|
||||||
|
position: relative; /* Ensure positioning context */
|
||||||
|
overflow: visible;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
@ -710,27 +713,94 @@
|
|||||||
font-size: 13px;
|
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 */
|
||||||
.dropdown {
|
.dropdown {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
position: absolute;
|
position: fixed; /* Changed from absolute to fixed */
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
z-index: 10;
|
z-index: 9999; /* Very high z-index */
|
||||||
display: none;
|
display: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown:hover .dropdown-menu {
|
.dropdown.active .dropdown-menu {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -767,6 +837,19 @@
|
|||||||
color: #dc2626;
|
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 */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
background: white;
|
background: white;
|
||||||
|
|||||||
@ -252,118 +252,136 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Applications View -->
|
<!-- Applications View -->
|
||||||
<div *ngIf="showApplications && selectedJobForApplications" class="applications-container">
|
<!-- Applications View -->
|
||||||
<div class="applications-card">
|
<div *ngIf="showApplications && selectedJobForApplications" class="applications-container">
|
||||||
<div class="applications-header">
|
<div class="applications-card">
|
||||||
<div class="header-info">
|
<div class="applications-header">
|
||||||
<h3>Applications</h3>
|
<div class="header-info">
|
||||||
<p>{{ selectedJobForApplications.title }}</p>
|
<h3>Applications</h3>
|
||||||
</div>
|
<p>{{ selectedJobForApplications.title }}</p>
|
||||||
<span class="application-count">{{ applications.length }} {{ applications.length === 1 ? 'Application' : 'Applications' }}</span>
|
</div>
|
||||||
</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">
|
<div class="applications-body">
|
||||||
<table class="applications-table">
|
<div *ngIf="applications.length > 0" class="table-wrapper">
|
||||||
<thead>
|
<table class="applications-table">
|
||||||
<tr>
|
<thead>
|
||||||
<th>Applicant</th>
|
<tr>
|
||||||
<th>Contact</th>
|
<th>Applicant</th>
|
||||||
<th>Experience</th>
|
<th>Contact</th>
|
||||||
<th>Status</th>
|
<th>Experience</th>
|
||||||
<th>Applied Date</th>
|
<th>Resume</th>
|
||||||
<th>Actions</th>
|
<th>Status</th>
|
||||||
</tr>
|
<th>Applied Date</th>
|
||||||
</thead>
|
<th>Actions</th>
|
||||||
<tbody>
|
</tr>
|
||||||
<tr *ngFor="let application of applications">
|
</thead>
|
||||||
<td>
|
<tbody>
|
||||||
<div class="applicant-info">
|
<tr *ngFor="let application of applications; let i = index">
|
||||||
<div class="applicant-avatar">
|
<td>
|
||||||
<i class="fa fa-user"></i>
|
<div class="applicant-info">
|
||||||
</div>
|
<div class="applicant-avatar">
|
||||||
<span class="applicant-name">{{ application.fullName }}</span>
|
<i class="fa fa-user"></i>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<span class="applicant-name">{{ application.fullName }}</span>
|
||||||
<td>
|
</div>
|
||||||
<div class="contact-info">
|
</td>
|
||||||
<div class="contact-item">
|
<td>
|
||||||
<i class="fa fa-envelope"></i>
|
<div class="contact-info">
|
||||||
<span>{{ application.email }}</span>
|
<div class="contact-item">
|
||||||
</div>
|
<i class="fa fa-envelope"></i>
|
||||||
<div class="contact-item">
|
<span>{{ application.email }}</span>
|
||||||
<i class="fa fa-phone"></i>
|
</div>
|
||||||
<span>{{ application.phone }}</span>
|
<div class="contact-item">
|
||||||
</div>
|
<i class="fa fa-phone"></i>
|
||||||
</div>
|
<span>{{ application.phone }}</span>
|
||||||
</td>
|
</div>
|
||||||
<td>
|
</div>
|
||||||
<span class="experience-badge">{{ application.experience }}</span>
|
</td>
|
||||||
</td>
|
<td>
|
||||||
<td>
|
<span class="experience-badge">{{ application.experience }}</span>
|
||||||
<span class="status-badge" [ngClass]="getStatusBadgeClass(application.status || 'pending')">
|
</td>
|
||||||
{{ application.status || 'PENDING' }}
|
<td>
|
||||||
</span>
|
<div class="resume-actions" *ngIf="hasResume(application)">
|
||||||
</td>
|
<button class="btn-resume btn-view" (click)="viewResume(application)" title="View Resume">
|
||||||
<td>
|
<i class="fa fa-eye"></i>
|
||||||
<span class="date-text">{{ application.createdDate | date:'MMM d, y' }}</span>
|
<span>View</span>
|
||||||
</td>
|
</button>
|
||||||
<td>
|
<button class="btn-resume btn-download" (click)="downloadResume(application)" title="Download Resume">
|
||||||
<div class="action-buttons">
|
<i class="fa fa-download"></i>
|
||||||
<div class="dropdown">
|
<span>Download</span>
|
||||||
<button class="btn-action btn-status">
|
</button>
|
||||||
<i class="fa fa-edit"></i>
|
</div>
|
||||||
<span>Status</span>
|
<div class="no-resume" *ngIf="!hasResume(application)">
|
||||||
<i class="fa fa-chevron-down"></i>
|
<i class="fa fa-file-excel"></i>
|
||||||
</button>
|
<span>No Resume</span>
|
||||||
<div class="dropdown-menu">
|
</div>
|
||||||
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'PENDING')">
|
</td>
|
||||||
<i class="fa fa-clock"></i>
|
<td>
|
||||||
Pending
|
<span class="status-badge" [ngClass]="getStatusBadgeClass(application.status || 'pending')">
|
||||||
</button>
|
{{ application.status || 'PENDING' }}
|
||||||
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'REVIEWED')">
|
</span>
|
||||||
<i class="fa fa-eye"></i>
|
</td>
|
||||||
Reviewed
|
<td>
|
||||||
</button>
|
<span class="date-text">{{ application.createdDate | date:'MMM d, y' }}</span>
|
||||||
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'SHORTLISTED')">
|
</td>
|
||||||
<i class="fa fa-star"></i>
|
<td>
|
||||||
Shortlisted
|
<div class="action-buttons">
|
||||||
</button>
|
<div class="dropdown" [class.active]="activeDropdownIndex === i">
|
||||||
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'INTERVIEWED')">
|
<button class="btn-action btn-status" (click)="toggleDropdown(i, $event)">
|
||||||
<i class="fa fa-comments"></i>
|
<i class="fa fa-edit"></i>
|
||||||
Interviewed
|
<span>Status</span>
|
||||||
</button>
|
<i class="fa fa-chevron-down"></i>
|
||||||
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'HIRED')">
|
</button>
|
||||||
<i class="fa fa-check-circle"></i>
|
<div class="dropdown-menu">
|
||||||
Hired
|
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'PENDING')">
|
||||||
</button>
|
<i class="fa fa-clock"></i>
|
||||||
<button class="dropdown-item item-danger" (click)="updateApplicationStatus(application, 'REJECTED')">
|
Pending
|
||||||
<i class="fa fa-times-circle"></i>
|
</button>
|
||||||
Rejected
|
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'REVIEWED')">
|
||||||
</button>
|
<i class="fa fa-eye"></i>
|
||||||
</div>
|
Reviewed
|
||||||
</div>
|
</button>
|
||||||
<button class="btn-action btn-delete" (click)="deleteApplication(application)">
|
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'SHORTLISTED')">
|
||||||
<i class="fa fa-trash"></i>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
<button class="btn-action btn-delete" (click)="deleteApplication(application)">
|
||||||
</tbody>
|
<i class="fa fa-trash"></i>
|
||||||
</table>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Empty State for Applications -->
|
<!-- Empty State for Applications -->
|
||||||
<div *ngIf="applications.length === 0" class="empty-state">
|
<div *ngIf="applications.length === 0" class="empty-state">
|
||||||
<div class="empty-icon">
|
<div class="empty-icon">
|
||||||
<i class="fa fa-inbox"></i>
|
<i class="fa fa-inbox"></i>
|
||||||
</div>
|
|
||||||
<h3>No applications yet</h3>
|
|
||||||
<p>This job hasn't received any applications</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h3>No applications yet</h3>
|
||||||
|
<p>This job hasn't received any applications</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Jobs List -->
|
<!-- Jobs List -->
|
||||||
<div *ngIf="!showJobForm && !showApplications" class="jobs-list-container">
|
<div *ngIf="!showJobForm && !showApplications" class="jobs-list-container">
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// career.component.ts
|
// career.component.ts
|
||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit, HostListener } from '@angular/core';
|
||||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { CareerService, Job, JobApplication } from 'src/app/service/career.service';
|
import { CareerService, Job, JobApplication } from 'src/app/service/career.service';
|
||||||
|
|
||||||
@ -20,6 +20,9 @@ export class CareerComponent implements OnInit {
|
|||||||
// Filter for applications
|
// Filter for applications
|
||||||
selectedJobForApplications: Job | null = null;
|
selectedJobForApplications: Job | null = null;
|
||||||
|
|
||||||
|
// Dropdown management
|
||||||
|
activeDropdownIndex: number | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private careerService: CareerService,
|
private careerService: CareerService,
|
||||||
private fb: FormBuilder
|
private fb: FormBuilder
|
||||||
@ -34,7 +37,7 @@ export class CareerComponent implements OnInit {
|
|||||||
description: ['', Validators.required],
|
description: ['', Validators.required],
|
||||||
requirements: [''],
|
requirements: [''],
|
||||||
responsibilities: [''],
|
responsibilities: [''],
|
||||||
isActive: [true] // Default to true
|
isActive: [true]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,7 +62,6 @@ export class CareerComponent implements OnInit {
|
|||||||
this.showJobForm = true;
|
this.showJobForm = true;
|
||||||
this.resetJobForm();
|
this.resetJobForm();
|
||||||
|
|
||||||
// Ensure isActive is true for new jobs
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.jobForm.patchValue({
|
this.jobForm.patchValue({
|
||||||
isActive: true
|
isActive: true
|
||||||
@ -84,7 +86,7 @@ export class CareerComponent implements OnInit {
|
|||||||
description: job.description,
|
description: job.description,
|
||||||
requirements: job.requirements ? job.requirements.join(', ') : '',
|
requirements: job.requirements ? job.requirements.join(', ') : '',
|
||||||
responsibilities: job.responsibilities ? job.responsibilities.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.editing = true;
|
||||||
this.showJobForm = true;
|
this.showJobForm = true;
|
||||||
@ -94,18 +96,8 @@ export class CareerComponent implements OnInit {
|
|||||||
if (this.jobForm.valid) {
|
if (this.jobForm.valid) {
|
||||||
const jobData = this.jobForm.value;
|
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;
|
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 = jobData.requirements
|
||||||
? jobData.requirements.split(',').map((req: string) => req.trim()).filter((req: string) => req.length > 0)
|
? 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)
|
? 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) {
|
if (this.editing && this.selectedJob) {
|
||||||
this.careerService.updateJob(this.selectedJob.id!, jobData).subscribe(() => {
|
this.careerService.updateJob(this.selectedJob.id!, jobData).subscribe(() => {
|
||||||
this.loadJobs();
|
this.loadJobs();
|
||||||
@ -142,13 +132,11 @@ export class CareerComponent implements OnInit {
|
|||||||
this.selectedJob = null;
|
this.selectedJob = null;
|
||||||
this.editing = false;
|
this.editing = false;
|
||||||
|
|
||||||
// Explicitly set default values after reset
|
|
||||||
this.jobForm.patchValue({
|
this.jobForm.patchValue({
|
||||||
isActive: true
|
isActive: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Application management
|
|
||||||
viewApplications(job: Job) {
|
viewApplications(job: Job) {
|
||||||
this.selectedJobForApplications = job;
|
this.selectedJobForApplications = job;
|
||||||
this.showApplications = true;
|
this.showApplications = true;
|
||||||
@ -160,12 +148,39 @@ export class CareerComponent implements OnInit {
|
|||||||
hideApplications() {
|
hideApplications() {
|
||||||
this.showApplications = false;
|
this.showApplications = false;
|
||||||
this.selectedJobForApplications = null;
|
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) {
|
updateApplicationStatus(application: JobApplication, status: string) {
|
||||||
this.careerService.updateApplicationStatus(application.id!, status).subscribe(() => {
|
this.careerService.updateApplicationStatus(application.id!, status).subscribe(() => {
|
||||||
// Reload applications for the selected job
|
this.activeDropdownIndex = null;
|
||||||
|
|
||||||
if (this.selectedJobForApplications) {
|
if (this.selectedJobForApplications) {
|
||||||
this.viewApplications(this.selectedJobForApplications);
|
this.viewApplications(this.selectedJobForApplications);
|
||||||
} else {
|
} else {
|
||||||
@ -177,6 +192,8 @@ export class CareerComponent implements OnInit {
|
|||||||
deleteApplication(application: JobApplication) {
|
deleteApplication(application: JobApplication) {
|
||||||
if (confirm('Are you sure you want to delete this application?')) {
|
if (confirm('Are you sure you want to delete this application?')) {
|
||||||
this.careerService.deleteApplication(application.id!).subscribe(() => {
|
this.careerService.deleteApplication(application.id!).subscribe(() => {
|
||||||
|
this.activeDropdownIndex = null;
|
||||||
|
|
||||||
if (this.selectedJobForApplications) {
|
if (this.selectedJobForApplications) {
|
||||||
this.viewApplications(this.selectedJobForApplications);
|
this.viewApplications(this.selectedJobForApplications);
|
||||||
} else {
|
} else {
|
||||||
@ -202,4 +219,62 @@ export class CareerComponent implements OnInit {
|
|||||||
if (!jobId) return 0;
|
if (!jobId) return 0;
|
||||||
return this.applications.filter(app => app.job?.id === jobId).length;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1144,4 +1144,11 @@
|
|||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Required field asterisk */
|
||||||
|
.required-asterisk {
|
||||||
|
color: #e53e3e;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: 2px;
|
||||||
}
|
}
|
||||||
@ -103,7 +103,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="title">
|
<label for="title">
|
||||||
<i class="fa fa-book"></i>
|
<i class="fa fa-book"></i>
|
||||||
Course Title
|
Course Title <span class="required-asterisk">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" id="title" class="form-input" formControlName="title"
|
<input type="text" id="title" class="form-input" formControlName="title"
|
||||||
placeholder="e.g., Advanced Cardiac Care Training">
|
placeholder="e.g., Advanced Cardiac Care Training">
|
||||||
@ -224,10 +224,6 @@
|
|||||||
</label>
|
</label>
|
||||||
<textarea id="description" class="form-textarea" formControlName="description" rows="4"
|
<textarea id="description" class="form-textarea" formControlName="description" rows="4"
|
||||||
placeholder="Describe the course objectives, curriculum, and what students will learn..."></textarea>
|
placeholder="Describe the course objectives, curriculum, and what students will learn..."></textarea>
|
||||||
<div *ngIf="courseForm.get('description')?.invalid && courseForm.get('description')?.touched" class="error-message">
|
|
||||||
<i class="fa fa-exclamation-circle"></i>
|
|
||||||
Course description is required.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@ -341,7 +337,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="eventTitle">
|
<label for="eventTitle">
|
||||||
<i class="fa fa-heading"></i>
|
<i class="fa fa-heading"></i>
|
||||||
Event Title
|
Event Title <span class="required-asterisk">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" id="eventTitle" class="form-input" formControlName="title"
|
<input type="text" id="eventTitle" class="form-input" formControlName="title"
|
||||||
placeholder="Enter event title">
|
placeholder="Enter event title">
|
||||||
@ -358,10 +354,6 @@
|
|||||||
</label>
|
</label>
|
||||||
<textarea id="eventDescription" class="form-textarea" formControlName="description" rows="4"
|
<textarea id="eventDescription" class="form-textarea" formControlName="description" rows="4"
|
||||||
placeholder="Describe the event details, objectives, and what attendees can expect..."></textarea>
|
placeholder="Describe the event details, objectives, and what attendees can expect..."></textarea>
|
||||||
<div *ngIf="upcomingEventForm.get('description')?.invalid && upcomingEventForm.get('description')?.touched" class="error-message">
|
|
||||||
<i class="fa fa-exclamation-circle"></i>
|
|
||||||
Event description is required.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
@ -43,12 +43,12 @@ export class EducationComponent implements OnInit {
|
|||||||
) {
|
) {
|
||||||
this.courseForm = this.fb.group({
|
this.courseForm = this.fb.group({
|
||||||
title: ['', Validators.required],
|
title: ['', Validators.required],
|
||||||
description: ['', Validators.required],
|
description: [''],
|
||||||
duration: ['', Validators.required],
|
duration: [''],
|
||||||
seats: ['', [Validators.required, Validators.min(1)]],
|
seats: [''],
|
||||||
category: ['', Validators.required],
|
category: [''],
|
||||||
level: ['', Validators.required],
|
level: [''],
|
||||||
instructor: ['', Validators.required],
|
instructor: [''],
|
||||||
price: [''],
|
price: [''],
|
||||||
startDate: [''],
|
startDate: [''],
|
||||||
eligibility: [''],
|
eligibility: [''],
|
||||||
@ -58,8 +58,8 @@ export class EducationComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
this.upcomingEventForm = this.fb.group({
|
this.upcomingEventForm = this.fb.group({
|
||||||
title: ['', Validators.required],
|
title: ['', Validators.required],
|
||||||
description: ['', Validators.required],
|
description: [''],
|
||||||
schedule: ['', Validators.required],
|
schedule: [''],
|
||||||
eventDate: [''],
|
eventDate: [''],
|
||||||
isActive: [true]
|
isActive: [true]
|
||||||
});
|
});
|
||||||
@ -381,5 +381,4 @@ export class EducationComponent implements OnInit {
|
|||||||
getApplicationCount(courseId?: number): number {
|
getApplicationCount(courseId?: number): number {
|
||||||
if (!courseId) return 0;
|
if (!courseId) return 0;
|
||||||
return this.applications.filter(app => app.course?.id === courseId).length;
|
return this.applications.filter(app => app.course?.id === courseId).length;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
@ -28,7 +28,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="code">
|
<label for="code">
|
||||||
<i class="bi bi-upc"></i>
|
<i class="bi bi-upc"></i>
|
||||||
Event Code *
|
Event Code
|
||||||
</label>
|
</label>
|
||||||
<input id="code" formControlName="code" class="form-input" placeholder="Enter event code">
|
<input id="code" formControlName="code" class="form-input" placeholder="Enter event code">
|
||||||
</div>
|
</div>
|
||||||
@ -119,10 +119,10 @@
|
|||||||
<input id="email" formControlName="email" class="form-input" placeholder="contact@example.com">
|
<input id="email" formControlName="email" class="form-input" placeholder="contact@example.com">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- NEW FIELD: Book Seat Link -->
|
<!-- Registration/Booking Link -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="bookSeatLink">
|
<label for="bookSeatLink">
|
||||||
<i class="bi bi-link-45deg"></i>
|
<i class="bi bi-ticket-perforated"></i>
|
||||||
Book Seat Registration Link
|
Book Seat Registration Link
|
||||||
</label>
|
</label>
|
||||||
<input id="bookSeatLink"
|
<input id="bookSeatLink"
|
||||||
@ -132,6 +132,19 @@
|
|||||||
<p class="form-hint">Enter the URL where users can register/book seats for this event</p>
|
<p class="form-hint">Enter the URL where users can register/book seats for this event</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Learn More Link -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="learnMoreLink">
|
||||||
|
<i class="bi bi-info-circle"></i>
|
||||||
|
Learn More Information Link
|
||||||
|
</label>
|
||||||
|
<input id="learnMoreLink"
|
||||||
|
formControlName="learnMoreLink"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="https://example.com/event-details">
|
||||||
|
<p class="form-hint">Enter the URL for additional event information (optional)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="checkbox-wrapper">
|
<div class="checkbox-wrapper">
|
||||||
<input id="isActive" type="checkbox" formControlName="isActive" class="checkbox-input">
|
<input id="isActive" type="checkbox" formControlName="isActive" class="checkbox-input">
|
||||||
@ -196,7 +209,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div formArrayName="highlights">
|
<div formArrayName="highlights">
|
||||||
<div *ngFor="let highlight of highlights.controls; let i = index" class="simple-array-item">
|
<div *ngFor="let highlight of highlights.controls; let i = index" class="simple-array-item">
|
||||||
<input [formControlName]="i" class="form-input" placeholder="Highlight">
|
<input [formControlName]="i" class="form-input" placeholder="Event highlight">
|
||||||
<button type="button" class="btn-remove-inline" (click)="removeHighlight(i)">
|
<button type="button" class="btn-remove-inline" (click)="removeHighlight(i)">
|
||||||
<i class="bi bi-x"></i>
|
<i class="bi bi-x"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -212,7 +225,7 @@
|
|||||||
|
|
||||||
<!-- Right Column -->
|
<!-- Right Column -->
|
||||||
<div class="form-column">
|
<div class="form-column">
|
||||||
<!-- Main Image Section -->
|
<!-- Main Event Image Section -->
|
||||||
<div class="form-card">
|
<div class="form-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<i class="bi bi-image"></i>
|
<i class="bi bi-image"></i>
|
||||||
@ -305,10 +318,12 @@
|
|||||||
<h4 class="url-title">Or Add Gallery URLs</h4>
|
<h4 class="url-title">Or Add Gallery URLs</h4>
|
||||||
<div formArrayName="galleryImages">
|
<div formArrayName="galleryImages">
|
||||||
<div *ngFor="let image of galleryImages.controls; let i = index" class="gallery-url-item">
|
<div *ngFor="let image of galleryImages.controls; let i = index" class="gallery-url-item">
|
||||||
<input [formControlName]="i" class="form-input" placeholder="https://images.unsplash.com/...">
|
<div>
|
||||||
<button type="button" class="btn-remove-inline" (click)="removeGalleryImage(i)">
|
<input [formControlName]="i" class="form-input" placeholder="https://images.unsplash.com/...">
|
||||||
<i class="bi bi-x"></i>
|
<button type="button" class="btn-remove-inline" (click)="removeGalleryImage(i)">
|
||||||
</button>
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<!-- Thumbnail Preview -->
|
<!-- Thumbnail Preview -->
|
||||||
<div class="gallery-thumbnail" *ngIf="galleryImages.at(i).value">
|
<div class="gallery-thumbnail" *ngIf="galleryImages.at(i).value">
|
||||||
<img [src]="galleryImages.at(i).value" alt="Gallery Preview">
|
<img [src]="galleryImages.at(i).value" alt="Gallery Preview">
|
||||||
|
|||||||
@ -28,7 +28,7 @@ export class EventFormComponent implements OnInit {
|
|||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.eventForm = this.fb.group({
|
this.eventForm = this.fb.group({
|
||||||
code: ['', Validators.required],
|
code: [''],
|
||||||
year: ['', Validators.required],
|
year: ['', Validators.required],
|
||||||
subject: ['', Validators.required],
|
subject: ['', Validators.required],
|
||||||
title: ['', Validators.required],
|
title: ['', Validators.required],
|
||||||
@ -44,7 +44,8 @@ export class EventFormComponent implements OnInit {
|
|||||||
fees: this.fb.array([]), // Form uses 'fees' (plural)
|
fees: this.fb.array([]), // Form uses 'fees' (plural)
|
||||||
phone: ['', Validators.required],
|
phone: ['', Validators.required],
|
||||||
email: ['', [Validators.required, Validators.email]],
|
email: ['', [Validators.required, Validators.email]],
|
||||||
bookSeatLink: [''], // NEW FIELD: Book Seat Link
|
bookSeatLink: [''], // Registration/booking link
|
||||||
|
learnMoreLink: [''], // Additional information link
|
||||||
isActive: [true]
|
isActive: [true]
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -147,7 +148,8 @@ export class EventFormComponent implements OnInit {
|
|||||||
mainImage: event.mainImage,
|
mainImage: event.mainImage,
|
||||||
phone: event.phone,
|
phone: event.phone,
|
||||||
email: event.email,
|
email: event.email,
|
||||||
bookSeatLink: event.bookSeatLink, // NEW FIELD
|
bookSeatLink: event.bookSeatLink,
|
||||||
|
learnMoreLink: event.learnMoreLink,
|
||||||
isActive: event.isActive
|
isActive: event.isActive
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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: #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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -203,6 +203,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card-minimal:hover {
|
.stat-card-minimal:hover {
|
||||||
|
|||||||
@ -69,12 +69,12 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Register Link -->
|
<!-- Register Link -->
|
||||||
<div class="login-footer">
|
<!-- <div class="login-footer">
|
||||||
<p class="footer-text">
|
<p class="footer-text">
|
||||||
Don't have an account?
|
Don't have an account?
|
||||||
<a routerLink="/register" class="footer-link">Sign Up</a>
|
<a routerLink="/register" class="footer-link">Sign Up</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -26,8 +26,7 @@
|
|||||||
<ul class="nav-list">
|
<ul class="nav-list">
|
||||||
<!-- Home -->
|
<!-- Home -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" routerLink="/dashboard/home" routerLinkActive="active"
|
<a class="nav-link" routerLink="/dashboard/home" routerLinkActive="active" (click)="changeTitle('Home')">
|
||||||
(click)="changeTitle('Home')">
|
|
||||||
<span class="nav-icon">
|
<span class="nav-icon">
|
||||||
<i class="fa fa-home"></i>
|
<i class="fa fa-home"></i>
|
||||||
</span>
|
</span>
|
||||||
@ -38,7 +37,7 @@
|
|||||||
<!-- Users -->
|
<!-- Users -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" routerLink="/dashboard/userManagement" routerLinkActive="active"
|
<a class="nav-link" routerLink="/dashboard/userManagement" routerLinkActive="active"
|
||||||
(click)="changeTitle('Users')">
|
(click)="changeTitle('Users')">
|
||||||
<span class="nav-icon">
|
<span class="nav-icon">
|
||||||
<i class="fa fa-users"></i>
|
<i class="fa fa-users"></i>
|
||||||
</span>
|
</span>
|
||||||
@ -49,7 +48,7 @@
|
|||||||
<!-- Professors -->
|
<!-- Professors -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" routerLink="/dashboard/professorManagement" routerLinkActive="active"
|
<a class="nav-link" routerLink="/dashboard/professorManagement" routerLinkActive="active"
|
||||||
(click)="changeTitle('Professors')">
|
(click)="changeTitle('Professors')">
|
||||||
<span class="nav-icon">
|
<span class="nav-icon">
|
||||||
<i class="fa fa-chalkboard-teacher"></i>
|
<i class="fa fa-chalkboard-teacher"></i>
|
||||||
</span>
|
</span>
|
||||||
@ -64,8 +63,7 @@
|
|||||||
|
|
||||||
<!-- Blogs -->
|
<!-- Blogs -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" routerLink="/dashboard/blogs" routerLinkActive="active"
|
<a class="nav-link" routerLink="/dashboard/blogs" routerLinkActive="active" (click)="changeTitle('Blogs')">
|
||||||
(click)="changeTitle('Blogs')">
|
|
||||||
<span class="nav-icon">
|
<span class="nav-icon">
|
||||||
<i class="fa fa-blog"></i>
|
<i class="fa fa-blog"></i>
|
||||||
</span>
|
</span>
|
||||||
@ -73,10 +71,42 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</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>
|
||||||
|
|
||||||
|
<!-- Publications -->
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" routerLink="/dashboard/publications" routerLinkActive="active"
|
||||||
|
(click)="changeTitle('Publications')">
|
||||||
|
<span class="nav-icon">
|
||||||
|
<i class="fa fa-book-open"></i>
|
||||||
|
</span>
|
||||||
|
<span class="nav-text">Publications</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
<!-- Events -->
|
<!-- Events -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" routerLink="/dashboard/events" routerLinkActive="active"
|
<a class="nav-link" routerLink="/dashboard/events" routerLinkActive="active" (click)="changeTitle('Events')">
|
||||||
(click)="changeTitle('Events')">
|
|
||||||
<span class="nav-icon">
|
<span class="nav-icon">
|
||||||
<i class="fa fa-calendar"></i>
|
<i class="fa fa-calendar"></i>
|
||||||
</span>
|
</span>
|
||||||
@ -87,7 +117,7 @@
|
|||||||
<!-- Milestones -->
|
<!-- Milestones -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" routerLink="/dashboard/milestone/list" routerLinkActive="active"
|
<a class="nav-link" routerLink="/dashboard/milestone/list" routerLinkActive="active"
|
||||||
(click)="changeTitle('Milestones')">
|
(click)="changeTitle('Milestones')">
|
||||||
<span class="nav-icon">
|
<span class="nav-icon">
|
||||||
<i class="fa fa-flag-checkered"></i>
|
<i class="fa fa-flag-checkered"></i>
|
||||||
</span>
|
</span>
|
||||||
@ -98,7 +128,7 @@
|
|||||||
<!-- Testimonials -->
|
<!-- Testimonials -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" routerLink="/dashboard/testimonial/list" routerLinkActive="active"
|
<a class="nav-link" routerLink="/dashboard/testimonial/list" routerLinkActive="active"
|
||||||
(click)="changeTitle('Testimonials')">
|
(click)="changeTitle('Testimonials')">
|
||||||
<span class="nav-icon">
|
<span class="nav-icon">
|
||||||
<i class="fa fa-star"></i>
|
<i class="fa fa-star"></i>
|
||||||
</span>
|
</span>
|
||||||
@ -114,7 +144,7 @@
|
|||||||
<!-- Education -->
|
<!-- Education -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" routerLink="/dashboard/education" routerLinkActive="active"
|
<a class="nav-link" routerLink="/dashboard/education" routerLinkActive="active"
|
||||||
(click)="changeTitle('Education')">
|
(click)="changeTitle('Education')">
|
||||||
<span class="nav-icon">
|
<span class="nav-icon">
|
||||||
<i class="fa fa-graduation-cap"></i>
|
<i class="fa fa-graduation-cap"></i>
|
||||||
</span>
|
</span>
|
||||||
@ -124,8 +154,7 @@
|
|||||||
|
|
||||||
<!-- Careers -->
|
<!-- Careers -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" routerLink="/dashboard/career" routerLinkActive="active"
|
<a class="nav-link" routerLink="/dashboard/career" routerLinkActive="active" (click)="changeTitle('Careers')">
|
||||||
(click)="changeTitle('Careers')">
|
|
||||||
<span class="nav-icon">
|
<span class="nav-icon">
|
||||||
<i class="fa fa-briefcase"></i>
|
<i class="fa fa-briefcase"></i>
|
||||||
</span>
|
</span>
|
||||||
@ -141,7 +170,7 @@
|
|||||||
<!-- Settings -->
|
<!-- Settings -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" routerLink="/dashboard/settings" routerLinkActive="active"
|
<a class="nav-link" routerLink="/dashboard/settings" routerLinkActive="active"
|
||||||
(click)="changeTitle('Settings')">
|
(click)="changeTitle('Settings')">
|
||||||
<span class="nav-icon">
|
<span class="nav-icon">
|
||||||
<i class="fa fa-cogs"></i>
|
<i class="fa fa-cogs"></i>
|
||||||
</span>
|
</span>
|
||||||
@ -151,8 +180,7 @@
|
|||||||
|
|
||||||
<!-- Profile -->
|
<!-- Profile -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" routerLink="/dashboard/profile" routerLinkActive="active"
|
<a class="nav-link" routerLink="/dashboard/profile" routerLinkActive="active" (click)="changeTitle('Profile')">
|
||||||
(click)="changeTitle('Profile')">
|
|
||||||
<span class="nav-icon">
|
<span class="nav-icon">
|
||||||
<i class="fa fa-user"></i>
|
<i class="fa fa-user"></i>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,5 @@
|
|||||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { Modal } from 'bootstrap';
|
||||||
import { BehaviorSubject } from 'rxjs';
|
import { BehaviorSubject } from 'rxjs';
|
||||||
import { Professor } from '../../model/Professor';
|
import { Professor } from '../../model/Professor';
|
||||||
import { NotificationService } from '../../service/notification.service';
|
import { NotificationService } from '../../service/notification.service';
|
||||||
@ -24,6 +25,12 @@ interface Award {
|
|||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Skill {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-professor',
|
selector: 'app-professor',
|
||||||
templateUrl: './professor.component.html',
|
templateUrl: './professor.component.html',
|
||||||
@ -41,7 +48,17 @@ export class ProfessorComponent implements OnInit, OnDestroy {
|
|||||||
public loggedInProfessor: Professor;
|
public loggedInProfessor: Professor;
|
||||||
public refreshing: boolean;
|
public refreshing: boolean;
|
||||||
private subs = new SubSink();
|
private subs = new SubSink();
|
||||||
|
|
||||||
|
// ── Drag-and-drop reordering state ────────────────────────────────────────
|
||||||
|
/** Whether the user has switched to "reorder mode" */
|
||||||
|
public reorderMode: boolean = false;
|
||||||
|
/** Index of the row currently being dragged */
|
||||||
|
public dragIndex: number | null = null;
|
||||||
|
/** Visual index of the row the drag is over */
|
||||||
|
public dragOverIndex: number | null = null;
|
||||||
|
/** True while saving the new order to the backend */
|
||||||
|
public savingOrder: boolean = false;
|
||||||
|
|
||||||
selectedProfessor: Professor = {
|
selectedProfessor: Professor = {
|
||||||
professorId: '',
|
professorId: '',
|
||||||
firstName: '',
|
firstName: '',
|
||||||
@ -62,92 +79,209 @@ export class ProfessorComponent implements OnInit, OnDestroy {
|
|||||||
description: '',
|
description: '',
|
||||||
designation: '',
|
designation: '',
|
||||||
workDays: [],
|
workDays: [],
|
||||||
awards: []
|
awards: [],
|
||||||
|
skills: []
|
||||||
};
|
};
|
||||||
|
|
||||||
public profileImageFileName: string | null;
|
public profileImageFileName: string | null;
|
||||||
public profileImage: File | null;
|
public profileImage: File | null;
|
||||||
public fileUploadStatus: FileUploadStatus = new FileUploadStatus();
|
public fileUploadStatus: FileUploadStatus = new FileUploadStatus();
|
||||||
|
|
||||||
// Additional properties for extended functionality
|
|
||||||
public availableDays: string[] = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
public availableDays: string[] = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||||
public selectedWorkDays: { [key: string]: boolean } = {};
|
public selectedWorkDays: { [key: string]: boolean } = {};
|
||||||
|
|
||||||
// Awards management
|
|
||||||
public newProfessorAwards: Award[] = [];
|
public newProfessorAwards: Award[] = [];
|
||||||
public selectedProfessorAwards: Award[] = [];
|
public selectedProfessorAwards: Award[] = [];
|
||||||
|
|
||||||
|
public newProfessorSkills: Skill[] = [];
|
||||||
|
public selectedProfessorSkills: Skill[] = [];
|
||||||
|
|
||||||
|
private closeModal(modalId: string): void {
|
||||||
|
const modalElement = document.getElementById(modalId);
|
||||||
|
if (!modalElement) return;
|
||||||
|
const modalInstance = Modal.getInstance(modalElement) || new Modal(modalElement);
|
||||||
|
modalInstance.hide();
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
document.querySelectorAll('.modal-backdrop').forEach(b => b.remove());
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private professorService: ProfessorService,
|
private professorService: ProfessorService,
|
||||||
private notificationService: NotificationService,
|
private notificationService: NotificationService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private authenticationService: AuthenticationService,
|
private authenticationService: AuthenticationService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.getProfessors(true);
|
this.getProfessors(true);
|
||||||
this.loggedInUser = this.authenticationService.getUserFromLocalStorage();
|
this.loggedInUser = this.authenticationService.getUserFromLocalStorage();
|
||||||
this.initializeAwards();
|
this.initializeCollections();
|
||||||
|
this.setupModalEventListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.subs.unsubscribe();
|
this.subs.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
private initializeAwards(): void {
|
// ── Collection helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private initializeCollections(): void {
|
||||||
this.newProfessorAwards = [];
|
this.newProfessorAwards = [];
|
||||||
this.selectedProfessorAwards = [];
|
this.selectedProfessorAwards = [];
|
||||||
|
this.newProfessorSkills = [];
|
||||||
|
this.selectedProfessorSkills = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Award management methods
|
private setupModalEventListeners(): void {
|
||||||
public addNewAward(): void {
|
const editModal = document.getElementById('editProfessorModal');
|
||||||
this.newProfessorAwards.push({
|
if (editModal) {
|
||||||
title: '',
|
editModal.addEventListener('hidden.bs.modal', () => this.clearEditProfessorData());
|
||||||
year: '',
|
}
|
||||||
description: '',
|
const addModal = document.getElementById('addProfessorModal');
|
||||||
imageUrl: ''
|
if (addModal) {
|
||||||
});
|
addModal.addEventListener('hidden.bs.modal', () => this.clearNewProfessorData());
|
||||||
}
|
|
||||||
|
|
||||||
public removeAward(index: number): void {
|
|
||||||
this.newProfessorAwards.splice(index, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
public addEditAward(): void {
|
|
||||||
this.selectedProfessorAwards.push({
|
|
||||||
title: '',
|
|
||||||
year: '',
|
|
||||||
description: '',
|
|
||||||
imageUrl: ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public removeEditAward(index: number): void {
|
|
||||||
this.selectedProfessorAwards.splice(index, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Category display helper
|
|
||||||
public getCategoryDisplayName(category: string): string {
|
|
||||||
switch (category) {
|
|
||||||
case 'FACULTY':
|
|
||||||
return 'Faculty';
|
|
||||||
case 'SUPPORT_TEAM':
|
|
||||||
return 'Support Team';
|
|
||||||
case 'TRAINEE_FELLOW':
|
|
||||||
return 'Trainee/Fellow';
|
|
||||||
default:
|
|
||||||
return 'Unknown';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleTitleChange(title: string): void {
|
private invalidateVariables(): void {
|
||||||
this.titleSubject.next(title);
|
this.profileImage = null;
|
||||||
|
this.profileImageFileName = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public changeTitle(title: string): void {
|
private clearNewProfessorData(): void {
|
||||||
this.titleSubject.next(title);
|
this.profileImage = null;
|
||||||
|
this.profileImageFileName = null;
|
||||||
|
this.selectedWorkDays = {};
|
||||||
|
this.newProfessorAwards = [];
|
||||||
|
this.newProfessorSkills = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clearEditProfessorData(): void {
|
||||||
|
this.profileImage = null;
|
||||||
|
this.profileImageFileName = null;
|
||||||
|
this.selectedWorkDays = {};
|
||||||
|
this.selectedProfessorAwards = [];
|
||||||
|
this.selectedProfessorSkills = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Award helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public addNewAward(): void { this.newProfessorAwards.push({ title: '', year: '', description: '', imageUrl: '' }); }
|
||||||
|
public removeAward(index: number): void { this.newProfessorAwards.splice(index, 1); }
|
||||||
|
public addEditAward(): void { this.selectedProfessorAwards.push({ title: '', year: '', description: '', imageUrl: '' }); }
|
||||||
|
public removeEditAward(index: number): void { this.selectedProfessorAwards.splice(index, 1); }
|
||||||
|
|
||||||
|
// ── Skill helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public addNewSkill(): void { this.newProfessorSkills.push({ name: '', level: 0 }); }
|
||||||
|
public removeSkill(index: number): void { this.newProfessorSkills.splice(index, 1); }
|
||||||
|
public addEditSkill(): void { this.selectedProfessorSkills.push({ name: '', level: 0 }); }
|
||||||
|
public removeEditSkill(index: number): void { this.selectedProfessorSkills.splice(index, 1); }
|
||||||
|
|
||||||
|
// ── Category helper ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public getCategoryDisplayName(category: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
FACULTY: 'Faculty',
|
||||||
|
SUPPORT_TEAM: 'Support Team',
|
||||||
|
TRAINEE_FELLOW: 'Trainee/Fellow',
|
||||||
|
RESIGNED: 'Resigned',
|
||||||
|
GUIDES: 'Guides',
|
||||||
|
FRIENDS: 'Friends',
|
||||||
|
PATRONS: 'Patrons',
|
||||||
|
};
|
||||||
|
return map[category] ?? category ?? 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly NO_IMAGE_CATEGORIES = ['TRAINEE_FELLOW', 'SUPPORT_TEAM', 'GUIDES', 'FRIENDS', 'PATRONS'];
|
||||||
|
|
||||||
|
public isImageUploadDisabled(category: string): boolean {
|
||||||
|
return this.NO_IMAGE_CATEGORIES.includes(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drag-and-drop reordering ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Toggle between normal view and drag-and-drop reorder mode. */
|
||||||
|
public toggleReorderMode(): void {
|
||||||
|
this.reorderMode = !this.reorderMode;
|
||||||
|
if (!this.reorderMode) {
|
||||||
|
this.dragIndex = null;
|
||||||
|
this.dragOverIndex = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDragStart(index: number): void {
|
||||||
|
this.dragIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDragOver(event: DragEvent, index: number): void {
|
||||||
|
event.preventDefault(); // Required to allow drop
|
||||||
|
this.dragOverIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDragLeave(): void {
|
||||||
|
// Keep dragOverIndex so the visual indicator stays while hovering over child elements
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDrop(event: DragEvent, dropIndex: number): void {
|
||||||
|
event.preventDefault();
|
||||||
|
if (this.dragIndex === null || this.dragIndex === dropIndex) {
|
||||||
|
this.dragIndex = null;
|
||||||
|
this.dragOverIndex = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder the local array
|
||||||
|
const reordered = [...this.professors];
|
||||||
|
const [moved] = reordered.splice(this.dragIndex, 1);
|
||||||
|
reordered.splice(dropIndex, 0, moved);
|
||||||
|
this.professors = reordered;
|
||||||
|
|
||||||
|
this.dragIndex = null;
|
||||||
|
this.dragOverIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDragEnd(): void {
|
||||||
|
this.dragIndex = null;
|
||||||
|
this.dragOverIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the current visual order to the backend.
|
||||||
|
* Sends professor UUIDs in the current array order.
|
||||||
|
*/
|
||||||
|
public saveDisplayOrder(): void {
|
||||||
|
this.savingOrder = true;
|
||||||
|
const orderedIds = this.professors.map(p => p.professorId);
|
||||||
|
|
||||||
|
this.subs.sink = this.professorService.updateDisplayOrder(orderedIds).subscribe(
|
||||||
|
(updatedProfessors: Professor[]) => {
|
||||||
|
this.professors = updatedProfessors;
|
||||||
|
this.professorService.addProfessorsToLocalStorage(this.professors);
|
||||||
|
this.reorderMode = false;
|
||||||
|
this.savingOrder = false;
|
||||||
|
this.notificationService.notify(NotificationType.SUCCESS, 'Display order saved successfully');
|
||||||
|
},
|
||||||
|
(errorResponse: HttpErrorResponse) => {
|
||||||
|
this.sendErrorNotification(errorResponse.error?.message ?? 'Failed to save order');
|
||||||
|
this.savingOrder = false;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel reorder: restore the last saved order from localStorage. */
|
||||||
|
public cancelReorder(): void {
|
||||||
|
this.professors = this.professorService.getProfessorsFromLocalStorage();
|
||||||
|
this.reorderMode = false;
|
||||||
|
this.dragIndex = null;
|
||||||
|
this.dragOverIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core CRUD ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
handleTitleChange(title: string): void { this.titleSubject.next(title); }
|
||||||
|
public changeTitle(title: string): void { this.titleSubject.next(title); }
|
||||||
|
|
||||||
public getProfessors(showNotification: boolean): void {
|
public getProfessors(showNotification: boolean): void {
|
||||||
this.refreshing = true;
|
this.refreshing = true;
|
||||||
this.subs.sink = this.professorService.getAllProfessors().subscribe(
|
this.subs.sink = this.professorService.getAllProfessors().subscribe(
|
||||||
@ -155,7 +289,8 @@ export class ProfessorComponent implements OnInit, OnDestroy {
|
|||||||
this.professors = professorsPage.content;
|
this.professors = professorsPage.content;
|
||||||
this.professorService.addProfessorsToLocalStorage(this.professors);
|
this.professorService.addProfessorsToLocalStorage(this.professors);
|
||||||
if (showNotification) {
|
if (showNotification) {
|
||||||
this.notificationService.notify(NotificationType.SUCCESS, `${this.professors.length} professors loaded successfully`);
|
this.notificationService.notify(NotificationType.SUCCESS,
|
||||||
|
`${this.professors.length} professors loaded successfully`);
|
||||||
}
|
}
|
||||||
this.refreshing = false;
|
this.refreshing = false;
|
||||||
},
|
},
|
||||||
@ -167,17 +302,21 @@ export class ProfessorComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onSelectProfessor(selectedProfessor: Professor): void {
|
public onSelectProfessor(selectedProfessor: Professor): void {
|
||||||
this.selectedProfessor = selectedProfessor;
|
this.selectedProfessor = JSON.parse(JSON.stringify(selectedProfessor));
|
||||||
this.selectedProfessorAwards = [...(selectedProfessor.awards || [])];
|
|
||||||
|
this.selectedProfessorAwards = selectedProfessor.awards
|
||||||
// Set up work days for viewing
|
? JSON.parse(JSON.stringify(selectedProfessor.awards)) : [];
|
||||||
|
|
||||||
|
this.selectedProfessorSkills = selectedProfessor.skills
|
||||||
|
? JSON.parse(JSON.stringify(selectedProfessor.skills)) : [];
|
||||||
|
|
||||||
this.selectedWorkDays = {};
|
this.selectedWorkDays = {};
|
||||||
if (selectedProfessor.workDays && Array.isArray(selectedProfessor.workDays)) {
|
if (selectedProfessor.workDays && Array.isArray(selectedProfessor.workDays)) {
|
||||||
selectedProfessor.workDays.forEach(day => {
|
selectedProfessor.workDays.forEach(day => {
|
||||||
this.selectedWorkDays[day] = true;
|
if (this.availableDays.includes(day)) this.selectedWorkDays[day] = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.clickButton('openProfessorInfo');
|
this.clickButton('openProfessorInfo');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,19 +328,13 @@ export class ProfessorComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onCategoryChange(category: string): void {
|
public onCategoryChange(category: string): void {
|
||||||
// Clear profile image if Trainee/Fellow is selected
|
if (this.isImageUploadDisabled(category)) {
|
||||||
if (category === 'TRAINEE_FELLOW') {
|
|
||||||
this.profileImage = null;
|
this.profileImage = null;
|
||||||
this.profileImageFileName = null;
|
this.profileImageFileName = null;
|
||||||
// Reset file input
|
|
||||||
const fileInput = document.getElementById('newProfessorProfileImage') as HTMLInputElement;
|
const fileInput = document.getElementById('newProfessorProfileImage') as HTMLInputElement;
|
||||||
if (fileInput) {
|
if (fileInput) fileInput.value = '';
|
||||||
fileInput.value = '';
|
|
||||||
}
|
|
||||||
const editFileInput = document.getElementById('editProfessorProfileImage') as HTMLInputElement;
|
const editFileInput = document.getElementById('editProfessorProfileImage') as HTMLInputElement;
|
||||||
if (editFileInput) {
|
if (editFileInput) editFileInput.value = '';
|
||||||
editFileInput.value = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -217,13 +350,11 @@ export class ProfessorComponent implements OnInit, OnDestroy {
|
|||||||
const formData = this.createExtendedProfessorFormData(professorForm.value, this.profileImage);
|
const formData = this.createExtendedProfessorFormData(professorForm.value, this.profileImage);
|
||||||
this.subs.sink = this.professorService.addProfessor(formData).subscribe(
|
this.subs.sink = this.professorService.addProfessor(formData).subscribe(
|
||||||
(professor: Professor) => {
|
(professor: Professor) => {
|
||||||
this.clickButton('new-professor-close');
|
this.closeModal('addProfessorModal');
|
||||||
this.getProfessors(false);
|
this.getProfessors(false);
|
||||||
this.invalidateVariables();
|
|
||||||
professorForm.reset();
|
professorForm.reset();
|
||||||
this.selectedWorkDays = {};
|
this.notificationService.notify(NotificationType.SUCCESS,
|
||||||
this.newProfessorAwards = [];
|
`Professor ${professor.firstName} added successfully`);
|
||||||
this.notificationService.notify(NotificationType.SUCCESS, `Professor ${professor.firstName} added successfully`);
|
|
||||||
},
|
},
|
||||||
(errorResponse: HttpErrorResponse) => {
|
(errorResponse: HttpErrorResponse) => {
|
||||||
this.sendErrorNotification(errorResponse.error.message);
|
this.sendErrorNotification(errorResponse.error.message);
|
||||||
@ -231,40 +362,23 @@ export class ProfessorComponent implements OnInit, OnDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private invalidateVariables(): void {
|
public saveNewProfessor(): void { this.clickButton('new-professor-save'); }
|
||||||
this.profileImage = null;
|
|
||||||
this.profileImageFileName = null;
|
|
||||||
this.selectedWorkDays = {};
|
|
||||||
this.newProfessorAwards = [];
|
|
||||||
this.selectedProfessorAwards = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public saveNewProfessor(): void {
|
|
||||||
this.clickButton('new-professor-save');
|
|
||||||
}
|
|
||||||
|
|
||||||
public searchProfessors(searchTerm: string): void {
|
public searchProfessors(searchTerm: string): void {
|
||||||
if (!searchTerm) {
|
if (!searchTerm) {
|
||||||
this.professors = this.professorService.getProfessorsFromLocalStorage();
|
this.professors = this.professorService.getProfessorsFromLocalStorage();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchProfessors: Professor[] = [];
|
|
||||||
searchTerm = searchTerm.toLowerCase();
|
searchTerm = searchTerm.toLowerCase();
|
||||||
for (const professor of this.professorService.getProfessorsFromLocalStorage()) {
|
this.professors = this.professorService.getProfessorsFromLocalStorage().filter(p =>
|
||||||
if (
|
p.firstName.toLowerCase().includes(searchTerm) ||
|
||||||
professor.firstName.toLowerCase().includes(searchTerm) ||
|
p.lastName.toLowerCase().includes(searchTerm) ||
|
||||||
professor.lastName.toLowerCase().includes(searchTerm) ||
|
p.email.toLowerCase().includes(searchTerm) ||
|
||||||
professor.email.toLowerCase().includes(searchTerm) ||
|
(p.department && p.department.toLowerCase().includes(searchTerm)) ||
|
||||||
(professor.department && professor.department.toLowerCase().includes(searchTerm)) ||
|
(p.phone && p.phone.toLowerCase().includes(searchTerm)) ||
|
||||||
(professor.phone && professor.phone.toLowerCase().includes(searchTerm)) ||
|
(p.specialty && p.specialty.toLowerCase().includes(searchTerm)) ||
|
||||||
(professor.specialty && professor.specialty.toLowerCase().includes(searchTerm)) ||
|
(p.category && this.getCategoryDisplayName(p.category).toLowerCase().includes(searchTerm))
|
||||||
(professor.category && this.getCategoryDisplayName(professor.category).toLowerCase().includes(searchTerm))
|
);
|
||||||
) {
|
|
||||||
matchProfessors.push(professor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.professors = matchProfessors;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private clickButton(buttonId: string): void {
|
private clickButton(buttonId: string): void {
|
||||||
@ -272,19 +386,21 @@ export class ProfessorComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onEditProfessor(professor: Professor): void {
|
public onEditProfessor(professor: Professor): void {
|
||||||
this.selectedProfessor = { ...professor };
|
this.selectedProfessor = JSON.parse(JSON.stringify(professor));
|
||||||
|
|
||||||
// Set up work days for editing
|
|
||||||
this.selectedWorkDays = {};
|
this.selectedWorkDays = {};
|
||||||
if (professor.workDays && Array.isArray(professor.workDays)) {
|
if (professor.workDays && Array.isArray(professor.workDays)) {
|
||||||
professor.workDays.forEach(day => {
|
professor.workDays.forEach(day => {
|
||||||
this.selectedWorkDays[day] = true;
|
if (this.availableDays.includes(day)) this.selectedWorkDays[day] = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up awards for editing
|
this.selectedProfessorAwards = professor.awards
|
||||||
this.selectedProfessorAwards = [...(professor.awards || [])];
|
? JSON.parse(JSON.stringify(professor.awards)) : [];
|
||||||
|
|
||||||
|
this.selectedProfessorSkills = professor.skills
|
||||||
|
? JSON.parse(JSON.stringify(professor.skills)) : [];
|
||||||
|
|
||||||
this.clickButton('openProfessorEdit');
|
this.clickButton('openProfessorEdit');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -293,15 +409,14 @@ export class ProfessorComponent implements OnInit, OnDestroy {
|
|||||||
this.notificationService.notify(NotificationType.ERROR, 'Please fill out all required fields.');
|
this.notificationService.notify(NotificationType.ERROR, 'Please fill out all required fields.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = this.createExtendedProfessorFormData(this.selectedProfessor, this.profileImage);
|
const formData = this.createExtendedProfessorFormData(this.selectedProfessor, this.profileImage);
|
||||||
|
|
||||||
this.subs.add(this.professorService.updateProfessor(this.selectedProfessor.professorId, formData).subscribe(
|
this.subs.add(this.professorService.updateProfessor(this.selectedProfessor.professorId, formData).subscribe(
|
||||||
(professor: Professor) => {
|
(professor: Professor) => {
|
||||||
this.clickButton('closeEditProfessorButton');
|
this.closeModal('editProfessorModal');
|
||||||
this.getProfessors(false);
|
this.getProfessors(false);
|
||||||
this.invalidateVariables();
|
this.invalidateVariables();
|
||||||
this.notificationService.notify(NotificationType.SUCCESS, `Professor ${professor.firstName} updated successfully`);
|
this.notificationService.notify(NotificationType.SUCCESS,
|
||||||
|
`Professor ${professor.firstName} updated successfully`);
|
||||||
},
|
},
|
||||||
(errorResponse: HttpErrorResponse) => {
|
(errorResponse: HttpErrorResponse) => {
|
||||||
this.sendErrorNotification(errorResponse.error.message);
|
this.sendErrorNotification(errorResponse.error.message);
|
||||||
@ -312,48 +427,56 @@ export class ProfessorComponent implements OnInit, OnDestroy {
|
|||||||
private createExtendedProfessorFormData(professor: any, profileImage: File | null): FormData {
|
private createExtendedProfessorFormData(professor: any, profileImage: File | null): FormData {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
// Basic fields
|
formData.append('firstName', professor.firstName || '');
|
||||||
formData.append('firstName', professor.firstName || '');
|
formData.append('lastName', professor.lastName || '');
|
||||||
formData.append('lastName', professor.lastName || '');
|
formData.append('email', professor.email || '');
|
||||||
formData.append('email', professor.email || '');
|
formData.append('department', professor.department || '');
|
||||||
formData.append('department', professor.department || '');
|
formData.append('position', professor.position || '');
|
||||||
formData.append('position', professor.position || '');
|
|
||||||
formData.append('officeLocation', professor.officeLocation || '');
|
formData.append('officeLocation', professor.officeLocation || '');
|
||||||
formData.append('status', professor.status || 'ACTIVE');
|
formData.append('status', professor.status || 'ACTIVE');
|
||||||
formData.append('category', professor.category || 'FACULTY');
|
formData.append('category', professor.category || 'FACULTY');
|
||||||
|
|
||||||
// Extended fields
|
if (professor.joinDate) {
|
||||||
formData.append('phone', professor.phone || '');
|
formData.append('joinDate', new Date(professor.joinDate).toISOString());
|
||||||
formData.append('specialty', professor.specialty || '');
|
}
|
||||||
formData.append('experience', professor.experience || '');
|
|
||||||
formData.append('designation', professor.designation || professor.position || '');
|
formData.append('phone', professor.phone || '');
|
||||||
formData.append('description', professor.description || '');
|
formData.append('specialty', professor.specialty || '');
|
||||||
|
formData.append('experience', professor.experience || '');
|
||||||
|
formData.append('designation', professor.designation || professor.position || '');
|
||||||
|
formData.append('description', professor.description || '');
|
||||||
formData.append('certification', professor.certification || '');
|
formData.append('certification', professor.certification || '');
|
||||||
formData.append('training', professor.training || '');
|
formData.append('training',
|
||||||
|
(professor.status !== WorkingStatus.RETIRED && professor.status !== WorkingStatus.INACTIVE)
|
||||||
|
? (professor.training || '') : ''
|
||||||
|
);
|
||||||
|
|
||||||
// Work days
|
|
||||||
const workDays = Object.keys(this.selectedWorkDays).filter(day => this.selectedWorkDays[day]);
|
const workDays = Object.keys(this.selectedWorkDays).filter(day => this.selectedWorkDays[day]);
|
||||||
if (workDays.length > 0) {
|
workDays.forEach(day => formData.append('workDays', day));
|
||||||
workDays.forEach(day => {
|
|
||||||
formData.append('workDays', day);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Awards - determine which awards array to use
|
|
||||||
const awardsToSubmit = professor.professorId ? this.selectedProfessorAwards : this.newProfessorAwards;
|
const awardsToSubmit = professor.professorId ? this.selectedProfessorAwards : this.newProfessorAwards;
|
||||||
if (awardsToSubmit && awardsToSubmit.length > 0) {
|
if (awardsToSubmit?.length) {
|
||||||
awardsToSubmit.forEach((award, index) => {
|
awardsToSubmit
|
||||||
if (award.title && award.year) { // Only include awards with title and year
|
.filter(a => a.title?.trim() && a.year?.trim())
|
||||||
formData.append(`awards[${index}].title`, award.title);
|
.forEach((award, i) => {
|
||||||
formData.append(`awards[${index}].year`, award.year);
|
formData.append(`awards[${i}].title`, award.title.trim());
|
||||||
formData.append(`awards[${index}].description`, award.description || '');
|
formData.append(`awards[${i}].year`, award.year.trim());
|
||||||
formData.append(`awards[${index}].imageUrl`, award.imageUrl || '');
|
formData.append(`awards[${i}].description`, award.description || '');
|
||||||
}
|
formData.append(`awards[${i}].imageUrl`, award.imageUrl || '');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Profile image - only add if not Trainee/Fellow category
|
const skillsToSubmit = professor.professorId ? this.selectedProfessorSkills : this.newProfessorSkills;
|
||||||
if (profileImage && professor.category !== 'TRAINEE_FELLOW') {
|
if (skillsToSubmit?.length) {
|
||||||
|
skillsToSubmit
|
||||||
|
.filter(s => s.name?.trim())
|
||||||
|
.forEach((skill, i) => {
|
||||||
|
formData.append(`skills[${i}].name`, skill.name.trim());
|
||||||
|
formData.append(`skills[${i}].level`, String(skill.level ?? 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileImage && !this.isImageUploadDisabled(professor.category)) {
|
||||||
formData.append('profileImage', profileImage);
|
formData.append('profileImage', profileImage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -375,20 +498,16 @@ export class ProfessorComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public updateProfileImage(): void {
|
public updateProfileImage(): void { this.clickButton('profile-image-input'); }
|
||||||
this.clickButton('profile-image-input');
|
|
||||||
}
|
|
||||||
|
|
||||||
public onUpdateProfileImage(): void {
|
public onUpdateProfileImage(): void {
|
||||||
if (!this.profileImage) return;
|
if (!this.profileImage) return;
|
||||||
this.refreshing = true;
|
this.refreshing = true;
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("profileImage", this.profileImage);
|
formData.append('profileImage', this.profileImage);
|
||||||
let professor = this.professorService.getSelectedProfessor();
|
const professor = this.professorService.getSelectedProfessor();
|
||||||
this.subs.sink = this.professorService.updateProfileImage(professor.professorId, formData).subscribe(
|
this.subs.sink = this.professorService.updateProfileImage(professor.professorId, formData).subscribe(
|
||||||
(event: HttpEvent<any>) => {
|
(event: HttpEvent<any>) => { this.reportUploadProgress(event); },
|
||||||
this.reportUploadProgress(event);
|
|
||||||
},
|
|
||||||
(errorResponse: HttpErrorResponse) => {
|
(errorResponse: HttpErrorResponse) => {
|
||||||
this.sendErrorNotification(errorResponse.error.message);
|
this.sendErrorNotification(errorResponse.error.message);
|
||||||
this.refreshing = false;
|
this.refreshing = false;
|
||||||
@ -406,13 +525,15 @@ export class ProfessorComponent implements OnInit, OnDestroy {
|
|||||||
case HttpEventType.UploadProgress:
|
case HttpEventType.UploadProgress:
|
||||||
this.fileUploadStatus.percentage = Math.round(100 * event.loaded / event.total!);
|
this.fileUploadStatus.percentage = Math.round(100 * event.loaded / event.total!);
|
||||||
this.fileUploadStatus.status = 'progress';
|
this.fileUploadStatus.status = 'progress';
|
||||||
break;
|
break;
|
||||||
case HttpEventType.Response:
|
case HttpEventType.Response:
|
||||||
if (event.status === 200) {
|
if (event.status === 200) {
|
||||||
if (this.loggedInProfessor) {
|
if (this.loggedInProfessor) {
|
||||||
this.loggedInProfessor.profileImageUrl = `${event.body.profileImageUrl}?time=${new Date().getTime()}`;
|
this.loggedInProfessor.profileImageUrl =
|
||||||
|
`${event.body.profileImageUrl}?time=${new Date().getTime()}`;
|
||||||
}
|
}
|
||||||
this.notificationService.notify(NotificationType.SUCCESS, `${event.body.firstName}'s image updated successfully`);
|
this.notificationService.notify(NotificationType.SUCCESS,
|
||||||
|
`${event.body.firstName}'s image updated successfully`);
|
||||||
this.fileUploadStatus.status = 'done';
|
this.fileUploadStatus.status = 'done';
|
||||||
} else {
|
} else {
|
||||||
this.sendErrorNotification('Unable to upload image. Please try again');
|
this.sendErrorNotification('Unable to upload image. Please try again');
|
||||||
@ -424,7 +545,8 @@ export class ProfessorComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public get isAdmin(): boolean {
|
public get isAdmin(): boolean {
|
||||||
return this.loggedInUser && (this.loggedInUser.role === Role.ADMIN || this.loggedInUser.role === Role.SUPER_ADMIN);
|
return this.loggedInUser &&
|
||||||
|
(this.loggedInUser.role === Role.ADMIN || this.loggedInUser.role === Role.SUPER_ADMIN);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get isManager(): boolean {
|
public get isManager(): boolean {
|
||||||
@ -452,8 +574,12 @@ export class ProfessorComponent implements OnInit, OnDestroy {
|
|||||||
description: '',
|
description: '',
|
||||||
designation: '',
|
designation: '',
|
||||||
workDays: [],
|
workDays: [],
|
||||||
awards: []
|
awards: [],
|
||||||
|
skills: []
|
||||||
};
|
};
|
||||||
this.invalidateVariables();
|
this.clearNewProfessorData();
|
||||||
|
this.clearEditProfessorData();
|
||||||
|
this.newProfessorSkills = [];
|
||||||
|
this.selectedProfessorSkills = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,742 @@
|
|||||||
|
/* Publication Layout */
|
||||||
|
.publication-layout {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
width: 260px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publication-content {
|
||||||
|
margin-left: 260px;
|
||||||
|
flex: 1;
|
||||||
|
padding: 32px;
|
||||||
|
width: calc(100% - 260px);
|
||||||
|
max-width: 1400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header Section */
|
||||||
|
.publication-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filters Section */
|
||||||
|
.filters-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Publications List */
|
||||||
|
.publications-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publication-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publication-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.publication-card.inactive {
|
||||||
|
opacity: 0.6;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Publication Content */
|
||||||
|
.publication-content-area {
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publication-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publication-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-badge {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-badge {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-badge-large {
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-badge {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publication-title {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publication-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item i {
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authors-text {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doi-link {
|
||||||
|
color: #3b82f6;
|
||||||
|
text-decoration: none;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doi-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doi-link-large {
|
||||||
|
color: #3b82f6;
|
||||||
|
text-decoration: none;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doi-link-large:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badge */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-inactive {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Buttons */
|
||||||
|
.publication-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;
|
||||||
|
max-height: calc(100vh - 200px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px 32px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Publication Detail Card */
|
||||||
|
.publication-detail-card {
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section h4 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section p {
|
||||||
|
margin: 0;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abstract-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authors-list,
|
||||||
|
.keywords-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-chip,
|
||||||
|
.keyword-chip {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-chip {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Styles */
|
||||||
|
.form-section {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
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,
|
||||||
|
.form-select {
|
||||||
|
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,
|
||||||
|
.form-select: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.publication-content {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.publication-content {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.publication-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-section {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-count {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row,
|
||||||
|
.detail-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.publication-content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publication-card {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publication-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header,
|
||||||
|
.modal-body,
|
||||||
|
.modal-footer {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.publication-content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions > * {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,506 @@
|
|||||||
|
<div class="publication-layout">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<app-menu class="sidebar"></app-menu>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="publication-content">
|
||||||
|
<!-- Header Section -->
|
||||||
|
<div class="publication-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1 class="page-title">Publications Management</h1>
|
||||||
|
<p class="page-subtitle">Manage academic publications and research papers</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 publications..."
|
||||||
|
ngModel
|
||||||
|
(ngModelChange)="searchPublications(searchTerm.value)">
|
||||||
|
</div>
|
||||||
|
<button *ngIf="isManager" class="btn-primary" data-bs-toggle="modal" data-bs-target="#addPublicationModal">
|
||||||
|
<i class="fa fa-plus"></i>
|
||||||
|
<span>New Publication</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-refresh" (click)="getPublications(true)">
|
||||||
|
<i class="fas fa-sync" [ngClass]="{'fa-spin': refreshing }"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="filters-section">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label><i class="fa fa-tag"></i> Category:</label>
|
||||||
|
<select [(ngModel)]="selectedCategory" (change)="onCategoryChange()" class="filter-select">
|
||||||
|
<option *ngFor="let cat of categories" [value]="cat">{{cat}}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label><i class="fa fa-calendar"></i> Year:</label>
|
||||||
|
<select [(ngModel)]="selectedYear" (change)="onYearChange()" class="filter-select">
|
||||||
|
<option value="All">All Years</option>
|
||||||
|
<option *ngFor="let year of years" [value]="year">{{year}}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="results-count">
|
||||||
|
Showing {{publications.length}} publication{{publications.length !== 1 ? 's' : ''}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Publications List -->
|
||||||
|
<div class="publications-list" *ngIf="publications && publications.length > 0">
|
||||||
|
<div *ngFor="let publication of publications"
|
||||||
|
class="publication-card"
|
||||||
|
[class.inactive]="!publication?.isActive">
|
||||||
|
|
||||||
|
<div class="publication-content-area" (click)="publication && onSelectPublication(publication)">
|
||||||
|
<div class="publication-header-row">
|
||||||
|
<div class="publication-meta">
|
||||||
|
<span class="year-badge">{{publication?.year}}</span>
|
||||||
|
<span class="category-badge" *ngIf="publication?.category">{{publication.category}}</span>
|
||||||
|
<span class="status-badge"
|
||||||
|
[class.status-active]="publication?.isActive"
|
||||||
|
[class.status-inactive]="!publication?.isActive">
|
||||||
|
<i class="fa"
|
||||||
|
[class.fa-check-circle]="publication?.isActive"
|
||||||
|
[class.fa-times-circle]="!publication?.isActive"></i>
|
||||||
|
{{publication?.isActive ? 'Active' : 'Inactive'}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="order-badge">Order: {{publication?.displayOrder}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="publication-title">{{publication?.title}}</h3>
|
||||||
|
|
||||||
|
<div class="publication-details">
|
||||||
|
<div class="detail-item">
|
||||||
|
<i class="fa fa-users"></i>
|
||||||
|
<span class="authors-text">{{publication?.authors}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<i class="fa fa-book"></i>
|
||||||
|
<span>{{publication?.journal}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item" *ngIf="publication?.doi">
|
||||||
|
<i class="fa fa-link"></i>
|
||||||
|
<a [href]="publication.doi" target="_blank" class="doi-link" (click)="$event.stopPropagation()">
|
||||||
|
{{publication.doi}}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="publication-actions">
|
||||||
|
<button class="btn-action btn-toggle"
|
||||||
|
(click)="onToggleActive(publication); $event.stopPropagation()"
|
||||||
|
[title]="publication?.isActive ? 'Set Inactive' : 'Set Active'">
|
||||||
|
<i class="fas" [class.fa-toggle-on]="publication?.isActive" [class.fa-toggle-off]="!publication?.isActive"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn-action btn-edit"
|
||||||
|
(click)="onEditPublication(publication); $event.stopPropagation()"
|
||||||
|
title="Edit">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
<button *ngIf="isAdmin"
|
||||||
|
class="btn-action btn-delete"
|
||||||
|
(click)="onDeletePublication(publication); $event.stopPropagation()"
|
||||||
|
title="Delete">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</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 publications...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div *ngIf="!refreshing && (!publications || publications.length === 0)" class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<i class="fa fa-book-open"></i>
|
||||||
|
</div>
|
||||||
|
<h3>No publications yet</h3>
|
||||||
|
<p>Get started by creating your first publication</p>
|
||||||
|
<button *ngIf="isManager" class="btn-primary" data-bs-toggle="modal" data-bs-target="#addPublicationModal">
|
||||||
|
<i class="fa fa-plus"></i>
|
||||||
|
<span>Create Your First Publication</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden Modal Triggers -->
|
||||||
|
<button [hidden]="true" type="button" id="openPublicationInfo" data-bs-toggle="modal" data-bs-target="#viewPublicationModal"></button>
|
||||||
|
<button [hidden]="true" type="button" id="openPublicationEdit" data-bs-toggle="modal" data-bs-target="#editPublicationModal"></button>
|
||||||
|
|
||||||
|
<!-- View Publication Modal -->
|
||||||
|
<div class="modal fade" id="viewPublicationModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content" *ngIf="selectedPublication">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Publication Details</h3>
|
||||||
|
<button class="modal-close" data-bs-dismiss="modal">
|
||||||
|
<i class="fa fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="publication-detail-card">
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="detail-section">
|
||||||
|
<h4>Title</h4>
|
||||||
|
<p>{{selectedPublication.title}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Authors -->
|
||||||
|
<div class="detail-section">
|
||||||
|
<h4>Authors</h4>
|
||||||
|
<div class="authors-list">
|
||||||
|
<span *ngFor="let author of getAuthorsList(selectedPublication.authors)" class="author-chip">
|
||||||
|
{{author}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Year & Journal -->
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-section">
|
||||||
|
<h4>Year</h4>
|
||||||
|
<p>{{selectedPublication.year}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="detail-section">
|
||||||
|
<h4>Journal</h4>
|
||||||
|
<p>{{selectedPublication.journal}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DOI -->
|
||||||
|
<div class="detail-section" *ngIf="selectedPublication.doi">
|
||||||
|
<h4>DOI</h4>
|
||||||
|
<a [href]="selectedPublication.doi" target="_blank" class="doi-link-large">
|
||||||
|
{{selectedPublication.doi}}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category & Publication Date -->
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-section" *ngIf="selectedPublication.category">
|
||||||
|
<h4>Category</h4>
|
||||||
|
<span class="category-badge-large">{{selectedPublication.category}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-section" *ngIf="selectedPublication.publicationDate">
|
||||||
|
<h4>Publication Date</h4>
|
||||||
|
<p>{{selectedPublication.publicationDate}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Abstract -->
|
||||||
|
<div class="detail-section" *ngIf="selectedPublication.abstractText">
|
||||||
|
<h4>Abstract</h4>
|
||||||
|
<p class="abstract-text">{{selectedPublication.abstractText}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Keywords -->
|
||||||
|
<div class="detail-section" *ngIf="selectedPublication.keywords">
|
||||||
|
<h4>Keywords</h4>
|
||||||
|
<div class="keywords-list">
|
||||||
|
<span *ngFor="let keyword of getKeywordsList(selectedPublication.keywords)" class="keyword-chip">
|
||||||
|
{{keyword}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status & Order -->
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-section">
|
||||||
|
<h4>Status</h4>
|
||||||
|
<span class="status-badge"
|
||||||
|
[class.status-active]="selectedPublication.isActive"
|
||||||
|
[class.status-inactive]="!selectedPublication.isActive">
|
||||||
|
{{selectedPublication.isActive ? 'Active' : 'Inactive'}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-section">
|
||||||
|
<h4>Display Order</h4>
|
||||||
|
<p>{{selectedPublication.displayOrder}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Publication Modal -->
|
||||||
|
<div *ngIf="isManager" class="modal fade" id="addPublicationModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Add New Publication</h3>
|
||||||
|
<button class="modal-close" data-bs-dismiss="modal">
|
||||||
|
<i class="fa fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form #newPublicationForm="ngForm" (ngSubmit)="onAddNewPublication(newPublicationForm)">
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<h4 class="section-title">Basic Information</h4>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="title">
|
||||||
|
<i class="fa fa-heading"></i>
|
||||||
|
Title *
|
||||||
|
</label>
|
||||||
|
<textarea id="title" name="title" class="form-textarea" rows="3" ngModel required placeholder="Enter publication title"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="authors">
|
||||||
|
<i class="fa fa-users"></i>
|
||||||
|
Authors * (comma-separated)
|
||||||
|
</label>
|
||||||
|
<textarea id="authors" name="authors" class="form-textarea" rows="2" ngModel required placeholder="Smith, J., Doe, A., Johnson, B."></textarea>
|
||||||
|
<small class="form-text">Separate multiple authors with commas</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="year">
|
||||||
|
<i class="fa fa-calendar"></i>
|
||||||
|
Year *
|
||||||
|
</label>
|
||||||
|
<input type="number" id="year" name="year" class="form-input" ngModel required min="1900" max="2100" placeholder="2024">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="journal">
|
||||||
|
<i class="fa fa-book"></i>
|
||||||
|
Journal *
|
||||||
|
</label>
|
||||||
|
<input type="text" id="journal" name="journal" class="form-input" ngModel required placeholder="Journal name">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<h4 class="section-title">Additional Details</h4>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="doi">
|
||||||
|
<i class="fa fa-link"></i>
|
||||||
|
DOI
|
||||||
|
</label>
|
||||||
|
<input type="text" id="doi" name="doi" class="form-input" ngModel placeholder="https://doi.org/10.1234/example">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="category">
|
||||||
|
<i class="fa fa-tag"></i>
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<select id="category" name="category" class="form-select" ngModel>
|
||||||
|
<option value="">Select category...</option>
|
||||||
|
<option value="Clinical Research">Clinical Research</option>
|
||||||
|
<option value="Case Reports">Case Reports</option>
|
||||||
|
<option value="Review">Review</option>
|
||||||
|
<option value="Healthcare Systems">Healthcare Systems</option>
|
||||||
|
<option value="Meta-Analysis">Meta-Analysis</option>
|
||||||
|
<option value="Editorial">Editorial</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="publicationDate">
|
||||||
|
<i class="fa fa-calendar-alt"></i>
|
||||||
|
Publication Date
|
||||||
|
</label>
|
||||||
|
<input type="text" id="publicationDate" name="publicationDate" class="form-input" ngModel placeholder="January 2024">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="abstractText">
|
||||||
|
<i class="fa fa-align-left"></i>
|
||||||
|
Abstract
|
||||||
|
</label>
|
||||||
|
<textarea id="abstractText" name="abstractText" class="form-textarea" rows="6" ngModel placeholder="Enter abstract text"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="keywords">
|
||||||
|
<i class="fa fa-tags"></i>
|
||||||
|
Keywords (comma-separated)
|
||||||
|
</label>
|
||||||
|
<textarea id="keywords" name="keywords" class="form-textarea" rows="2" ngModel placeholder="trauma, surgery, emergency"></textarea>
|
||||||
|
<small class="form-text">Separate multiple keywords with commas</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="displayOrder">
|
||||||
|
<i class="fa fa-sort-numeric-up"></i>
|
||||||
|
Display Order
|
||||||
|
</label>
|
||||||
|
<input type="number" id="displayOrder" name="displayOrder" class="form-input" ngModel value="0" placeholder="0">
|
||||||
|
<small class="form-text">Lower numbers appear first within the same year</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)="newPublicationForm.ngSubmit.emit()"
|
||||||
|
[disabled]="newPublicationForm.invalid">
|
||||||
|
<i class="fa fa-save"></i>
|
||||||
|
Create Publication
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Publication Modal -->
|
||||||
|
<div class="modal fade" id="editPublicationModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content" *ngIf="selectedPublication">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Edit Publication</h3>
|
||||||
|
<button class="modal-close" data-bs-dismiss="modal">
|
||||||
|
<i class="fa fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form #editPublicationForm="ngForm" (ngSubmit)="onUpdatePublication(editPublicationForm)">
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<h4 class="section-title">Basic Information</h4>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editTitle">
|
||||||
|
<i class="fa fa-heading"></i>
|
||||||
|
Title *
|
||||||
|
</label>
|
||||||
|
<textarea id="editTitle" name="title" class="form-textarea" rows="3" [(ngModel)]="selectedPublication.title" required></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editAuthors">
|
||||||
|
<i class="fa fa-users"></i>
|
||||||
|
Authors * (comma-separated)
|
||||||
|
</label>
|
||||||
|
<textarea id="editAuthors" name="authors" class="form-textarea" rows="2" [(ngModel)]="selectedPublication.authors" required></textarea>
|
||||||
|
<small class="form-text">Separate multiple authors with commas</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editYear">
|
||||||
|
<i class="fa fa-calendar"></i>
|
||||||
|
Year *
|
||||||
|
</label>
|
||||||
|
<input type="number" id="editYear" name="year" class="form-input" [(ngModel)]="selectedPublication.year" required min="1900" max="2100">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editJournal">
|
||||||
|
<i class="fa fa-book"></i>
|
||||||
|
Journal *
|
||||||
|
</label>
|
||||||
|
<input type="text" id="editJournal" name="journal" class="form-input" [(ngModel)]="selectedPublication.journal" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<h4 class="section-title">Additional Details</h4>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editDoi">
|
||||||
|
<i class="fa fa-link"></i>
|
||||||
|
DOI
|
||||||
|
</label>
|
||||||
|
<input type="text" id="editDoi" name="doi" class="form-input" [(ngModel)]="selectedPublication.doi">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editCategory">
|
||||||
|
<i class="fa fa-tag"></i>
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<select id="editCategory" name="category" class="form-select" [(ngModel)]="selectedPublication.category">
|
||||||
|
<option value="">Select category...</option>
|
||||||
|
<option value="Clinical Research">Clinical Research</option>
|
||||||
|
<option value="Case Reports">Case Reports</option>
|
||||||
|
<option value="Review">Review</option>
|
||||||
|
<option value="Healthcare Systems">Healthcare Systems</option>
|
||||||
|
<option value="Meta-Analysis">Meta-Analysis</option>
|
||||||
|
<option value="Editorial">Editorial</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editPublicationDate">
|
||||||
|
<i class="fa fa-calendar-alt"></i>
|
||||||
|
Publication Date
|
||||||
|
</label>
|
||||||
|
<input type="text" id="editPublicationDate" name="publicationDate" class="form-input" [(ngModel)]="selectedPublication.publicationDate">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editAbstractText">
|
||||||
|
<i class="fa fa-align-left"></i>
|
||||||
|
Abstract
|
||||||
|
</label>
|
||||||
|
<textarea id="editAbstractText" name="abstractText" class="form-textarea" rows="6" [(ngModel)]="selectedPublication.abstractText"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editKeywords">
|
||||||
|
<i class="fa fa-tags"></i>
|
||||||
|
Keywords (comma-separated)
|
||||||
|
</label>
|
||||||
|
<textarea id="editKeywords" name="keywords" class="form-textarea" rows="2" [(ngModel)]="selectedPublication.keywords"></textarea>
|
||||||
|
<small class="form-text">Separate multiple keywords with commas</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editDisplayOrder">
|
||||||
|
<i class="fa fa-sort-numeric-up"></i>
|
||||||
|
Display Order
|
||||||
|
</label>
|
||||||
|
<input type="number" id="editDisplayOrder" name="displayOrder" class="form-input" [(ngModel)]="selectedPublication.displayOrder">
|
||||||
|
<small class="form-text">Lower numbers appear first within the same year</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button class="btn-primary" (click)="editPublicationForm.ngSubmit.emit()" [disabled]="editPublicationForm.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 { PublicationsComponent } from './publications.component';
|
||||||
|
|
||||||
|
describe('PublicationsComponent', () => {
|
||||||
|
let component: PublicationsComponent;
|
||||||
|
let fixture: ComponentFixture<PublicationsComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ PublicationsComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(PublicationsComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,300 @@
|
|||||||
|
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
import { Publication } from '../../model/publication.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 { PublicationService } from 'src/app/service/publication.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-publication',
|
||||||
|
templateUrl: './publications.component.html',
|
||||||
|
styleUrls: ['./publications.component.css']
|
||||||
|
})
|
||||||
|
export class PublicationsComponent implements OnInit, OnDestroy {
|
||||||
|
private titleSubject = new BehaviorSubject<string>('Publications');
|
||||||
|
public titleAction$ = this.titleSubject.asObservable();
|
||||||
|
public loggedInUser: User;
|
||||||
|
|
||||||
|
public publications: Publication[] = [];
|
||||||
|
public selectedPublication: Publication | null = null;
|
||||||
|
public refreshing: boolean = false;
|
||||||
|
private subs = new SubSink();
|
||||||
|
|
||||||
|
// Filter options
|
||||||
|
public categories: string[] = ['All', 'Clinical Research', 'Case Reports', 'Review', 'Healthcare Systems', 'Meta-Analysis', 'Editorial'];
|
||||||
|
public selectedCategory: string = 'All';
|
||||||
|
public years: number[] = [];
|
||||||
|
public selectedYear: string = 'All';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private publicationService: PublicationService,
|
||||||
|
private notificationService: NotificationService,
|
||||||
|
private authenticationService: AuthenticationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
document.addEventListener('hidden.bs.modal', () => {
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
const backdrops = document.querySelectorAll('.modal-backdrop');
|
||||||
|
backdrops.forEach(b => b.remove());
|
||||||
|
});
|
||||||
|
this.getPublications(false);
|
||||||
|
this.loggedInUser = this.authenticationService.getUserFromLocalStorage();
|
||||||
|
this.setupModalEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.subs.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupModalEventListeners(): void {
|
||||||
|
const editModal = document.getElementById('editPublicationModal');
|
||||||
|
if (editModal) {
|
||||||
|
editModal.addEventListener('hidden.bs.modal', () => {
|
||||||
|
this.clearEditPublicationData();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const addModal = document.getElementById('addPublicationModal');
|
||||||
|
if (addModal) {
|
||||||
|
addModal.addEventListener('hidden.bs.modal', () => {
|
||||||
|
this.clearNewPublicationData();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewModal = document.getElementById('viewPublicationModal');
|
||||||
|
if (viewModal) {
|
||||||
|
viewModal.addEventListener('hidden.bs.modal', () => {
|
||||||
|
this.selectedPublication = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearNewPublicationData(): void {
|
||||||
|
// Clear any form data if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearEditPublicationData(): void {
|
||||||
|
this.selectedPublication = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPublications(showNotification: boolean): void {
|
||||||
|
this.refreshing = true;
|
||||||
|
this.subs.sink = this.publicationService.getAllPublications().subscribe(
|
||||||
|
publications => {
|
||||||
|
this.publications = (publications || []).filter(pub => pub !== null && pub !== undefined);
|
||||||
|
this.publications.sort((a, b) => {
|
||||||
|
// Sort by year DESC, then by displayOrder ASC
|
||||||
|
if (b.year !== a.year) {
|
||||||
|
return b.year - a.year;
|
||||||
|
}
|
||||||
|
return (a.displayOrder || 0) - (b.displayOrder || 0);
|
||||||
|
});
|
||||||
|
this.publicationService.addPublicationsToLocalStorage(this.publications);
|
||||||
|
this.extractYears();
|
||||||
|
|
||||||
|
if (showNotification && this.publications.length > 0) {
|
||||||
|
this.notificationService.notify(NotificationType.SUCCESS, `Loaded ${this.publications.length} publication${this.publications.length > 1 ? 's' : ''}`);
|
||||||
|
}
|
||||||
|
this.refreshing = false;
|
||||||
|
},
|
||||||
|
(errorResponse: HttpErrorResponse) => {
|
||||||
|
this.sendErrorNotification(errorResponse.error?.message || 'Failed to load publications');
|
||||||
|
this.refreshing = false;
|
||||||
|
this.publications = [];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractYears(): void {
|
||||||
|
const yearSet = new Set<number>();
|
||||||
|
this.publications.forEach(pub => {
|
||||||
|
if (pub.year) {
|
||||||
|
yearSet.add(pub.year);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.years = Array.from(yearSet).sort((a, b) => b - a);
|
||||||
|
}
|
||||||
|
|
||||||
|
public onSelectPublication(publication: Publication): void {
|
||||||
|
this.selectedPublication = JSON.parse(JSON.stringify(publication));
|
||||||
|
this.clickButton('openPublicationInfo');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 onAddNewPublication(publicationForm: NgForm): void {
|
||||||
|
const formData = this.createPublicationFormData(publicationForm.value);
|
||||||
|
this.subs.sink = this.publicationService.addPublication(formData).subscribe(
|
||||||
|
(publication: Publication) => {
|
||||||
|
this.getPublications(false);
|
||||||
|
this.notificationService.notify(NotificationType.SUCCESS, 'Publication added successfully');
|
||||||
|
publicationForm.reset();
|
||||||
|
this.clearNewPublicationData();
|
||||||
|
const modalElement = document.getElementById('addPublicationModal');
|
||||||
|
if (modalElement) {
|
||||||
|
const modal = (window as any).bootstrap.Modal.getInstance(modalElement);
|
||||||
|
if (modal) {
|
||||||
|
modal.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(errorResponse: HttpErrorResponse) => {
|
||||||
|
this.sendErrorNotification(errorResponse.error.message);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public searchPublications(searchTerm: string): void {
|
||||||
|
if (!searchTerm) {
|
||||||
|
this.publications = this.publicationService.getPublicationsFromLocalStorage();
|
||||||
|
this.applyFilters();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchPublications: Publication[] = [];
|
||||||
|
searchTerm = searchTerm.toLowerCase();
|
||||||
|
for (const publication of this.publicationService.getPublicationsFromLocalStorage()) {
|
||||||
|
if (
|
||||||
|
(publication.title && publication.title.toLowerCase().includes(searchTerm)) ||
|
||||||
|
(publication.authors && publication.authors.toLowerCase().includes(searchTerm)) ||
|
||||||
|
(publication.journal && publication.journal.toLowerCase().includes(searchTerm)) ||
|
||||||
|
(publication.category && publication.category.toLowerCase().includes(searchTerm))
|
||||||
|
) {
|
||||||
|
matchPublications.push(publication);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.publications = matchPublications;
|
||||||
|
this.applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onCategoryChange(): void {
|
||||||
|
this.applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onYearChange(): void {
|
||||||
|
this.applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyFilters(): void {
|
||||||
|
let filtered = this.publicationService.getPublicationsFromLocalStorage();
|
||||||
|
|
||||||
|
// Apply category filter
|
||||||
|
if (this.selectedCategory !== 'All') {
|
||||||
|
filtered = filtered.filter(pub => pub.category === this.selectedCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply year filter
|
||||||
|
if (this.selectedYear !== 'All') {
|
||||||
|
const year = parseInt(this.selectedYear);
|
||||||
|
filtered = filtered.filter(pub => pub.year === year);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.publications = filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clickButton(buttonId: string): void {
|
||||||
|
document.getElementById(buttonId)?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onEditPublication(publication: Publication): void {
|
||||||
|
this.selectedPublication = JSON.parse(JSON.stringify(publication));
|
||||||
|
this.clickButton('openPublicationEdit');
|
||||||
|
}
|
||||||
|
|
||||||
|
public onUpdatePublication(form: NgForm): void {
|
||||||
|
if (form.invalid || !this.selectedPublication) {
|
||||||
|
this.notificationService.notify(NotificationType.ERROR, 'Please fill out all required fields.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = this.createPublicationFormData(this.selectedPublication);
|
||||||
|
|
||||||
|
this.subs.add(this.publicationService.updatePublication(this.selectedPublication.id!, formData).subscribe(
|
||||||
|
(publication: Publication) => {
|
||||||
|
this.getPublications(false);
|
||||||
|
this.notificationService.notify(NotificationType.SUCCESS, 'Publication updated successfully');
|
||||||
|
const modalElement = document.getElementById('editPublicationModal');
|
||||||
|
if (modalElement) {
|
||||||
|
const modal = (window as any).bootstrap.Modal.getInstance(modalElement);
|
||||||
|
if (modal) {
|
||||||
|
modal.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(errorResponse: HttpErrorResponse) => {
|
||||||
|
this.sendErrorNotification(errorResponse.error.message);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private createPublicationFormData(publication: any): FormData {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('title', publication.title || '');
|
||||||
|
formData.append('authors', publication.authors || '');
|
||||||
|
formData.append('year', (publication.year || new Date().getFullYear()).toString());
|
||||||
|
formData.append('journal', publication.journal || '');
|
||||||
|
formData.append('doi', publication.doi || '');
|
||||||
|
formData.append('category', publication.category || '');
|
||||||
|
formData.append('abstractText', publication.abstractText || '');
|
||||||
|
formData.append('publicationDate', publication.publicationDate || '');
|
||||||
|
formData.append('keywords', publication.keywords || '');
|
||||||
|
formData.append('displayOrder', (publication.displayOrder || 0).toString());
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDeletePublication(publication: Publication): void {
|
||||||
|
if (confirm(`Are you sure you want to delete "${publication.title}"?`)) {
|
||||||
|
this.subs.sink = this.publicationService.deletePublication(publication.id!).subscribe(
|
||||||
|
(response: CustomHttpResponse) => {
|
||||||
|
this.getPublications(false);
|
||||||
|
this.notificationService.notify(NotificationType.SUCCESS, response.message);
|
||||||
|
},
|
||||||
|
(errorResponse: HttpErrorResponse) => {
|
||||||
|
this.sendErrorNotification(errorResponse.error.message);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onToggleActive(publication: Publication): void {
|
||||||
|
this.subs.sink = this.publicationService.toggleActiveStatus(publication.id!).subscribe(
|
||||||
|
(response: Publication) => {
|
||||||
|
this.getPublications(false);
|
||||||
|
this.notificationService.notify(NotificationType.SUCCESS, `Publication ${response.isActive ? 'activated' : 'deactivated'} successfully`);
|
||||||
|
},
|
||||||
|
(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 getAuthorsList(authors: string): string[] {
|
||||||
|
return authors ? authors.split(',').map(a => a.trim()).filter(a => a.length > 0) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public getKeywordsList(keywords: string): string[] {
|
||||||
|
return keywords ? keywords.split(',').map(k => k.trim()).filter(k => k.length > 0) : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,594 @@
|
|||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
/* Add to existing CSS */
|
||||||
|
|
||||||
|
.badge-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: #e0e7ff;
|
||||||
|
color: #4338ca;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select dropdown styling */
|
||||||
|
select.form-input {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 12px center;
|
||||||
|
padding-right: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.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,220 @@
|
|||||||
|
<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>
|
||||||
|
<div class="badge-group">
|
||||||
|
<span class="category-badge">{{ getCategoryLabel(serviceTile?.category) }}</span>
|
||||||
|
<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>
|
||||||
|
</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">Category:</span>
|
||||||
|
<span class="detail-value">{{ getCategoryLabel(selectedServiceTile.category) }}</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>Category *</label>
|
||||||
|
<select class="form-input" name="category" ngModel required>
|
||||||
|
<option value="" disabled selected>Select Category</option>
|
||||||
|
<option *ngFor="let cat of categories" [value]="cat.value">{{ cat.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Description * (Separate points with commas)</label>
|
||||||
|
<textarea class="form-textarea" name="description" ngModel required
|
||||||
|
placeholder="e.g., Point one, Point two, Point three"></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>Category *</label>
|
||||||
|
<select class="form-input" name="category" [(ngModel)]="selectedServiceTile.category" required>
|
||||||
|
<option value="" disabled>Select Category</option>
|
||||||
|
<option *ngFor="let cat of categories" [value]="cat.value">{{ cat.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Description * (Separate points with commas)</label>
|
||||||
|
<textarea class="form-textarea" name="description" [(ngModel)]="selectedServiceTile.description"
|
||||||
|
required placeholder="e.g., Point one, Point two, Point three"></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,200 @@
|
|||||||
|
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();
|
||||||
|
|
||||||
|
// Category options
|
||||||
|
public categories = [
|
||||||
|
{ value: 'ACUTE_CARE_SURGERY', label: 'Acute Care Surgery' },
|
||||||
|
{ value: 'TRAUMA_CARE', label: 'Trauma Care' }
|
||||||
|
];
|
||||||
|
|
||||||
|
@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 {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
form.reset();
|
||||||
|
this.notificationService.notify(NotificationType.SUCCESS, 'Service tile created successfully');
|
||||||
|
},
|
||||||
|
(errorResponse: HttpErrorResponse) => {
|
||||||
|
this.sendErrorNotification(errorResponse.error.message);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public onEditServiceTile(serviceTile: ServiceTile): void {
|
||||||
|
this.selectedServiceTile = { ...serviceTile };
|
||||||
|
this.editModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
this.notificationService.notify(NotificationType.SUCCESS, 'Service tile updated successfully');
|
||||||
|
},
|
||||||
|
(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) ||
|
||||||
|
this.getCategoryLabel(t.category).toLowerCase().includes(searchTerm)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCategoryLabel(category: string): string {
|
||||||
|
const cat = this.categories.find(c => c.value === category);
|
||||||
|
return cat ? cat.label : category;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createServiceTileFormData(serviceTile: any): FormData {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('title', serviceTile.title || '');
|
||||||
|
formData.append('description', serviceTile.description || '');
|
||||||
|
formData.append('category', serviceTile.category || '');
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,5 +2,6 @@
|
|||||||
export enum WorkingStatus {
|
export enum WorkingStatus {
|
||||||
ACTIVE = 'ACTIVE',
|
ACTIVE = 'ACTIVE',
|
||||||
ON_LEAVE = 'ON_LEAVE',
|
ON_LEAVE = 'ON_LEAVE',
|
||||||
RETIRED = 'RETIRED'
|
RETIRED = 'RETIRED',
|
||||||
|
INACTIVE = 'INACTIVE'
|
||||||
}
|
}
|
||||||
@ -1,6 +1,10 @@
|
|||||||
// src/app/enum/professor-category.enum.ts
|
// src/app/enum/professor-category.enum.ts
|
||||||
export enum ProfessorCategory {
|
export enum ProfessorCategory {
|
||||||
FACULTY = 'FACULTY',
|
FACULTY = 'FACULTY',
|
||||||
SUPPORT_TEAM = 'SUPPORT_TEAM',
|
SUPPORT_TEAM = 'SUPPORT_TEAM',
|
||||||
TRAINEE_FELLOW = 'TRAINEE_FELLOW'
|
TRAINEE_FELLOW = 'TRAINEE_FELLOW',
|
||||||
|
RESIGNED = 'RESIGNED',
|
||||||
|
GUIDES = 'GUIDES',
|
||||||
|
FRIENDS = 'FRIENDS',
|
||||||
|
PATRONS = 'PATRONS'
|
||||||
}
|
}
|
||||||
@ -15,7 +15,13 @@ export interface Professor {
|
|||||||
profileImageUrl: string;
|
profileImageUrl: string;
|
||||||
status: WorkingStatus;
|
status: WorkingStatus;
|
||||||
category: ProfessorCategory;
|
category: ProfessorCategory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controls display order on the public frontend.
|
||||||
|
* Lower = appears first. Set via the admin drag-and-drop reorder UI.
|
||||||
|
*/
|
||||||
|
displayOrder?: number;
|
||||||
|
|
||||||
// Additional fields for Next.js integration
|
// Additional fields for Next.js integration
|
||||||
phone?: string;
|
phone?: string;
|
||||||
specialty?: string;
|
specialty?: string;
|
||||||
@ -25,7 +31,7 @@ export interface Professor {
|
|||||||
description?: string;
|
description?: string;
|
||||||
designation?: string;
|
designation?: string;
|
||||||
workDays?: string[];
|
workDays?: string[];
|
||||||
|
|
||||||
// Awards and skills
|
// Awards and skills
|
||||||
awards?: Award[];
|
awards?: Award[];
|
||||||
skills?: Skill[];
|
skills?: Skill[];
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
16
support-portal-frontend/src/app/model/publication.model.ts
Normal file
16
support-portal-frontend/src/app/model/publication.model.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export interface Publication {
|
||||||
|
id?: number;
|
||||||
|
title: string;
|
||||||
|
authors: string; // Comma-separated string
|
||||||
|
year: number;
|
||||||
|
journal: string;
|
||||||
|
doi?: string;
|
||||||
|
category?: string;
|
||||||
|
abstractText?: string;
|
||||||
|
publicationDate?: string;
|
||||||
|
keywords?: string; // Comma-separated string
|
||||||
|
isActive: boolean;
|
||||||
|
displayOrder?: number;
|
||||||
|
createdDate?: Date;
|
||||||
|
lastModified?: Date;
|
||||||
|
}
|
||||||
10
support-portal-frontend/src/app/model/service-tile.model.ts
Normal file
10
support-portal-frontend/src/app/model/service-tile.model.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export interface ServiceTile {
|
||||||
|
id?: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
category: 'TRAUMA_CARE' | 'ACUTE_CARE_SURGERY';
|
||||||
|
isActive: boolean;
|
||||||
|
displayOrder?: number;
|
||||||
|
createdDate?: Date;
|
||||||
|
lastModified?: Date;
|
||||||
|
}
|
||||||
@ -27,9 +27,10 @@ export interface JobApplication {
|
|||||||
experience: string;
|
experience: string;
|
||||||
coverLetter?: string;
|
coverLetter?: string;
|
||||||
resumeUrl?: string;
|
resumeUrl?: string;
|
||||||
|
resumePath?: string; // Add this property
|
||||||
status?: string;
|
status?: string;
|
||||||
job?: Job;
|
job?: Job;
|
||||||
createdDate?: string; // Add this line
|
createdDate?: string;
|
||||||
updatedDate?: string;
|
updatedDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,4 +92,12 @@ export class CareerService {
|
|||||||
deleteApplication(id: number): Observable<void> {
|
deleteApplication(id: number): Observable<void> {
|
||||||
return this.http.delete<void>(`${this.applicationsApiUrl}/${id}`);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
|
|||||||
import { environment } from "../../environments/environment";
|
import { environment } from "../../environments/environment";
|
||||||
import { HttpClient, HttpEvent } from "@angular/common/http";
|
import { HttpClient, HttpEvent } from "@angular/common/http";
|
||||||
import { Observable } from "rxjs";
|
import { Observable } from "rxjs";
|
||||||
import { Professor } from "../model/Professor"; // Ensure this model is correctly defined
|
import { Professor } from "../model/Professor";
|
||||||
import { CustomHttpResponse } from "../dto/custom-http-response";
|
import { CustomHttpResponse } from "../dto/custom-http-response";
|
||||||
import { map } from "rxjs/operators";
|
import { map } from "rxjs/operators";
|
||||||
|
|
||||||
@ -16,8 +16,7 @@ export class ProfessorService {
|
|||||||
|
|
||||||
private selectedProfessor: Professor;
|
private selectedProfessor: Professor;
|
||||||
|
|
||||||
constructor(private httpClient: HttpClient) {
|
constructor(private httpClient: HttpClient) { }
|
||||||
}
|
|
||||||
|
|
||||||
public getAllProfessors(): Observable<ProfessorPage> {
|
public getAllProfessors(): Observable<ProfessorPage> {
|
||||||
return this.httpClient
|
return this.httpClient
|
||||||
@ -41,11 +40,20 @@ export class ProfessorService {
|
|||||||
|
|
||||||
public updateProfileImage(professorId: string, formData: FormData): Observable<HttpEvent<Professor>> {
|
public updateProfileImage(professorId: string, formData: FormData): Observable<HttpEvent<Professor>> {
|
||||||
return this.httpClient
|
return this.httpClient
|
||||||
.put<Professor>(`${this.host}/user/${professorId}/profile-image`, formData,
|
.put<Professor>(`${this.host}/user/${professorId}/profile-image`, formData, {
|
||||||
{
|
reportProgress: true,
|
||||||
reportProgress: true,
|
observe: 'events'
|
||||||
observe: 'events'
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the new display order to the backend.
|
||||||
|
* @param orderedIds Professor UUIDs in the desired display order (index 0 = first).
|
||||||
|
* @returns The full professor list sorted by the new displayOrder.
|
||||||
|
*/
|
||||||
|
public updateDisplayOrder(orderedIds: string[]): Observable<Professor[]> {
|
||||||
|
return this.httpClient
|
||||||
|
.put<Professor[]>(`${this.host}/professor/order`, orderedIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
public addProfessorsToLocalStorage(professors: Professor[]) {
|
public addProfessorsToLocalStorage(professors: Professor[]) {
|
||||||
@ -53,7 +61,7 @@ export class ProfessorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getProfessorsFromLocalStorage(): Professor[] {
|
public getProfessorsFromLocalStorage(): Professor[] {
|
||||||
let professors = this.storage.getItem('professors');
|
const professors = this.storage.getItem('professors');
|
||||||
if (professors) {
|
if (professors) {
|
||||||
return JSON.parse(professors);
|
return JSON.parse(professors);
|
||||||
}
|
}
|
||||||
@ -70,7 +78,6 @@ export class ProfessorService {
|
|||||||
formData.append('position', professor.position);
|
formData.append('position', professor.position);
|
||||||
formData.append('officeLocation', professor.officeLocation);
|
formData.append('officeLocation', professor.officeLocation);
|
||||||
formData.append('status', professor.status);
|
formData.append('status', professor.status);
|
||||||
// formData.append('joinDate', professor.joinDate.toString()); // Convert LocalDateTime to string
|
|
||||||
if (profileImage)
|
if (profileImage)
|
||||||
formData.append('profileImage', profileImage);
|
formData.append('profileImage', profileImage);
|
||||||
|
|
||||||
@ -86,7 +93,7 @@ export class ProfessorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public findProfessorById(id: string): Professor | Observable<Professor> {
|
public findProfessorById(id: string): Professor | Observable<Professor> {
|
||||||
let cachedProfessors = this.getProfessorsFromLocalStorage();
|
const cachedProfessors = this.getProfessorsFromLocalStorage();
|
||||||
const foundProfessor = cachedProfessors.find((p) => p.professorId === id);
|
const foundProfessor = cachedProfessors.find((p) => p.professorId === id);
|
||||||
|
|
||||||
if (foundProfessor) return foundProfessor;
|
if (foundProfessor) return foundProfessor;
|
||||||
@ -108,4 +115,4 @@ export interface ProfessorPage {
|
|||||||
numberOfElements: number;
|
numberOfElements: number;
|
||||||
number: number;
|
number: number;
|
||||||
empty: boolean;
|
empty: boolean;
|
||||||
}
|
}
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
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 { Publication } from '../model/publication.model';
|
||||||
|
import { CustomHttpResponse } from '../dto/custom-http-response';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class PublicationService {
|
||||||
|
private host = environment.apiUrl;
|
||||||
|
|
||||||
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
public addPublication(formData: FormData): Observable<Publication> {
|
||||||
|
return this.http.post<any>(`${this.host}/publications`, formData).pipe(
|
||||||
|
map(pub => this.transformPublication(pub))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public updatePublication(id: number, formData: FormData): Observable<Publication> {
|
||||||
|
return this.http.put<any>(`${this.host}/publications/${id}`, formData).pipe(
|
||||||
|
map(pub => this.transformPublication(pub))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getActivePublications(): Observable<Publication[]> {
|
||||||
|
return this.http.get<any[]>(`${this.host}/publications/active`).pipe(
|
||||||
|
map(pubs => pubs.map(pub => this.transformPublication(pub)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPublicationById(id: number): Observable<Publication> {
|
||||||
|
return this.http.get<any>(`${this.host}/publications/${id}`).pipe(
|
||||||
|
map(pub => this.transformPublication(pub))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAllPublications(): Observable<Publication[]> {
|
||||||
|
return this.http.get<any[]>(`${this.host}/publications`).pipe(
|
||||||
|
map(pubs => pubs.map(pub => this.transformPublication(pub)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPublicationsByCategory(category: string): Observable<Publication[]> {
|
||||||
|
return this.http.get<any[]>(`${this.host}/publications/category/${category}`).pipe(
|
||||||
|
map(pubs => pubs.map(pub => this.transformPublication(pub)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPublicationsByYear(year: number): Observable<Publication[]> {
|
||||||
|
return this.http.get<any[]>(`${this.host}/publications/year/${year}`).pipe(
|
||||||
|
map(pubs => pubs.map(pub => this.transformPublication(pub)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public deletePublication(id: number): Observable<CustomHttpResponse> {
|
||||||
|
return this.http.delete<CustomHttpResponse>(`${this.host}/publications/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleActiveStatus(id: number): Observable<Publication> {
|
||||||
|
return this.http.put<any>(`${this.host}/publications/${id}/toggle-active`, {}).pipe(
|
||||||
|
map(pub => this.transformPublication(pub))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public reorderPublications(orderedIds: number[]): Observable<CustomHttpResponse> {
|
||||||
|
return this.http.put<CustomHttpResponse>(`${this.host}/publications/reorder`, orderedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform backend response to match frontend model
|
||||||
|
private transformPublication(pub: any): Publication {
|
||||||
|
return {
|
||||||
|
...pub,
|
||||||
|
isActive: pub.isActive !== undefined ? pub.isActive : pub.active
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local storage methods
|
||||||
|
public addPublicationsToLocalStorage(publications: Publication[]): void {
|
||||||
|
localStorage.setItem('publications', JSON.stringify(publications));
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPublicationsFromLocalStorage(): Publication[] {
|
||||||
|
const publications = localStorage.getItem('publications');
|
||||||
|
return publications ? JSON.parse(publications) : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
"compileOnSave": false,
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
@ -6,6 +5,7 @@
|
|||||||
"noImplicitAny": false,
|
"noImplicitAny": false,
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"outDir": "./dist/out-tsc",
|
"outDir": "./dist/out-tsc",
|
||||||
|
"rootDir": "./src",
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"strictPropertyInitialization": false,
|
"strictPropertyInitialization": false,
|
||||||
@ -22,6 +22,10 @@
|
|||||||
"lib": [
|
"lib": [
|
||||||
"es2018",
|
"es2018",
|
||||||
"dom"
|
"dom"
|
||||||
|
],
|
||||||
|
"types": [
|
||||||
|
"jasmine",
|
||||||
|
"node"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"angularCompilerOptions": {
|
"angularCompilerOptions": {
|
||||||
@ -30,4 +34,4 @@
|
|||||||
"strictInputAccessModifiers": true,
|
"strictInputAccessModifiers": true,
|
||||||
"strictTemplates": true
|
"strictTemplates": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user