Compare commits
76 Commits
main
...
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 | |||
| 6d46457973 | |||
| 46234722f8 | |||
| 931e49e58b | |||
| 434d95eeaf | |||
| 91cb073c78 | |||
| 53ba9d1beb | |||
| 953c9dea16 | |||
| 3d1fab5c72 | |||
| ed17ec6c7e | |||
| 74ea5a62bf | |||
| 42ed53db2c | |||
| 41824cfc38 | |||
| 9643c762d5 | |||
| 1386459e03 | |||
| f5210206f6 | |||
| 0ad84e030a | |||
| 5a88ba6993 | |||
| b5df742a80 | |||
| e6b9a44f9b | |||
| 677462879e | |||
| c7bf66814f | |||
| 90e4c719fb | |||
| 3804f39632 | |||
| 0c3054df1b | |||
| 279f025432 | |||
| 94c4f03455 | |||
| 8e20b100eb | |||
| 10ee87fce4 | |||
| 10b80e12ba | |||
| 43ed620a0a | |||
| 7763940b2a | |||
| 2f6c0a08b4 | |||
| 55bacb9d2d | |||
| 46343199d8 | |||
| 45640454bc | |||
| e1abf8631f | |||
| 93917b63e1 | |||
| b81b4b49ae | |||
| df444a5db2 | |||
| d2dc7eb83b | |||
| c0e6685005 | |||
| 189d4cec77 | |||
| 464531aad6 | |||
| 158f97c657 | |||
| 1728167833 | |||
| 6525c50679 | |||
| badde1665d | |||
| 0e45929b5e | |||
| e72a3ea6ba | |||
| 106d42860c | |||
| b8b004bf31 |
@ -1,26 +1,70 @@
|
|||||||
version: '3.1'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
cmc-new-backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./support-portal-backend
|
context: support-portal-backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "8070:8080"
|
- "8080:8080"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_HOST=mysql-common-mysql-1
|
MYSQL_HOST: db
|
||||||
- SPRING_DATASOURCE_URL=jdbc:mysql://mysql-common-mysql-1:3306/demo
|
MYSQL_USER: support_portal_user
|
||||||
- SPRING_DATASOURCE_USERNAME=youruser
|
MYSQL_PASSWORD: support_portal_password
|
||||||
- SPRING_DATASOURCE_PASSWORD=youruserpassword
|
MYSQL_DATABASE: support-portal
|
||||||
|
|
||||||
|
# ✅ Activate production profile and enforce HTTPS base URL
|
||||||
|
SPRING_PROFILES_ACTIVE: production
|
||||||
|
APP_BASE_URL: https://cmcbackend.rootxwire.com
|
||||||
|
|
||||||
|
# ✅ Optional: ensures Spring detects HTTPS correctly behind reverse proxy
|
||||||
|
SERVER_FORWARD_HEADERS_STRATEGY: native
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Persist uploaded images and files
|
||||||
|
- blog-uploads:/app/uploads
|
||||||
networks:
|
networks:
|
||||||
- cmc-forntend
|
- angular-spring
|
||||||
- mysql-common_mynetwork
|
- spring-mysql
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mysql:8.0.19
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
MYSQL_USER: support_portal_user
|
||||||
|
MYSQL_PASSWORD: support_portal_password
|
||||||
|
MYSQL_DATABASE: support-portal
|
||||||
|
MYSQL_ROOT_PASSWORD: root_password
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"]
|
||||||
|
interval: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/mysql
|
||||||
|
networks:
|
||||||
|
- spring-mysql
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: support-portal-frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8072:80"
|
||||||
|
networks:
|
||||||
|
- angular-spring
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db-data: {}
|
db-data: {}
|
||||||
|
blog-uploads: {}
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
cmc-forntend: {}
|
angular-spring: {}
|
||||||
mysql-common_mynetwork:
|
spring-mysql: {}
|
||||||
external: true
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ services:
|
|||||||
image: adminer
|
image: adminer
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 8081:8080
|
- 8082:8080
|
||||||
|
|
||||||
#https://medium.com/dandelion-tutorials/using-s3-localstack-with-spring-boot-and-r2dbc-5ea201a18aea
|
#https://medium.com/dandelion-tutorials/using-s3-localstack-with-spring-boot-and-r2dbc-5ea201a18aea
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,46 @@
|
|||||||
FROM --platform=$BUILDPLATFORM maven:3.8.5-eclipse-temurin-17 AS builder
|
# =======================
|
||||||
WORKDIR /workdir/server
|
# Builder stage
|
||||||
COPY pom.xml /workdir/server/pom.xml
|
# =======================
|
||||||
RUN mvn dependency:go-offline
|
FROM maven:3.8.5-eclipse-temurin-17 AS builder
|
||||||
|
|
||||||
COPY src /workdir/server/src
|
WORKDIR /workdir/server
|
||||||
RUN mvn package -Dmaven.test.skip=true
|
|
||||||
|
# Copy only pom.xml first for better layer caching
|
||||||
|
COPY pom.xml .
|
||||||
|
|
||||||
|
# Download dependencies with optimization flags
|
||||||
|
RUN mvn dependency:resolve dependency:resolve-plugins -B -T 1C
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN mvn package -B -T 1C -DskipTests -Dmaven.javadoc.skip=true
|
||||||
|
|
||||||
|
# Verify the JAR was created
|
||||||
RUN ls -la target/
|
RUN ls -la target/
|
||||||
|
|
||||||
|
# =======================
|
||||||
|
# Runtime stage
|
||||||
|
# =======================
|
||||||
FROM eclipse-temurin:17-jre-focal
|
FROM eclipse-temurin:17-jre-focal
|
||||||
|
|
||||||
|
# Expose port
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
VOLUME /tmp
|
|
||||||
COPY --from=builder /workdir/server/target/*.jar /app/app.jar
|
# Create app and uploads directories
|
||||||
ENTRYPOINT ["java","-jar","/app/app.jar"]
|
RUN mkdir -p /app && \
|
||||||
|
mkdir -p /app/uploads && \
|
||||||
|
chmod 755 /app/uploads
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the JAR from builder stage
|
||||||
|
COPY --from=builder /workdir/server/target/*.jar /app/
|
||||||
|
|
||||||
|
# Create volume for uploads (optional but recommended)
|
||||||
|
VOLUME ["/app/uploads"]
|
||||||
|
|
||||||
|
# Entry point: dynamically run the first JAR in /app
|
||||||
|
ENTRYPOINT ["sh", "-c", "java -jar /app/$(ls /app | grep .jar | head -n1)"]
|
||||||
@ -166,16 +166,20 @@
|
|||||||
</dependencyManagement>
|
</dependencyManagement>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
<finalName>support-portal-backend</finalName>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
<!-- Spring Boot plugin (repackage to make it executable) -->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<version>2.5.4</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>repackage</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
<configuration>
|
<configuration>
|
||||||
<excludes>
|
<excludes>
|
||||||
<exclude>
|
<exclude>
|
||||||
@ -183,11 +187,10 @@
|
|||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
</exclude>
|
</exclude>
|
||||||
</excludes>
|
</excludes>
|
||||||
<!-- <executable>true</executable>-->
|
|
||||||
<finalName>support-portal</finalName>
|
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
|
||||||
|
<!-- Compiler plugin for MapStruct and Lombok -->
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-compiler-plugin</artifactId>
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
@ -206,61 +209,13 @@
|
|||||||
<path>
|
<path>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
<version>${lombok.version}</version>
|
<version>1.18.28</version>
|
||||||
</path>
|
</path>
|
||||||
|
|
||||||
</annotationProcessorPaths>
|
</annotationProcessorPaths>
|
||||||
|
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
|
||||||
<plugin>
|
|
||||||
<groupId>io.fabric8</groupId>
|
|
||||||
<artifactId>docker-maven-plugin</artifactId>
|
|
||||||
<version>0.33.0</version>
|
|
||||||
<configuration>
|
|
||||||
<dockerHost>http://dockerapp.shyshkin.net:2375</dockerHost>
|
|
||||||
<verbose>true</verbose>
|
|
||||||
<containerNamePattern>%a</containerNamePattern>
|
|
||||||
<images>
|
|
||||||
<image>
|
|
||||||
<name>${docker.image.prefix}/${docker.image.name}</name>
|
|
||||||
<alias>${docker.image.name}</alias>
|
|
||||||
<build>
|
|
||||||
<assembly>
|
|
||||||
<descriptorRef>artifact</descriptorRef>
|
|
||||||
</assembly>
|
|
||||||
<dockerFile>Dockerfile</dockerFile>
|
|
||||||
<tags>
|
|
||||||
<tag>latest</tag>
|
|
||||||
<tag>${project.version}</tag>
|
|
||||||
</tags>
|
|
||||||
</build>
|
|
||||||
<run>
|
|
||||||
<ports>
|
|
||||||
<port>443:8080</port>
|
|
||||||
</ports>
|
|
||||||
<env>
|
|
||||||
<SPRING_PROFILES_ACTIVE>aws-rds,image-s3</SPRING_PROFILES_ACTIVE>
|
|
||||||
</env>
|
|
||||||
<restartPolicy>
|
|
||||||
<name>always</name>
|
|
||||||
</restartPolicy>
|
|
||||||
<volumes>
|
|
||||||
<bind>
|
|
||||||
<volume>/home/ec2-user/supportportal:/root/supportportal</volume>
|
|
||||||
</bind>
|
|
||||||
</volumes>
|
|
||||||
</run>
|
|
||||||
</image>
|
|
||||||
</images>
|
|
||||||
</configuration>
|
|
||||||
</plugin>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import net.shyshkin.study.fullstack.supportportal.backend.filter.JwtAuthenticati
|
|||||||
import net.shyshkin.study.fullstack.supportportal.backend.filter.JwtAuthorizationFilter;
|
import net.shyshkin.study.fullstack.supportportal.backend.filter.JwtAuthorizationFilter;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.security.authentication.AuthenticationManager;
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
|
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
|
||||||
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
|
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
|
||||||
@ -17,16 +18,18 @@ import org.springframework.security.config.http.SessionCreationPolicy;
|
|||||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.Arrays;
|
||||||
|
|
||||||
import static org.springframework.http.HttpMethod.*;
|
import static org.springframework.http.HttpMethod.POST;
|
||||||
|
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@EnableGlobalMethodSecurity(prePostEnabled = true)
|
@EnableGlobalMethodSecurity(prePostEnabled = true)
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Configuration
|
||||||
public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
||||||
|
|
||||||
private final JwtAuthorizationFilter jwtAuthorizationFilter;
|
private final JwtAuthorizationFilter jwtAuthorizationFilter;
|
||||||
@ -38,17 +41,19 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
|||||||
@Value("${app.public-urls}")
|
@Value("${app.public-urls}")
|
||||||
private String[] publicUrls;
|
private String[] publicUrls;
|
||||||
|
|
||||||
|
@Value("${app.cors.allowed-origins}")
|
||||||
|
private String[] allowedOrigins;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void configure(HttpSecurity http) throws Exception {
|
protected void configure(HttpSecurity http) throws Exception {
|
||||||
|
|
||||||
http.csrf().disable();
|
http.csrf().disable();
|
||||||
|
|
||||||
http.cors();
|
http.cors();
|
||||||
|
|
||||||
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
|
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
|
||||||
|
|
||||||
http.authorizeRequests()
|
http.authorizeRequests()
|
||||||
.antMatchers(publicUrls).permitAll()
|
.antMatchers(publicUrls).permitAll()
|
||||||
|
.antMatchers(POST, "/api/files/upload").permitAll() // Allow file uploads
|
||||||
.anyRequest().authenticated();
|
.anyRequest().authenticated();
|
||||||
|
|
||||||
http.exceptionHandling()
|
http.exceptionHandling()
|
||||||
@ -60,9 +65,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
|
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
|
||||||
auth
|
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);
|
||||||
.userDetailsService(userDetailsService)
|
|
||||||
.passwordEncoder(passwordEncoder);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@ -72,24 +75,17 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public WebMvcConfigurer corsConfigurer(@Value("${app.cors.allowed-origins}") String[] allowedOrigins) {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
return new WebMvcConfigurer() {
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
@Override
|
config.setAllowedOrigins(Arrays.asList(allowedOrigins));
|
||||||
public void addCorsMappings(CorsRegistry registry) {
|
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
registry.addMapping("/user/login")
|
config.setAllowedHeaders(Arrays.asList("*"));
|
||||||
.allowedOrigins(allowedOrigins)
|
config.setExposedHeaders(Arrays.asList(SecurityConstants.JWT_TOKEN_HEADER));
|
||||||
.exposedHeaders(SecurityConstants.JWT_TOKEN_HEADER);
|
config.setAllowCredentials(true);
|
||||||
|
config.setMaxAge(3600L); // 1 hour
|
||||||
|
|
||||||
String[] allowedMethods = List.of(GET, POST, PUT, DELETE)
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
.stream()
|
source.registerCorsConfiguration("/**", config);
|
||||||
.map(Enum::name)
|
return source;
|
||||||
.toArray(String[]::new);
|
|
||||||
|
|
||||||
registry.addMapping("/**")
|
|
||||||
.allowedMethods(allowedMethods)
|
|
||||||
.allowedOrigins(allowedOrigins);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
package net.shyshkin.study.fullstack.supportportal.backend.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class WebConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
@Value("${file.upload.directory}")
|
||||||
|
private String uploadDirectory;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||||
|
// Ensure the path ends with a separator
|
||||||
|
String uploadPath = uploadDirectory;
|
||||||
|
if (!uploadPath.endsWith(File.separator)) {
|
||||||
|
uploadPath += File.separator;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve uploaded files under /uploads/**
|
||||||
|
registry.addResourceHandler("/uploads/**")
|
||||||
|
.addResourceLocations("file:" + uploadPath)
|
||||||
|
.setCachePeriod(3600); // Cache for 1 hour
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,10 +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/";
|
||||||
|
|
||||||
// public static final String PROFESSOR_IMAGE_PATH = "/professor/image/";
|
// Professor-specific constants
|
||||||
// public static final String PROFESSOR_FOLDER = System.getProperty("user.home") + "/supportportal/professor/";
|
public static final String PROFESSOR_IMAGE_PATH = "/professor/image/";
|
||||||
// public static final String DEFAULT_PROFESSOR_IMAGE_URI_PATTERN = "/professor/%s/profile-image";
|
public static final String PROFESSOR_FOLDER = "/app/uploads/professor/";
|
||||||
// public static final String PROFESSOR_IMAGE_FILENAME = "avatar.jpg";
|
public static final String DEFAULT_PROFESSOR_IMAGE_URI_PATTERN = "/professor/%s/profile-image";
|
||||||
|
public static final String PROFESSOR_IMAGE_FILENAME = "avatar.jpg";
|
||||||
|
|
||||||
|
// ✅ NEW: Hero Image constants
|
||||||
|
public static final String HERO_IMAGE_PATH = "/hero/image/";
|
||||||
|
public static final String HERO_FOLDER = "/app/uploads/hero/";
|
||||||
|
public static final String DEFAULT_HERO_IMAGE_URI_PATTERN = "/hero/%s/image";
|
||||||
|
public static final String HERO_IMAGE_PREFIX = "hero_";
|
||||||
}
|
}
|
||||||
@ -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() {
|
||||||
@ -62,15 +73,20 @@ public class CourseController {
|
|||||||
course.setImageUrl(courseDto.getImageUrl());
|
course.setImageUrl(courseDto.getImageUrl());
|
||||||
course.setEligibility(courseDto.getEligibility());
|
course.setEligibility(courseDto.getEligibility());
|
||||||
course.setObjectives(courseDto.getObjectives());
|
course.setObjectives(courseDto.getObjectives());
|
||||||
course.setActive(courseDto.isActive());
|
|
||||||
|
|
||||||
courseRepository.save(course);
|
// FIXED: Use getIsActive() and setIsActive() for both DTO and Entity
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).build();
|
// Handle null case - default to true
|
||||||
|
Boolean isActiveValue = courseDto.getIsActive() != null ? courseDto.getIsActive() : true;
|
||||||
|
course.setIsActive(isActiveValue);
|
||||||
|
|
||||||
|
Course savedCourse = courseRepository.save(course);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(savedCourse);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public ResponseEntity<?> updateCourse(@PathVariable Long id, @RequestBody CourseDto courseDto) {
|
public ResponseEntity<?> updateCourse(@PathVariable Long id, @RequestBody CourseDto courseDto) {
|
||||||
Course course = courseRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException("Course not found"));
|
Course course = courseRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Course not found"));
|
||||||
|
|
||||||
course.setTitle(courseDto.getTitle());
|
course.setTitle(courseDto.getTitle());
|
||||||
course.setDescription(courseDto.getDescription());
|
course.setDescription(courseDto.getDescription());
|
||||||
@ -84,10 +100,14 @@ public class CourseController {
|
|||||||
course.setImageUrl(courseDto.getImageUrl());
|
course.setImageUrl(courseDto.getImageUrl());
|
||||||
course.setEligibility(courseDto.getEligibility());
|
course.setEligibility(courseDto.getEligibility());
|
||||||
course.setObjectives(courseDto.getObjectives());
|
course.setObjectives(courseDto.getObjectives());
|
||||||
course.setActive(courseDto.isActive());
|
|
||||||
|
|
||||||
courseRepository.save(course);
|
// FIXED: Use getIsActive() and setIsActive() for both DTO and Entity
|
||||||
return ResponseEntity.ok().build();
|
// Handle null case - default to true
|
||||||
|
Boolean isActiveValue = courseDto.getIsActive() != null ? courseDto.getIsActive() : true;
|
||||||
|
course.setIsActive(isActiveValue);
|
||||||
|
|
||||||
|
Course updatedCourse = courseRepository.save(course);
|
||||||
|
return ResponseEntity.ok(updatedCourse);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
|
|||||||
@ -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,14 +11,19 @@ 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 = {
|
||||||
public class FileController { // Changed from FileUploadController to FileController
|
"https://cmcbackend.rootxwire.com",
|
||||||
|
"https://maincmc.rootxwire.com",
|
||||||
|
"https://cmctrauma.com"
|
||||||
|
})
|
||||||
|
public class FileController {
|
||||||
|
|
||||||
@Value("${file.upload.directory:uploads}")
|
@Value("${file.upload.directory:uploads}")
|
||||||
private String uploadDirectory;
|
private String uploadDirectory;
|
||||||
@ -26,45 +31,86 @@ public class FileController { // Changed from FileUploadController to FileContro
|
|||||||
@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());
|
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
// Return file URL - use generic /files/ path for documents
|
String fileUrl = baseUrl + "/uploads/" + uniqueFilename;
|
||||||
String fileUrl = "/uploads/" + uniqueFilename;
|
|
||||||
Map<String, String> response = new HashMap<>();
|
|
||||||
response.put("url", fileUrl);
|
|
||||||
response.put("filename", uniqueFilename);
|
|
||||||
|
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"url", fileUrl,
|
||||||
|
"filename", uniqueFilename
|
||||||
|
));
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
@ -72,49 +118,96 @@ public class FileController { // Changed from FileUploadController to FileContro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updated validation method
|
@DeleteMapping("/images/{filename}")
|
||||||
private boolean isValidFileType(String contentType) {
|
public ResponseEntity<?> deleteImage(@PathVariable String filename) {
|
||||||
return contentType != null && (
|
try {
|
||||||
// Image types
|
// Prevent path traversal
|
||||||
contentType.equals("image/jpeg") ||
|
String sanitized = Paths.get(filename).getFileName().toString();
|
||||||
contentType.equals("image/jpg") ||
|
|
||||||
contentType.equals("image/png") ||
|
Path uploadPath = Paths.get(uploadDirectory).normalize();
|
||||||
contentType.equals("image/gif") ||
|
Path filePath = uploadPath.resolve(sanitized).normalize();
|
||||||
contentType.equals("image/webp") ||
|
|
||||||
// Document types for resumes
|
if (!filePath.startsWith(uploadPath)) {
|
||||||
contentType.equals("application/pdf") ||
|
return ResponseEntity.badRequest().body("Invalid filename");
|
||||||
contentType.equals("application/msword") ||
|
}
|
||||||
contentType.equals("application/vnd.openxmlformats-officedocument.wordprocessingml.document")
|
|
||||||
);
|
if (!Files.exists(filePath)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
Files.delete(filePath);
|
||||||
|
return ResponseEntity.ok(Map.of("message", "File deleted successfully"));
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body("Failed to delete file: " + e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -22,7 +22,6 @@ public class JobController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private JobRepository jobRepository;
|
private JobRepository jobRepository;
|
||||||
|
|
||||||
// Get all active jobs (for public display)
|
|
||||||
@GetMapping("/active")
|
@GetMapping("/active")
|
||||||
public ResponseEntity<List<Job>> getActiveJobs() {
|
public ResponseEntity<List<Job>> getActiveJobs() {
|
||||||
try {
|
try {
|
||||||
@ -33,13 +32,11 @@ public class JobController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all jobs (for admin)
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<Job> getAllJobs() {
|
public List<Job> getAllJobs() {
|
||||||
return jobRepository.findAll();
|
return jobRepository.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get a single job by ID
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public ResponseEntity<Job> getJobById(@PathVariable Long id) {
|
public ResponseEntity<Job> getJobById(@PathVariable Long id) {
|
||||||
return jobRepository.findById(id)
|
return jobRepository.findById(id)
|
||||||
@ -51,8 +48,7 @@ public class JobController {
|
|||||||
public ResponseEntity<?> createJob(@RequestBody JobDto jobDto) {
|
public ResponseEntity<?> createJob(@RequestBody JobDto jobDto) {
|
||||||
System.out.println("=== BACKEND DEBUG ===");
|
System.out.println("=== BACKEND DEBUG ===");
|
||||||
System.out.println("Received JobDto: " + jobDto);
|
System.out.println("Received JobDto: " + jobDto);
|
||||||
System.out.println("isActive value: " + jobDto.isActive());
|
System.out.println("isActive value: " + jobDto.getIsActive());
|
||||||
// Remove the .getClass() line since boolean is a primitive
|
|
||||||
|
|
||||||
Job job = new Job();
|
Job job = new Job();
|
||||||
job.setTitle(jobDto.getTitle());
|
job.setTitle(jobDto.getTitle());
|
||||||
@ -64,19 +60,22 @@ public class JobController {
|
|||||||
job.setDescription(jobDto.getDescription());
|
job.setDescription(jobDto.getDescription());
|
||||||
job.setRequirements(jobDto.getRequirements());
|
job.setRequirements(jobDto.getRequirements());
|
||||||
job.setResponsibilities(jobDto.getResponsibilities());
|
job.setResponsibilities(jobDto.getResponsibilities());
|
||||||
job.setActive(jobDto.isActive());
|
|
||||||
|
|
||||||
System.out.println("Job before save - isActive: " + job.isActive());
|
Boolean isActiveValue = jobDto.getIsActive() != null ? jobDto.getIsActive() : true;
|
||||||
|
job.setIsActive(isActiveValue);
|
||||||
|
|
||||||
|
System.out.println("Job before save - isActive: " + job.getIsActive());
|
||||||
|
|
||||||
Job savedJob = jobRepository.save(job);
|
Job savedJob = jobRepository.save(job);
|
||||||
System.out.println("Job after save - isActive: " + savedJob.isActive());
|
System.out.println("Job after save - isActive: " + savedJob.getIsActive());
|
||||||
|
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).build();
|
return ResponseEntity.status(HttpStatus.CREATED).body(savedJob);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public ResponseEntity<?> updateJob(@PathVariable Long id, @RequestBody JobDto jobDto) {
|
public ResponseEntity<?> updateJob(@PathVariable Long id, @RequestBody JobDto jobDto) {
|
||||||
Job job = jobRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException("Job not found"));
|
Job job = jobRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Job not found"));
|
||||||
|
|
||||||
job.setTitle(jobDto.getTitle());
|
job.setTitle(jobDto.getTitle());
|
||||||
job.setDepartment(jobDto.getDepartment());
|
job.setDepartment(jobDto.getDepartment());
|
||||||
@ -87,10 +86,12 @@ public class JobController {
|
|||||||
job.setDescription(jobDto.getDescription());
|
job.setDescription(jobDto.getDescription());
|
||||||
job.setRequirements(jobDto.getRequirements());
|
job.setRequirements(jobDto.getRequirements());
|
||||||
job.setResponsibilities(jobDto.getResponsibilities());
|
job.setResponsibilities(jobDto.getResponsibilities());
|
||||||
job.setActive(jobDto.isActive());
|
|
||||||
|
|
||||||
jobRepository.save(job);
|
Boolean isActiveValue = jobDto.getIsActive() != null ? jobDto.getIsActive() : true;
|
||||||
return ResponseEntity.ok().build();
|
job.setIsActive(isActiveValue);
|
||||||
|
|
||||||
|
Job updatedJob = jobRepository.save(job);
|
||||||
|
return ResponseEntity.ok(updatedJob);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
|
|||||||
@ -0,0 +1,63 @@
|
|||||||
|
package net.shyshkin.study.fullstack.supportportal.backend.controller;
|
||||||
|
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.Milestone;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.dto.MilestoneDTO;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.service.MilestoneService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import javax.validation.Valid;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/milestones")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@CrossOrigin(origins = {"http://localhost:4200", "http://localhost:3000"})
|
||||||
|
public class MilestoneController {
|
||||||
|
|
||||||
|
private final MilestoneService milestoneService;
|
||||||
|
|
||||||
|
// Public endpoint - for user interface
|
||||||
|
@GetMapping("/public")
|
||||||
|
public ResponseEntity<List<Milestone>> getActiveMilestones() {
|
||||||
|
return ResponseEntity.ok(milestoneService.getActiveMilestones());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin endpoints - NO SECURITY (matching EventController pattern)
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<List<Milestone>> getAllMilestones() {
|
||||||
|
return ResponseEntity.ok(milestoneService.getAllMilestones());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<Milestone> getMilestoneById(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(milestoneService.getMilestoneById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<Milestone> createMilestone(@Valid @RequestBody MilestoneDTO milestoneDTO) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED)
|
||||||
|
.body(milestoneService.createMilestone(milestoneDTO));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<Milestone> updateMilestone(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@Valid @RequestBody MilestoneDTO milestoneDTO) {
|
||||||
|
return ResponseEntity.ok(milestoneService.updateMilestone(id, milestoneDTO));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Void> deleteMilestone(@PathVariable Long id) {
|
||||||
|
milestoneService.deleteMilestone(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// @PutMapping("/reorder")
|
||||||
|
// public ResponseEntity<Void> reorderMilestones(@RequestBody List<Long> orderedIds) {
|
||||||
|
// milestoneService.reorderMilestones(orderedIds);
|
||||||
|
// return ResponseEntity.ok().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,7 +34,8 @@ 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")
|
||||||
@ -43,7 +45,6 @@ public class ProfessorResource {
|
|||||||
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);
|
||||||
@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.shyshkin.study.fullstack.supportportal.controller;
|
||||||
|
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple test controller to verify Spring Boot is working
|
||||||
|
* Test URL: http://localhost:8080/api/test
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api")
|
||||||
|
public class TestController {
|
||||||
|
|
||||||
|
@GetMapping("/test")
|
||||||
|
public Map<String, String> test() {
|
||||||
|
Map<String, String> response = new HashMap<>();
|
||||||
|
response.put("status", "success");
|
||||||
|
response.put("message", "Spring Boot is working!");
|
||||||
|
response.put("timestamp", String.valueOf(System.currentTimeMillis()));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/health")
|
||||||
|
public String health() {
|
||||||
|
return "Application is healthy!";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package net.shyshkin.study.fullstack.supportportal.backend.controller;
|
||||||
|
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.Testimonial;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.dto.TestimonialDTO;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.service.TestimonialService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import javax.validation.Valid;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/testimonials")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@CrossOrigin(origins = {"http://localhost:4200", "http://localhost:3000"})
|
||||||
|
public class TestimonialController {
|
||||||
|
|
||||||
|
private final TestimonialService testimonialService;
|
||||||
|
|
||||||
|
// Public endpoint - for user interface
|
||||||
|
@GetMapping("/public")
|
||||||
|
public ResponseEntity<List<Testimonial>> getActiveTestimonials() {
|
||||||
|
return ResponseEntity.ok(testimonialService.getActiveTestimonials());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin endpoints - no security (matching your pattern)
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<List<Testimonial>> getAllTestimonials() {
|
||||||
|
return ResponseEntity.ok(testimonialService.getAllTestimonials());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<Testimonial> getTestimonialById(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(testimonialService.getTestimonialById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<Testimonial> createTestimonial(@Valid @RequestBody TestimonialDTO testimonialDTO) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED)
|
||||||
|
.body(testimonialService.createTestimonial(testimonialDTO));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<Testimonial> updateTestimonial(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@Valid @RequestBody TestimonialDTO testimonialDTO) {
|
||||||
|
return ResponseEntity.ok(testimonialService.updateTestimonial(id, testimonialDTO));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Void> deleteTestimonial(@PathVariable Long id) {
|
||||||
|
testimonialService.deleteTestimonial(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// Course.java - Entity for courses
|
||||||
package net.shyshkin.study.fullstack.supportportal.backend.domain;
|
package net.shyshkin.study.fullstack.supportportal.backend.domain;
|
||||||
|
|
||||||
import javax.persistence.*;
|
import javax.persistence.*;
|
||||||
@ -21,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)
|
||||||
@ -56,6 +57,7 @@ public class Course extends BaseEntity {
|
|||||||
@Column(name = "objective", columnDefinition = "TEXT")
|
@Column(name = "objective", columnDefinition = "TEXT")
|
||||||
private List<String> objectives;
|
private List<String> objectives;
|
||||||
|
|
||||||
|
// FIXED: Changed from primitive boolean to Boolean wrapper class
|
||||||
@Column(name = "is_active", nullable = false)
|
@Column(name = "is_active", nullable = false)
|
||||||
private boolean isActive = true;
|
private Boolean isActive = true;
|
||||||
}
|
}
|
||||||
@ -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,6 +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;
|
||||||
|
|
||||||
|
// Registration/Booking Link
|
||||||
|
@Column(name = "book_seat_link", length = 500)
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -49,6 +49,8 @@ public class Job extends BaseEntity {
|
|||||||
@Column(name = "responsibility")
|
@Column(name = "responsibility")
|
||||||
private List<String> responsibilities;
|
private List<String> responsibilities;
|
||||||
|
|
||||||
|
// CRITICAL FIX: Changed from primitive boolean to Boolean wrapper class
|
||||||
|
// This ensures proper handling of the value from JSON and prevents default false
|
||||||
@Column(name = "is_active", nullable = false)
|
@Column(name = "is_active", nullable = false)
|
||||||
private boolean isActive = true;
|
private Boolean isActive = true;
|
||||||
}
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
package net.shyshkin.study.fullstack.supportportal.backend.domain;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.*;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "milestones")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class Milestone implements Serializable {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 500)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
// @Column(nullable = false)
|
||||||
|
// private Integer displayOrder;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Boolean isActive = true;
|
||||||
|
|
||||||
|
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd", timezone = "UTC")
|
||||||
|
@Temporal(TemporalType.DATE)
|
||||||
|
private Date milestoneDate;
|
||||||
|
|
||||||
|
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "UTC")
|
||||||
|
@Column(nullable = false, updatable = false)
|
||||||
|
private Date createdAt;
|
||||||
|
|
||||||
|
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "UTC")
|
||||||
|
private Date updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdAt = new Date();
|
||||||
|
updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
updatedAt = 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,6 +44,15 @@ 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;
|
||||||
@ -68,20 +75,16 @@ public class Professor implements Serializable {
|
|||||||
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
package net.shyshkin.study.fullstack.supportportal.backend.domain;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@Entity
|
||||||
|
@Table(name = "testimonials")
|
||||||
|
public class Testimonial {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 100)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Integer age;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 200)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String story;
|
||||||
|
|
||||||
|
@Column(nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String outcome;
|
||||||
|
|
||||||
|
@Column(nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String impact;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 100)
|
||||||
|
private String category;
|
||||||
|
|
||||||
|
@Column(name = "is_active")
|
||||||
|
private Boolean isActive = true;
|
||||||
|
|
||||||
|
@Column(name = "created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdAt = LocalDateTime.now();
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -46,4 +46,5 @@ public class User implements Serializable {
|
|||||||
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
|
||||||
@ -40,17 +39,10 @@ public class CourseDto {
|
|||||||
private String imageUrl;
|
private String imageUrl;
|
||||||
private List<String> eligibility;
|
private List<String> eligibility;
|
||||||
private List<String> objectives;
|
private List<String> objectives;
|
||||||
private boolean active;
|
|
||||||
|
|
||||||
public boolean isActive() {
|
// FIXED: Changed from primitive boolean to Boolean wrapper
|
||||||
return active;
|
// Changed field name from "active" to "isActive"
|
||||||
}
|
// Removed all manual getter/setter methods
|
||||||
|
// Lombok @Data will generate: getIsActive() and setIsActive()
|
||||||
public void setActive(boolean active) {
|
private Boolean isActive;
|
||||||
this.active = active;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setIsActive(boolean active) {
|
|
||||||
this.active = active;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -37,20 +37,5 @@ public class JobDto {
|
|||||||
private List<String> requirements;
|
private List<String> requirements;
|
||||||
private List<String> responsibilities;
|
private List<String> responsibilities;
|
||||||
|
|
||||||
// Explicit boolean field handling (remove @Data for this field)
|
private Boolean isActive;
|
||||||
private boolean active; // Change from isActive to active
|
|
||||||
|
|
||||||
// Explicit getters and setters for the boolean field
|
|
||||||
public boolean isActive() {
|
|
||||||
return active;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setActive(boolean active) {
|
|
||||||
this.active = active;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Jackson JSON deserialization
|
|
||||||
public void setIsActive(boolean active) {
|
|
||||||
this.active = active;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package net.shyshkin.study.fullstack.supportportal.backend.domain.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.validation.constraints.NotBlank;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class MilestoneDTO {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@NotBlank(message = "Title is required")
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@NotBlank(message = "Description is required")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
// @NotNull(message = "Display order is required")
|
||||||
|
// private Integer displayOrder;
|
||||||
|
|
||||||
|
@NotNull(message = "Active status is required")
|
||||||
|
private Boolean isActive;
|
||||||
|
|
||||||
|
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
|
||||||
|
private Date milestoneDate;
|
||||||
|
}
|
||||||
@ -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,47 @@
|
|||||||
|
package net.shyshkin.study.fullstack.supportportal.backend.domain.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.validation.constraints.NotBlank;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
import javax.validation.constraints.Min;
|
||||||
|
import javax.validation.constraints.Max;
|
||||||
|
import javax.validation.constraints.Size;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class TestimonialDTO {
|
||||||
|
|
||||||
|
@NotBlank(message = "Name is required")
|
||||||
|
@Size(max = 100, message = "Name must not exceed 100 characters")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@NotNull(message = "Age is required")
|
||||||
|
@Min(value = 1, message = "Age must be at least 1")
|
||||||
|
@Max(value = 120, message = "Age must not exceed 120")
|
||||||
|
private Integer age;
|
||||||
|
|
||||||
|
@NotBlank(message = "Title is required")
|
||||||
|
@Size(max = 200, message = "Title must not exceed 200 characters")
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@NotBlank(message = "Story is required")
|
||||||
|
private String story;
|
||||||
|
|
||||||
|
@NotBlank(message = "Outcome is required")
|
||||||
|
private String outcome;
|
||||||
|
|
||||||
|
@NotBlank(message = "Impact is required")
|
||||||
|
private String impact;
|
||||||
|
|
||||||
|
@NotBlank(message = "Category is required")
|
||||||
|
@Size(max = 100, message = "Category must not exceed 100 characters")
|
||||||
|
private String category;
|
||||||
|
|
||||||
|
private Boolean isActive;
|
||||||
|
}
|
||||||
@ -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,21 @@
|
|||||||
|
package net.shyshkin.study.fullstack.supportportal.backend.exception;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
import javax.persistence.EntityNotFoundException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(EntityNotFoundException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleEntityNotFound(EntityNotFoundException ex) {
|
||||||
|
Map<String, String> error = new HashMap<>();
|
||||||
|
error.put("error", ex.getMessage());
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,12 @@
|
|||||||
|
package net.shyshkin.study.fullstack.supportportal.backend.exception.domain;
|
||||||
|
|
||||||
|
public class MilestoneNotFoundException extends RuntimeException {
|
||||||
|
|
||||||
|
public MilestoneNotFoundException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MilestoneNotFoundException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package net.shyshkin.study.fullstack.supportportal.backend.exception.domain;
|
||||||
|
|
||||||
|
public class TestimonialNotFoundException extends RuntimeException {
|
||||||
|
|
||||||
|
public TestimonialNotFoundException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TestimonialNotFoundException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package net.shyshkin.study.fullstack.supportportal.backend.repository;
|
||||||
|
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.Milestone;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface MilestoneRepository extends JpaRepository<Milestone, Long> {
|
||||||
|
|
||||||
|
// List<Milestone> findAllByIsActiveTrueOrderByDisplayOrderAsc();
|
||||||
|
|
||||||
|
// List<Milestone> findAllByOrderByDisplayOrderAsc();
|
||||||
|
List<Milestone> findAllByIsActiveTrue();
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|
||||||
@ -26,14 +28,34 @@ 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,17 @@
|
|||||||
|
package net.shyshkin.study.fullstack.supportportal.backend.repository;
|
||||||
|
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.Testimonial;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface TestimonialRepository extends JpaRepository<Testimonial, Long> {
|
||||||
|
|
||||||
|
// Get all active testimonials
|
||||||
|
List<Testimonial> findAllByIsActiveTrue();
|
||||||
|
|
||||||
|
// Get testimonials by category
|
||||||
|
List<Testimonial> findAllByIsActiveTrueAndCategory(String category);
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package net.shyshkin.study.fullstack.supportportal.backend.service;
|
||||||
|
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.HeroImage;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface HeroImageService {
|
||||||
|
|
||||||
|
HeroImage addHeroImage(String title, String subtitle, String description, MultipartFile image) throws IOException;
|
||||||
|
|
||||||
|
HeroImage updateHeroImage(Long id, String title, String subtitle, String description, MultipartFile image) throws IOException;
|
||||||
|
|
||||||
|
HeroImage getActiveHeroImage();
|
||||||
|
|
||||||
|
HeroImage getHeroImageById(Long id);
|
||||||
|
|
||||||
|
List<HeroImage> getAllHeroImages();
|
||||||
|
|
||||||
|
void deleteHeroImage(Long id);
|
||||||
|
|
||||||
|
HeroImage setActiveHeroImage(Long id);
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package net.shyshkin.study.fullstack.supportportal.backend.service;
|
||||||
|
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.Milestone;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.dto.MilestoneDTO;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface MilestoneService {
|
||||||
|
|
||||||
|
List<Milestone> getAllMilestones();
|
||||||
|
|
||||||
|
List<Milestone> getActiveMilestones();
|
||||||
|
|
||||||
|
Milestone getMilestoneById(Long id);
|
||||||
|
|
||||||
|
Milestone createMilestone(MilestoneDTO milestoneDTO);
|
||||||
|
|
||||||
|
Milestone updateMilestone(Long id, MilestoneDTO milestoneDTO);
|
||||||
|
|
||||||
|
void deleteMilestone(Long id);
|
||||||
|
|
||||||
|
// void reorderMilestones(List<Long> orderedIds);
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
@ -31,14 +32,19 @@ public interface ProfessorService {
|
|||||||
|
|
||||||
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,20 +87,19 @@ public class ProfessorServiceImpl implements ProfessorService {
|
|||||||
.department(department)
|
.department(department)
|
||||||
.position(position)
|
.position(position)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
return addNewProfessor(professorDto);
|
return addNewProfessor(professorDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String generateDefaultProfileImageUrl(UUID professorId) {
|
private String generateDefaultProfileImageUrl(UUID professorId) {
|
||||||
return ServletUriComponentsBuilder.fromCurrentContextPath()
|
return ServletUriComponentsBuilder.fromCurrentContextPath()
|
||||||
.path(String.format(DEFAULT_USER_IMAGE_URI_PATTERN, professorId))
|
.path(String.format(DEFAULT_PROFESSOR_IMAGE_URI_PATTERN, professorId))
|
||||||
.toUriString();
|
.toUriString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String generateProfileImageUrl(UUID professorId) {
|
private String generateProfileImageUrl(UUID professorId) {
|
||||||
return ServletUriComponentsBuilder.fromCurrentContextPath()
|
return ServletUriComponentsBuilder.fromCurrentContextPath()
|
||||||
.path(String.format(DEFAULT_USER_IMAGE_URI_PATTERN, professorId))
|
.path(String.format(DEFAULT_PROFESSOR_IMAGE_URI_PATTERN, professorId))
|
||||||
.pathSegment(USER_IMAGE_FILENAME)
|
.pathSegment(PROFESSOR_IMAGE_FILENAME)
|
||||||
.toUriString();
|
.toUriString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,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
|
||||||
@ -138,7 +150,7 @@ public class ProfessorServiceImpl implements ProfessorService {
|
|||||||
throw new NotAnImageFileException(profileImage.getOriginalFilename() + " is not an image file. Please upload an image");
|
throw new NotAnImageFileException(profileImage.getOriginalFilename() + " is not an image file. Please upload an image");
|
||||||
}
|
}
|
||||||
|
|
||||||
String imageUrl = profileImageService.persistProfileImage(professor.getProfessorId(), profileImage, USER_IMAGE_FILENAME);
|
String imageUrl = profileImageService.persistProfileImage(professor.getProfessorId(), profileImage, PROFESSOR_IMAGE_FILENAME);
|
||||||
|
|
||||||
if (imageUrl == null)
|
if (imageUrl == null)
|
||||||
imageUrl = generateProfileImageUrl(professor.getProfessorId());
|
imageUrl = generateProfileImageUrl(professor.getProfessorId());
|
||||||
@ -158,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())
|
||||||
@ -183,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())
|
||||||
@ -198,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());
|
||||||
}
|
}
|
||||||
@ -213,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());
|
||||||
@ -226,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());
|
||||||
@ -236,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())
|
||||||
@ -257,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())
|
||||||
@ -278,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());
|
||||||
}
|
}
|
||||||
@ -333,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,21 @@
|
|||||||
|
package net.shyshkin.study.fullstack.supportportal.backend.service;
|
||||||
|
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.Testimonial;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.dto.TestimonialDTO;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface TestimonialService {
|
||||||
|
|
||||||
|
List<Testimonial> getAllTestimonials();
|
||||||
|
|
||||||
|
List<Testimonial> getActiveTestimonials();
|
||||||
|
|
||||||
|
Testimonial getTestimonialById(Long id);
|
||||||
|
|
||||||
|
Testimonial createTestimonial(TestimonialDTO testimonialDTO);
|
||||||
|
|
||||||
|
Testimonial updateTestimonial(Long id, TestimonialDTO testimonialDTO);
|
||||||
|
|
||||||
|
void deleteTestimonial(Long id);
|
||||||
|
}
|
||||||
@ -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,141 @@
|
|||||||
|
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.Milestone;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.dto.MilestoneDTO;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.exception.domain.MilestoneNotFoundException;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.repository.MilestoneRepository;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.service.MilestoneService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class MilestoneServiceImpl implements MilestoneService {
|
||||||
|
|
||||||
|
private final MilestoneRepository milestoneRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Milestone> getAllMilestones() {
|
||||||
|
log.info("Fetching all milestones");
|
||||||
|
List<Milestone> milestones = milestoneRepository.findAll();
|
||||||
|
return sortMilestonesByMonthYear(milestones);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Milestone> getActiveMilestones() {
|
||||||
|
log.info("Fetching active milestones");
|
||||||
|
List<Milestone> milestones = milestoneRepository.findAllByIsActiveTrue();
|
||||||
|
return sortMilestonesByMonthYear(milestones);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Milestone getMilestoneById(Long id) {
|
||||||
|
log.info("Fetching milestone with id: {}", id);
|
||||||
|
return milestoneRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new MilestoneNotFoundException("Milestone not found with id: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public Milestone createMilestone(MilestoneDTO milestoneDTO) {
|
||||||
|
log.info("Creating new milestone: {}", milestoneDTO.getTitle());
|
||||||
|
|
||||||
|
Milestone milestone = Milestone.builder()
|
||||||
|
.title(milestoneDTO.getTitle())
|
||||||
|
.description(milestoneDTO.getDescription())
|
||||||
|
.isActive(milestoneDTO.getIsActive() != null ? milestoneDTO.getIsActive() : true)
|
||||||
|
.milestoneDate(milestoneDTO.getMilestoneDate())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Milestone savedMilestone = milestoneRepository.save(milestone);
|
||||||
|
log.info("Milestone created successfully with id: {}", savedMilestone.getId());
|
||||||
|
|
||||||
|
return savedMilestone;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public Milestone updateMilestone(Long id, MilestoneDTO milestoneDTO) {
|
||||||
|
log.info("Updating milestone with id: {}", id);
|
||||||
|
|
||||||
|
Milestone milestone = getMilestoneById(id);
|
||||||
|
|
||||||
|
milestone.setTitle(milestoneDTO.getTitle());
|
||||||
|
milestone.setDescription(milestoneDTO.getDescription());
|
||||||
|
milestone.setIsActive(milestoneDTO.getIsActive());
|
||||||
|
milestone.setMilestoneDate(milestoneDTO.getMilestoneDate());
|
||||||
|
|
||||||
|
Milestone updatedMilestone = milestoneRepository.save(milestone);
|
||||||
|
log.info("Milestone updated successfully");
|
||||||
|
|
||||||
|
return updatedMilestone;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void deleteMilestone(Long id) {
|
||||||
|
log.info("Deleting milestone with id: {}", id);
|
||||||
|
|
||||||
|
Milestone milestone = getMilestoneById(id);
|
||||||
|
milestoneRepository.delete(milestone);
|
||||||
|
|
||||||
|
log.info("Milestone deleted successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
// REMOVE reorderMilestones method
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort milestones by year and month extracted from title
|
||||||
|
* Most recent first (descending order)
|
||||||
|
*/
|
||||||
|
private List<Milestone> sortMilestonesByMonthYear(List<Milestone> milestones) {
|
||||||
|
return milestones.stream()
|
||||||
|
.sorted(Comparator.comparing(this::extractYearMonth).reversed())
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract YearMonth from milestone title
|
||||||
|
* Supports formats like:
|
||||||
|
* - "July 2025"
|
||||||
|
* - "2025"
|
||||||
|
* - "December 2023"
|
||||||
|
*/
|
||||||
|
private YearMonth extractYearMonth(Milestone milestone) {
|
||||||
|
String title = milestone.getTitle();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try parsing "Month Year" format (e.g., "July 2025", "December 2023")
|
||||||
|
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMMM yyyy", Locale.ENGLISH);
|
||||||
|
return YearMonth.parse(title, formatter);
|
||||||
|
} catch (DateTimeParseException e1) {
|
||||||
|
try {
|
||||||
|
// Try parsing "Month Year" with short month (e.g., "Jul 2025")
|
||||||
|
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMM yyyy", Locale.ENGLISH);
|
||||||
|
return YearMonth.parse(title, formatter);
|
||||||
|
} catch (DateTimeParseException e2) {
|
||||||
|
try {
|
||||||
|
// Try parsing just year (e.g., "2025", "2020")
|
||||||
|
int year = Integer.parseInt(title.trim());
|
||||||
|
return YearMonth.of(year, 1); // Default to January
|
||||||
|
} catch (NumberFormatException e3) {
|
||||||
|
// If all parsing fails, default to a very old date
|
||||||
|
log.warn("Could not parse date from title: {}. Using default date.", title);
|
||||||
|
return YearMonth.of(1900, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,95 @@
|
|||||||
|
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.Testimonial;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.domain.dto.TestimonialDTO;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.exception.domain.TestimonialNotFoundException;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.repository.TestimonialRepository;
|
||||||
|
import net.shyshkin.study.fullstack.supportportal.backend.service.TestimonialService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class TestimonialServiceImpl implements TestimonialService {
|
||||||
|
|
||||||
|
private final TestimonialRepository testimonialRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Testimonial> getAllTestimonials() {
|
||||||
|
log.info("Fetching all testimonials");
|
||||||
|
return testimonialRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Testimonial> getActiveTestimonials() {
|
||||||
|
log.info("Fetching active testimonials");
|
||||||
|
return testimonialRepository.findAllByIsActiveTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Testimonial getTestimonialById(Long id) {
|
||||||
|
log.info("Fetching testimonial with id: {}", id);
|
||||||
|
return testimonialRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new TestimonialNotFoundException("Testimonial not found with id: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public Testimonial createTestimonial(TestimonialDTO testimonialDTO) {
|
||||||
|
log.info("Creating new testimonial for: {}", testimonialDTO.getName());
|
||||||
|
|
||||||
|
Testimonial testimonial = Testimonial.builder()
|
||||||
|
.name(testimonialDTO.getName())
|
||||||
|
.age(testimonialDTO.getAge())
|
||||||
|
.title(testimonialDTO.getTitle())
|
||||||
|
.story(testimonialDTO.getStory())
|
||||||
|
.outcome(testimonialDTO.getOutcome())
|
||||||
|
.impact(testimonialDTO.getImpact())
|
||||||
|
.category(testimonialDTO.getCategory())
|
||||||
|
.isActive(testimonialDTO.getIsActive() != null ? testimonialDTO.getIsActive() : true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Testimonial savedTestimonial = testimonialRepository.save(testimonial);
|
||||||
|
log.info("Testimonial created successfully with id: {}", savedTestimonial.getId());
|
||||||
|
|
||||||
|
return savedTestimonial;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public Testimonial updateTestimonial(Long id, TestimonialDTO testimonialDTO) {
|
||||||
|
log.info("Updating testimonial with id: {}", id);
|
||||||
|
|
||||||
|
Testimonial testimonial = getTestimonialById(id);
|
||||||
|
|
||||||
|
testimonial.setName(testimonialDTO.getName());
|
||||||
|
testimonial.setAge(testimonialDTO.getAge());
|
||||||
|
testimonial.setTitle(testimonialDTO.getTitle());
|
||||||
|
testimonial.setStory(testimonialDTO.getStory());
|
||||||
|
testimonial.setOutcome(testimonialDTO.getOutcome());
|
||||||
|
testimonial.setImpact(testimonialDTO.getImpact());
|
||||||
|
testimonial.setCategory(testimonialDTO.getCategory());
|
||||||
|
testimonial.setIsActive(testimonialDTO.getIsActive());
|
||||||
|
|
||||||
|
Testimonial updatedTestimonial = testimonialRepository.save(testimonial);
|
||||||
|
log.info("Testimonial updated successfully");
|
||||||
|
|
||||||
|
return updatedTestimonial;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void deleteTestimonial(Long id) {
|
||||||
|
log.info("Deleting testimonial with id: {}", id);
|
||||||
|
|
||||||
|
Testimonial testimonial = getTestimonialById(id);
|
||||||
|
testimonialRepository.delete(testimonial);
|
||||||
|
|
||||||
|
log.info("Testimonial deleted successfully");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://127.0.0.1:3306/support_portal?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
|
||||||
|
username: root
|
||||||
|
password: root
|
||||||
|
|
||||||
|
file:
|
||||||
|
upload:
|
||||||
|
directory: ${user.home}/support-portal-uploads
|
||||||
|
|
||||||
|
app:
|
||||||
|
base-url: http://localhost:8080
|
||||||
|
cors:
|
||||||
|
allowed-origins: http://localhost:4200,http://localhost:3000
|
||||||
@ -20,9 +20,9 @@ spring:
|
|||||||
enable: false
|
enable: false
|
||||||
|
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:mysql://localhost:3306/support_portal?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
|
url: jdbc:mysql://db:3306/support-portal?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
|
||||||
username: support_portal_user
|
username: support_portal_user
|
||||||
password: Supp0rt_Porta!_P@ssword
|
password: support_portal_password
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
|
||||||
jpa:
|
jpa:
|
||||||
@ -41,19 +41,19 @@ spring:
|
|||||||
throw-exception-if-no-handler-found: true
|
throw-exception-if-no-handler-found: true
|
||||||
web:
|
web:
|
||||||
resources:
|
resources:
|
||||||
add-mappings: false
|
add-mappings: true
|
||||||
|
|
||||||
# File upload configuration
|
# File upload configuration
|
||||||
file:
|
file:
|
||||||
upload:
|
upload:
|
||||||
directory: uploads
|
directory: /app/uploads
|
||||||
|
|
||||||
app:
|
app:
|
||||||
base-url: ${APP_BASE_URL:http://localhost:8080}
|
base-url: ${APP_BASE_URL:http://localhost:8080}
|
||||||
# Updated public URLs to include image endpoints
|
# 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/**,/professor,/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
|
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,https://localhost:4200,http://localhost:3000,https://localhost:3000,http://art-support-portal.s3-website.eu-north-1.amazonaws.com,http://portal.shyshkin.net,*
|
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
|
||||||
|
|
||||||
@ -67,9 +67,9 @@ file:
|
|||||||
upload:
|
upload:
|
||||||
directory: /var/uploads/blog-images
|
directory: /var/uploads/blog-images
|
||||||
app:
|
app:
|
||||||
base-url: https://yourproductiondomain.com
|
base-url: https://cmcbackend.rootxwire.com
|
||||||
cors:
|
cors:
|
||||||
allowed-origins: https://yourfrontenddomain.com,https://youradmindomain.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
|
||||||
|
|||||||
@ -14,10 +14,6 @@ FROM nginx:1.17.1-alpine
|
|||||||
# COPY nginx.conf /etc/nginx/nginx.conf
|
# COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
COPY --from=build /usr/src/app/dist/support-portal-frontend /usr/share/nginx/html
|
COPY --from=build /usr/src/app/dist/support-portal-frontend /usr/share/nginx/html
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Expose port 80 to the Docker host, so we can access it
|
# Expose port 80 to the Docker host, so we can access it
|
||||||
# from the outside.
|
# from the outside.
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@ -31,13 +31,9 @@
|
|||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.css"
|
"src/styles.css"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
],
|
],
|
||||||
"scripts": [
|
"scripts": [
|
||||||
|
"node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
@ -45,13 +41,13 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "500kb",
|
"maximumWarning": "2mb",
|
||||||
"maximumError": "1mb"
|
"maximumError": "5mb"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "2kb",
|
"maximumWarning": "10kb",
|
||||||
"maximumError": "4kb"
|
"maximumError": "20kb"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fileReplacements": [
|
"fileReplacements": [
|
||||||
@ -125,9 +121,12 @@
|
|||||||
"src/assets/assets2"
|
"src/assets/assets2"
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.css"
|
"src/styles.css",
|
||||||
|
"src/app/common-styles.css"
|
||||||
],
|
],
|
||||||
"scripts": []
|
"scripts": [
|
||||||
|
"node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5853
support-portal-frontend/package-lock.json
generated
5853
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,7 +21,9 @@
|
|||||||
"@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",
|
||||||
"ng-particles": "^2.1.11",
|
"ng-particles": "^2.1.11",
|
||||||
"ngx-typed-js": "^2.0.2",
|
"ngx-typed-js": "^2.0.2",
|
||||||
"rxjs": "~6.6.0",
|
"rxjs": "~6.6.0",
|
||||||
@ -36,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",
|
||||||
|
|||||||
@ -19,6 +19,13 @@ import { BlogComponent } from '../component/blog/blog.component';
|
|||||||
import { EventFormComponent } from '../component/event-form/event-form.component';
|
import { EventFormComponent } from '../component/event-form/event-form.component';
|
||||||
import { CareerComponent } from '../component/career/career.component';
|
import { CareerComponent } from '../component/career/career.component';
|
||||||
import { EducationComponent } from '../component/education/education.component';
|
import { EducationComponent } from '../component/education/education.component';
|
||||||
|
import { MilestoneListComponent } from '../component/milestone/milestone-list/milestone-list.component';
|
||||||
|
import { MilestoneFormComponent } from '../component/milestone/milestone-form/milestone-form.component';
|
||||||
|
import { TestimonialFormComponent } from '../component/testimonial/testimonial-form/testimonial-form.component';
|
||||||
|
import { TestimonialListComponent } from '../component/testimonial/testimonial-list/testimonial-list.component';
|
||||||
|
import { HeroImageComponent } from '../component/hero-image/hero-image.component';
|
||||||
|
import { ServiceTileComponent } from '../component/service-tile/service-tile.component';
|
||||||
|
import { PublicationsComponent } from '../component/publications/publications.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: '', component: HomeComponent },
|
{ path: '', component: HomeComponent },
|
||||||
@ -36,6 +43,15 @@ 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/create', component: MilestoneFormComponent, canActivate: [AuthenticationGuard] },
|
||||||
|
{ path: 'milestone/edit/:id', component: MilestoneFormComponent, canActivate: [AuthenticationGuard] },
|
||||||
|
{ path: 'testimonial/list', component: TestimonialListComponent, canActivate: [AuthenticationGuard] },
|
||||||
|
{ path: 'testimonial/create', component: TestimonialFormComponent, canActivate: [AuthenticationGuard] },
|
||||||
|
{ path: 'testimonial/edit/:id', component: TestimonialFormComponent, canActivate: [AuthenticationGuard] },
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
redirectTo: 'login',
|
redirectTo: 'login',
|
||||||
|
|||||||
@ -34,6 +34,13 @@ import { AdminRoutingModule } from './admin-routing.module';
|
|||||||
import { CareerComponent } from '../component/career/career.component';
|
import { CareerComponent } from '../component/career/career.component';
|
||||||
import { EducationComponent } from '../component/education/education.component';
|
import { EducationComponent } from '../component/education/education.component';
|
||||||
import { CareerService } from '../service/career.service';
|
import { CareerService } from '../service/career.service';
|
||||||
|
import { MilestoneFormComponent } from '../component/milestone/milestone-form/milestone-form.component';
|
||||||
|
import { MilestoneListComponent } from '../component/milestone/milestone-list/milestone-list.component';
|
||||||
|
import { TestimonialFormComponent } from '../component/testimonial/testimonial-form/testimonial-form.component';
|
||||||
|
import { TestimonialListComponent } from '../component/testimonial/testimonial-list/testimonial-list.component';
|
||||||
|
import { HeroImageComponent } from '../component/hero-image/hero-image.component';
|
||||||
|
import { ServiceTileComponent } from '../component/service-tile/service-tile.component';
|
||||||
|
import { PublicationsComponent } from '../component/publications/publications.component';
|
||||||
// import { PagesModule } from '../pages/pages.module';
|
// import { PagesModule } from '../pages/pages.module';
|
||||||
|
|
||||||
|
|
||||||
@ -60,7 +67,14 @@ import { CareerService } from '../service/career.service';
|
|||||||
EventComponent,
|
EventComponent,
|
||||||
EventFormComponent,
|
EventFormComponent,
|
||||||
CareerComponent,
|
CareerComponent,
|
||||||
EducationComponent
|
EducationComponent,
|
||||||
|
MilestoneFormComponent,
|
||||||
|
MilestoneListComponent,
|
||||||
|
TestimonialFormComponent,
|
||||||
|
TestimonialListComponent,
|
||||||
|
HeroImageComponent,
|
||||||
|
ServiceTileComponent,
|
||||||
|
PublicationsComponent
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
|||||||
@ -33,6 +33,12 @@ import { NotificationModule } from './notification/notification.module';
|
|||||||
import { EventFormComponent } from './component/event-form/event-form.component';
|
import { EventFormComponent } from './component/event-form/event-form.component';
|
||||||
import { CareerComponent } from './component/career/career.component';
|
import { CareerComponent } from './component/career/career.component';
|
||||||
import { EducationComponent } from './component/education/education.component';
|
import { EducationComponent } from './component/education/education.component';
|
||||||
|
import { MilestoneFormComponent } from './component/milestone/milestone-form/milestone-form.component';
|
||||||
|
import { TestimonialFormComponent } from './component/testimonial/testimonial-form/testimonial-form.component';
|
||||||
|
import { TestimonialListComponent } from './component/testimonial/testimonial-list/testimonial-list.component';
|
||||||
|
import { HeroImageComponent } from './component/hero-image/hero-image.component';
|
||||||
|
import { ServiceTileComponent } from './component/service-tile/service-tile.component';
|
||||||
|
import { PublicationsComponent } from './component/publications/publications.component';
|
||||||
// import { PagesModule } from './pages/pages.module';
|
// import { PagesModule } from './pages/pages.module';
|
||||||
|
|
||||||
|
|
||||||
@ -42,6 +48,13 @@ import { EducationComponent } from './component/education/education.component';
|
|||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
AppComponent,
|
AppComponent,
|
||||||
|
//PublicationsComponent,
|
||||||
|
//ServiceTileComponent,
|
||||||
|
//HeroImageComponent,
|
||||||
|
//TestimonialFormComponent,
|
||||||
|
//TestimonialListComponent,
|
||||||
|
//MilestoneListComponent,
|
||||||
|
//MilestoneFormComponent,
|
||||||
// EducationComponent,
|
// EducationComponent,
|
||||||
// CareerComponent,
|
// CareerComponent,
|
||||||
// LoginComponent,
|
// LoginComponent,
|
||||||
|
|||||||
1031
support-portal-frontend/src/app/common-styles.css
Normal file
1031
support-portal-frontend/src/app/common-styles.css
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,31 +1,739 @@
|
|||||||
/* Add these styles to your component's CSS file or a global stylesheet */
|
/* Blog Layout */
|
||||||
|
.blog-layout {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
.blog-container {
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
width: 260px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-content {
|
||||||
|
margin-left: 260px;
|
||||||
|
flex: 1;
|
||||||
|
padding: 32px;
|
||||||
|
width: calc(100% - 260px);
|
||||||
|
max-width: 1400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header Section */
|
||||||
|
.blog-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-list {
|
.header-left {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-right: 20px; /* Adjust spacing as needed */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-form {
|
.page-title {
|
||||||
flex: 2; /* Adjust this if you want the form to be wider or narrower */
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-form form {
|
.page-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary,
|
||||||
|
.btn-cancel {
|
||||||
|
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 {
|
||||||
|
background: #2563eb;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Container */
|
||||||
|
.form-container {
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions-top {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Card */
|
||||||
|
.form-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Groups */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Inputs */
|
||||||
|
.form-input,
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus,
|
||||||
|
.form-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input::placeholder {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select {
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select option {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Hints */
|
||||||
|
.form-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error Messages */
|
||||||
|
.error-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message i {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image Upload */
|
||||||
|
.image-upload-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px;
|
||||||
|
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: 32px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-label span {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-label small {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image Preview */
|
||||||
|
.image-preview-container {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 300px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove-image {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(220, 38, 38, 0.9);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove-image:hover {
|
||||||
|
background: rgba(220, 38, 38, 1);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload Progress */
|
||||||
|
.upload-progress {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||||
|
animation: progress 1.5s infinite;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progress {
|
||||||
|
0% {
|
||||||
|
width: 0%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
width: 70%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox */
|
||||||
|
.checkbox-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-custom {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-input:checked + .checkbox-label .checkbox-custom {
|
||||||
|
background: #3b82f6;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-input:checked + .checkbox-label .checkbox-custom::after {
|
||||||
|
content: '\f00c';
|
||||||
|
font-family: 'Font Awesome 5 Free';
|
||||||
|
font-weight: 900;
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-text i {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editor Wrapper */
|
||||||
|
.editor-wrapper {
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper:focus-within {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview Card */
|
||||||
|
.preview-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header i {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-content {
|
||||||
|
padding: 24px;
|
||||||
|
min-height: 200px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-content .empty-preview {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List Container */
|
||||||
|
.list-container {
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table Wrapper */
|
||||||
|
.table-wrapper {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-table thead {
|
||||||
|
background: #f9fafb;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-table thead th {
|
||||||
|
padding: 16px 20px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-table tbody tr {
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-table tbody tr:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-table tbody tr:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-table tbody td {
|
||||||
|
padding: 16px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table Cell Styles */
|
||||||
|
.blog-title-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-title-cell i {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authors-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1e40af;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #4b5563;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badge */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-published {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-draft {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Buttons */
|
||||||
|
.action-buttons {
|
||||||
|
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-edit:hover {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #dc2626;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon i {
|
||||||
|
font-size: 36px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.blog-content {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions button {
|
||||||
.result-container {
|
flex: 1;
|
||||||
border: 1px solid #ddd;
|
justify-content: center;
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-container h4 {
|
.form-card {
|
||||||
margin-bottom: 1rem;
|
padding: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.blog-content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-table {
|
||||||
|
min-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.blog-content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions-top {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions-top button {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print Styles */
|
||||||
|
@media print {
|
||||||
|
.sidebar,
|
||||||
|
.header-actions,
|
||||||
|
.form-actions-top,
|
||||||
|
.action-buttons {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-content {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,151 +1,252 @@
|
|||||||
<app-menu></app-menu>
|
<div class="blog-layout">
|
||||||
<div class="container mt-4">
|
<!-- Sidebar -->
|
||||||
<div class="d-flex justify-content-between mb-3">
|
<app-menu class="sidebar"></app-menu>
|
||||||
<!-- Button to Toggle to Blog Form -->
|
|
||||||
<button *ngIf="!isShowForm" class="btn btn-primary" (click)="showForm()">New Blog</button>
|
<!-- Main Content -->
|
||||||
<!-- Button to Toggle to Blog List -->
|
<div class="blog-content">
|
||||||
<button *ngIf="isShowForm" class="btn btn-secondary" (click)="showTable()">Back to List</button>
|
<!-- Header Section -->
|
||||||
|
<div class="blog-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1 class="page-title">Blog Management</h1>
|
||||||
|
<p class="page-subtitle">Create and manage your blog posts</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button *ngIf="!isShowForm" class="btn-primary" (click)="showForm()">
|
||||||
|
<i class="fa fa-plus"></i>
|
||||||
|
<span>New Blog</span>
|
||||||
|
</button>
|
||||||
|
<button *ngIf="isShowForm" class="btn-secondary" (click)="showTable()">
|
||||||
|
<i class="fa fa-arrow-left"></i>
|
||||||
|
<span>Back to List</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Blog Form -->
|
<!-- Blog Form Section -->
|
||||||
<div *ngIf="isShowForm" class="mb-4">
|
<div *ngIf="isShowForm" class="form-container">
|
||||||
<form [formGroup]="blogForm" (ngSubmit)="saveBlog()">
|
<form [formGroup]="blogForm" (ngSubmit)="saveBlog()">
|
||||||
<div class="container">
|
<!-- Form Actions -->
|
||||||
<button type="submit" class="btn btn-primary mx-2">{{ editing ? 'Update Blog' : 'Create Blog'
|
<div class="form-actions-top">
|
||||||
}}</button>
|
<button type="submit" class="btn-primary">
|
||||||
<button type="button" class="btn btn-secondary ml-2" (click)="resetForm()">Cancel</button>
|
<i class="fa fa-save"></i>
|
||||||
|
<span>{{ editing ? 'Update Blog' : 'Create Blog' }}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-cancel" (click)="resetForm()">
|
||||||
|
<i class="fa fa-times"></i>
|
||||||
|
<span>Cancel</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group m-2 ">
|
<!-- Form Card -->
|
||||||
<label class="text-primary" for="title">Title</label>
|
<div class="form-card">
|
||||||
<input type="text" id="title" class="form-control" formControlName="title"
|
<!-- Title Field -->
|
||||||
placeholder="Enter blog title">
|
<div class="form-group">
|
||||||
<div *ngIf="blogForm?.get('title')?.invalid && blogForm.get('title')?.touched" class="text-danger">
|
<label for="title">
|
||||||
|
<i class="fa fa-heading"></i>
|
||||||
|
Blog Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
class="form-input"
|
||||||
|
formControlName="title"
|
||||||
|
placeholder="Enter a compelling title for your blog post">
|
||||||
|
<div *ngIf="blogForm?.get('title')?.invalid && blogForm.get('title')?.touched" class="error-message">
|
||||||
|
<i class="fa fa-exclamation-circle"></i>
|
||||||
Title is required.
|
Title is required.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group m-2">
|
<!-- Professors Field -->
|
||||||
<label class="text-primary" for="professors">Professors</label>
|
<div class="form-group">
|
||||||
<select id="professors" formControlName="professors" class="form-control" multiple>
|
<label for="professors">
|
||||||
|
<i class="fa fa-user-tie"></i>
|
||||||
|
Authors (Professors)
|
||||||
|
</label>
|
||||||
|
<select id="professors" formControlName="professors" class="form-select" multiple>
|
||||||
<option *ngFor="let professor of allProfessors" [value]="professor.id">
|
<option *ngFor="let professor of allProfessors" [value]="professor.id">
|
||||||
{{ professor.firstName }}
|
{{ professor.firstName }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div *ngIf="blogForm.get('professors')?.invalid && blogForm.get('professors')?.touched"
|
<small class="form-hint">Hold Ctrl (Windows) or Cmd (Mac) to select multiple authors</small>
|
||||||
class="text-danger mt-1">
|
<div *ngIf="blogForm.get('professors')?.invalid && blogForm.get('professors')?.touched" class="error-message">
|
||||||
|
<i class="fa fa-exclamation-circle"></i>
|
||||||
At least one professor must be selected.
|
At least one professor must be selected.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group m-2">
|
<!-- Tags Field -->
|
||||||
<label class="text-primary" for="tags">Tags</label>
|
<div class="form-group">
|
||||||
<input type="text" id="tags" class="form-control" formControlName="tags"
|
<label for="tags">
|
||||||
placeholder="Enter tags separated by commas">
|
<i class="fa fa-tags"></i>
|
||||||
<div *ngIf="blogForm.get('tags')?.invalid && blogForm.get('tags')?.touched" class="text-danger mt-1">
|
Tags
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="tags"
|
||||||
|
class="form-input"
|
||||||
|
formControlName="tags"
|
||||||
|
placeholder="Enter tags separated by commas (e.g., technology, education, innovation)">
|
||||||
|
<small class="form-hint">Separate multiple tags with commas</small>
|
||||||
|
<div *ngIf="blogForm.get('tags')?.invalid && blogForm.get('tags')?.touched" class="error-message">
|
||||||
|
<i class="fa fa-exclamation-circle"></i>
|
||||||
Tags are required.
|
Tags are required.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Add this section after the tags field and before the posted checkbox -->
|
|
||||||
<div class="form-group m-2">
|
<!-- Image Upload Field -->
|
||||||
<label class="text-primary" for="image">Blog Image</label>
|
<div class="form-group">
|
||||||
<input type="file" id="image" class="form-control" accept="image/*" (change)="onImageSelected($event)">
|
<label for="image">
|
||||||
|
<i class="fa fa-image"></i>
|
||||||
|
Blog Image
|
||||||
|
</label>
|
||||||
|
<div class="image-upload-wrapper">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="image"
|
||||||
|
class="file-input"
|
||||||
|
accept="image/*"
|
||||||
|
(change)="onImageSelected($event)">
|
||||||
|
<label for="image" class="file-label">
|
||||||
|
<i class="fa fa-cloud-upload-alt"></i>
|
||||||
|
<span>Choose an image or drag here</span>
|
||||||
|
<small>Supports: JPG, PNG, GIF (Max 10MB)</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Image Preview -->
|
<!-- Image Preview -->
|
||||||
<div *ngIf="imagePreviewUrl" class="mt-3">
|
<div *ngIf="imagePreviewUrl" class="image-preview-container">
|
||||||
<img [src]="imagePreviewUrl" alt="Image preview" class="img-thumbnail"
|
<div class="image-preview-wrapper">
|
||||||
style="max-width: 300px; max-height: 200px;">
|
<img [src]="imagePreviewUrl" alt="Image preview" class="image-preview">
|
||||||
<button type="button" class="btn btn-sm btn-danger ml-2"
|
<button type="button" class="btn-remove-image" (click)="removeImage()">
|
||||||
(click)="imagePreviewUrl = null; selectedImage = null;">
|
<i class="fa fa-times"></i>
|
||||||
Remove Image
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Upload Progress -->
|
<!-- Upload Progress -->
|
||||||
<div *ngIf="uploadingImage" class="mt-2">
|
<div *ngIf="uploadingImage" class="upload-progress">
|
||||||
<div class="progress">
|
<div class="progress-bar">
|
||||||
<div class="progress-bar progress-bar-striped progress-bar-animated" style="width: 100%">
|
<div class="progress-fill"></div>
|
||||||
Uploading...
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="progress-text">Uploading image...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group m-2">
|
<!-- Posted Checkbox -->
|
||||||
<input type="checkbox" class="mx-2" id="posted" formControlName="posted">
|
<div class="form-group">
|
||||||
<label class="text-primary" for="posted">Posted</label>
|
<div class="checkbox-wrapper">
|
||||||
|
<input type="checkbox" id="posted" formControlName="posted" class="checkbox-input">
|
||||||
|
<label for="posted" class="checkbox-label">
|
||||||
|
<span class="checkbox-custom"></span>
|
||||||
|
<span class="checkbox-text">
|
||||||
|
<i class="fa fa-globe"></i>
|
||||||
|
Publish this blog post
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<small class="form-hint">Check this to make the blog visible to the public</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Editor -->
|
||||||
|
<div class="form-group">
|
||||||
<div class="form-group m-2">
|
<label for="content">
|
||||||
<label class="text-primary m-1" for="content">Content</label>
|
<i class="fa fa-pen"></i>
|
||||||
<angular-editor [config]="editorConfig" [placeholder]="'Enter text here...'"
|
Blog Content
|
||||||
formControlName="content"></angular-editor>
|
</label>
|
||||||
<div *ngIf="blogForm.get('content')?.invalid && blogForm.get('content')?.touched"
|
<div class="editor-wrapper">
|
||||||
class="text-danger mt-1">
|
<angular-editor
|
||||||
|
[config]="editorConfig"
|
||||||
|
[placeholder]="'Write your blog content here...'"
|
||||||
|
formControlName="content">
|
||||||
|
</angular-editor>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="blogForm.get('content')?.invalid && blogForm.get('content')?.touched" class="error-message">
|
||||||
|
<i class="fa fa-exclamation-circle"></i>
|
||||||
Content is required.
|
Content is required.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="result-container mt-4">
|
|
||||||
<h4 class="text-primary">Preview</h4>
|
|
||||||
<div [innerHTML]="blogForm.get('content')?.value || ''"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview Card -->
|
||||||
|
<div class="preview-card">
|
||||||
|
<div class="preview-header">
|
||||||
|
<i class="fa fa-eye"></i>
|
||||||
|
<h3>Content Preview</h3>
|
||||||
|
</div>
|
||||||
|
<div class="preview-content" [innerHTML]="blogForm.get('content')?.value || '<p class=\'empty-preview\'>Start writing to see your content preview here...</p>'"></div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Blog List -->
|
<!-- Blog List Section -->
|
||||||
<div *ngIf="!isShowForm">
|
<div *ngIf="!isShowForm" class="list-container">
|
||||||
<div *ngIf="blogs.length > 0">
|
<div *ngIf="blogs.length > 0" class="table-wrapper">
|
||||||
<table class="table table-striped">
|
<table class="blog-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
<!-- <th>Content</th> -->
|
|
||||||
<th>Authors</th>
|
<th>Authors</th>
|
||||||
<th>Tags</th>
|
<th>Tags</th>
|
||||||
<th>Posted</th> <!-- New column for posted status -->
|
<th>Status</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let blog of blogs">
|
<tr *ngFor="let blog of blogs">
|
||||||
<td>{{ blog.title }}</td>
|
|
||||||
<!-- <td>{{ blog.content | slice:0:50 }}...</td> -->
|
|
||||||
<td>
|
<td>
|
||||||
<span *ngFor="let professor of blog.professors">{{ professor.firstName }}<br></span>
|
<div class="blog-title-cell">
|
||||||
|
<i class="fa fa-file-alt"></i>
|
||||||
|
<span>{{ blog.title }}</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span *ngFor="let tag of blog.tags">{{ tag }}<br></span>
|
<div class="authors-cell">
|
||||||
|
<span class="author-tag" *ngFor="let professor of blog.professors">
|
||||||
|
{{ professor.firstName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span *ngIf="blog.posted" class="text-success">✓</span>
|
<div class="tags-cell">
|
||||||
<!-- Check mark for posted -->
|
<span class="tag" *ngFor="let tag of blog.tags">
|
||||||
<span *ngIf="!blog.posted" class="text-danger">✗</span>
|
{{ tag }}
|
||||||
<!-- Cross mark for not posted -->
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-info btn-sm mr-2" (click)="editBlog(blog)">
|
<span class="status-badge" [class.status-published]="blog.posted" [class.status-draft]="!blog.posted">
|
||||||
<!-- Added margin-right for spacing -->
|
<i class="fa" [class.fa-check-circle]="blog.posted" [class.fa-clock]="!blog.posted"></i>
|
||||||
Edit
|
{{ blog.posted ? 'Published' : 'Draft' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button class="btn-action btn-edit" (click)="editBlog(blog)" title="Edit">
|
||||||
|
<i class="fa fa-edit"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-danger btn-sm" (click)="deleteBlog(blog)">
|
<button class="btn-action btn-delete" (click)="deleteBlog(blog)" title="Delete">
|
||||||
Delete
|
<i class="fa fa-trash-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
</div>
|
|
||||||
<div *ngIf="blogs.length === 0" class="alert alert-info">
|
|
||||||
No blogs available. Please create a new blog.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div *ngIf="blogs.length === 0" class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<i class="fa fa-inbox"></i>
|
||||||
|
</div>
|
||||||
|
<h3>No blogs yet</h3>
|
||||||
|
<p>Get started by creating your first blog post</p>
|
||||||
|
<button class="btn-primary" (click)="showForm()">
|
||||||
|
<i class="fa fa-plus"></i>
|
||||||
|
<span>Create First Blog</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,66 +1,124 @@
|
|||||||
<app-menu></app-menu>
|
<div class="career-layout">
|
||||||
<div class="container mt-4">
|
<!-- Sidebar -->
|
||||||
<!-- Header Actions -->
|
<app-menu class="sidebar"></app-menu>
|
||||||
<div class="d-flex justify-content-between mb-3">
|
|
||||||
<div>
|
<!-- Main Content -->
|
||||||
<button *ngIf="!showJobForm && !showApplications" class="btn btn-primary" (click)="showJobFormModal()">
|
<div class="career-content">
|
||||||
<i class="fa fa-plus"></i> New Job
|
<!-- Header Section -->
|
||||||
</button>
|
<div class="career-header">
|
||||||
<button *ngIf="showJobForm" class="btn btn-secondary" (click)="hideJobForm()">
|
<div class="header-left">
|
||||||
<i class="fa fa-arrow-left"></i> Back to Jobs
|
<h1 class="page-title">Career Management</h1>
|
||||||
</button>
|
<p class="page-subtitle">Manage job postings and applications</p>
|
||||||
<button *ngIf="showApplications" class="btn btn-secondary" (click)="hideApplications()">
|
|
||||||
<i class="fa fa-arrow-left"></i> Back to Jobs
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="!showJobForm && !showApplications">
|
<div class="header-actions">
|
||||||
<span class="badge badge-info">Total Jobs: {{ jobs.length }}</span>
|
<button *ngIf="!showJobForm && !showApplications" class="btn-primary" (click)="showJobFormModal()">
|
||||||
<span class="badge badge-warning ml-2">Total Applications: {{ applications.length }}</span>
|
<i class="fa fa-plus"></i>
|
||||||
|
<span>New Job</span>
|
||||||
|
</button>
|
||||||
|
<button *ngIf="showJobForm" class="btn-secondary" (click)="hideJobForm()">
|
||||||
|
<i class="fa fa-arrow-left"></i>
|
||||||
|
<span>Back to Jobs</span>
|
||||||
|
</button>
|
||||||
|
<button *ngIf="showApplications" class="btn-secondary" (click)="hideApplications()">
|
||||||
|
<i class="fa fa-arrow-left"></i>
|
||||||
|
<span>Back to Jobs</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Job Form -->
|
<!-- Stats Bar -->
|
||||||
<div *ngIf="showJobForm" class="card">
|
<div *ngIf="!showJobForm && !showApplications" class="stats-bar">
|
||||||
<div class="card-header">
|
<div class="stat-item">
|
||||||
<h5 class="mb-0">{{ editing ? 'Edit Job' : 'Create New Job' }}</h5>
|
<div class="stat-icon stat-icon-blue">
|
||||||
|
<i class="fa fa-briefcase"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="stat-details">
|
||||||
|
<span class="stat-label">Total Jobs</span>
|
||||||
|
<span class="stat-value">{{ jobs.length }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-icon stat-icon-orange">
|
||||||
|
<i class="fa fa-file-alt"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-details">
|
||||||
|
<span class="stat-label">Total Applications</span>
|
||||||
|
<span class="stat-value">{{ applications.length }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Job Form Section -->
|
||||||
|
<div *ngIf="showJobForm" class="form-container">
|
||||||
|
<div class="form-card">
|
||||||
|
<div class="form-card-header">
|
||||||
|
<h3>{{ editing ? 'Edit Job Posting' : 'Create New Job' }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="form-card-body">
|
||||||
<form [formGroup]="jobForm" (ngSubmit)="saveJob()">
|
<form [formGroup]="jobForm" (ngSubmit)="saveJob()">
|
||||||
<div class="row">
|
<!-- Basic Information -->
|
||||||
<div class="col-md-6">
|
<div class="form-section">
|
||||||
|
<h4 class="section-title">Basic Information</h4>
|
||||||
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="title" class="text-primary">Job Title *</label>
|
<label for="title">
|
||||||
<input type="text" id="title" class="form-control" formControlName="title" placeholder="Enter job title">
|
<i class="fa fa-briefcase"></i>
|
||||||
<div *ngIf="jobForm.get('title')?.invalid && jobForm.get('title')?.touched" class="text-danger">
|
Job Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
class="form-input"
|
||||||
|
formControlName="title"
|
||||||
|
placeholder="e.g., Senior Software Engineer">
|
||||||
|
<div *ngIf="jobForm.get('title')?.invalid && jobForm.get('title')?.touched" class="error-message">
|
||||||
|
<i class="fa fa-exclamation-circle"></i>
|
||||||
Job title is required.
|
Job title is required.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="department" class="text-primary">Department *</label>
|
<label for="department">
|
||||||
<input type="text" id="department" class="form-control" formControlName="department" placeholder="Enter department">
|
<i class="fa fa-building"></i>
|
||||||
<div *ngIf="jobForm.get('department')?.invalid && jobForm.get('department')?.touched" class="text-danger">
|
Department
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="department"
|
||||||
|
class="form-input"
|
||||||
|
formControlName="department"
|
||||||
|
placeholder="e.g., Engineering">
|
||||||
|
<div *ngIf="jobForm.get('department')?.invalid && jobForm.get('department')?.touched" class="error-message">
|
||||||
|
<i class="fa fa-exclamation-circle"></i>
|
||||||
Department is required.
|
Department is required.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
<div class="form-row">
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="location" class="text-primary">Location *</label>
|
<label for="location">
|
||||||
<input type="text" id="location" class="form-control" formControlName="location" placeholder="Enter location">
|
<i class="fa fa-map-marker-alt"></i>
|
||||||
<div *ngIf="jobForm.get('location')?.invalid && jobForm.get('location')?.touched" class="text-danger">
|
Location
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="location"
|
||||||
|
class="form-input"
|
||||||
|
formControlName="location"
|
||||||
|
placeholder="e.g., New York, NY">
|
||||||
|
<div *ngIf="jobForm.get('location')?.invalid && jobForm.get('location')?.touched" class="error-message">
|
||||||
|
<i class="fa fa-exclamation-circle"></i>
|
||||||
Location is required.
|
Location is required.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="type" class="text-primary">Job Type *</label>
|
<label for="type">
|
||||||
<select id="type" class="form-control" formControlName="type">
|
<i class="fa fa-clock"></i>
|
||||||
|
Job Type
|
||||||
|
</label>
|
||||||
|
<select id="type" class="form-select" formControlName="type">
|
||||||
<option value="">Select job type</option>
|
<option value="">Select job type</option>
|
||||||
<option value="Full-time">Full-time</option>
|
<option value="Full-time">Full-time</option>
|
||||||
<option value="Part-time">Part-time</option>
|
<option value="Part-time">Part-time</option>
|
||||||
@ -68,137 +126,270 @@
|
|||||||
<option value="Rotational">Rotational</option>
|
<option value="Rotational">Rotational</option>
|
||||||
<option value="Observership">Observership</option>
|
<option value="Observership">Observership</option>
|
||||||
</select>
|
</select>
|
||||||
<div *ngIf="jobForm.get('type')?.invalid && jobForm.get('type')?.touched" class="text-danger">
|
<div *ngIf="jobForm.get('type')?.invalid && jobForm.get('type')?.touched" class="error-message">
|
||||||
|
<i class="fa fa-exclamation-circle"></i>
|
||||||
Job type is required.
|
Job type is required.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
<div class="form-row">
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="experience" class="text-primary">Experience Required *</label>
|
<label for="experience">
|
||||||
<input type="text" id="experience" class="form-control" formControlName="experience" placeholder="e.g., MBBS + MS preferred">
|
<i class="fa fa-user-graduate"></i>
|
||||||
<div *ngIf="jobForm.get('experience')?.invalid && jobForm.get('experience')?.touched" class="text-danger">
|
Experience Required
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="experience"
|
||||||
|
class="form-input"
|
||||||
|
formControlName="experience"
|
||||||
|
placeholder="e.g., MBBS + MS preferred, 3-5 years">
|
||||||
|
<div *ngIf="jobForm.get('experience')?.invalid && jobForm.get('experience')?.touched" class="error-message">
|
||||||
|
<i class="fa fa-exclamation-circle"></i>
|
||||||
Experience requirement is required.
|
Experience requirement is required.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="salary" class="text-primary">Salary *</label>
|
<label for="salary">
|
||||||
<input type="text" id="salary" class="form-control" formControlName="salary" placeholder="e.g., As per hospital norms">
|
<i class="fa fa-dollar-sign"></i>
|
||||||
<div *ngIf="jobForm.get('salary')?.invalid && jobForm.get('salary')?.touched" class="text-danger">
|
Salary
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="salary"
|
||||||
|
class="form-input"
|
||||||
|
formControlName="salary"
|
||||||
|
placeholder="e.g., As per hospital norms, $80,000 - $100,000">
|
||||||
|
<div *ngIf="jobForm.get('salary')?.invalid && jobForm.get('salary')?.touched" class="error-message">
|
||||||
|
<i class="fa fa-exclamation-circle"></i>
|
||||||
Salary information is required.
|
Salary information is required.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Job Details -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h4 class="section-title">Job Details</h4>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="description" class="text-primary">Job Description *</label>
|
<label for="description">
|
||||||
<textarea id="description" class="form-control" formControlName="description" rows="4" placeholder="Enter job description"></textarea>
|
<i class="fa fa-align-left"></i>
|
||||||
<div *ngIf="jobForm.get('description')?.invalid && jobForm.get('description')?.touched" class="text-danger">
|
Job Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
class="form-textarea"
|
||||||
|
formControlName="description"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Describe the role, responsibilities, and what makes this position unique..."></textarea>
|
||||||
|
<div *ngIf="jobForm.get('description')?.invalid && jobForm.get('description')?.touched" class="error-message">
|
||||||
|
<i class="fa fa-exclamation-circle"></i>
|
||||||
Job description is required.
|
Job description is required.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="requirements" class="text-primary">Requirements</label>
|
<label for="requirements">
|
||||||
<textarea id="requirements" class="form-control" formControlName="requirements" rows="3" placeholder="Enter requirements separated by commas"></textarea>
|
<i class="fa fa-check-circle"></i>
|
||||||
<small class="form-text text-muted">Separate multiple requirements with commas</small>
|
Requirements
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="requirements"
|
||||||
|
class="form-textarea"
|
||||||
|
formControlName="requirements"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Bachelor's degree in relevant field, 3+ years experience, Strong communication skills..."></textarea>
|
||||||
|
<small class="form-hint">Separate multiple requirements with commas</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="responsibilities" class="text-primary">Responsibilities</label>
|
<label for="responsibilities">
|
||||||
<textarea id="responsibilities" class="form-control" formControlName="responsibilities" rows="3" placeholder="Enter responsibilities separated by commas"></textarea>
|
<i class="fa fa-tasks"></i>
|
||||||
<small class="form-text text-muted">Separate multiple responsibilities with commas</small>
|
Responsibilities
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="responsibilities"
|
||||||
|
class="form-textarea"
|
||||||
|
formControlName="responsibilities"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Lead team meetings, Develop strategic plans, Collaborate with stakeholders..."></textarea>
|
||||||
|
<small class="form-hint">Separate multiple responsibilities with commas</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-check mb-3">
|
<!-- Status -->
|
||||||
<input type="checkbox" class="form-check-input" id="isActive" formControlName="isActive">
|
<div class="form-section">
|
||||||
<label class="form-check-label text-primary" for="isActive">Active (visible to applicants)</label>
|
<div class="checkbox-wrapper">
|
||||||
|
<input type="checkbox" id="isActive" formControlName="isActive" class="checkbox-input">
|
||||||
|
<label for="isActive" class="checkbox-label">
|
||||||
|
<span class="checkbox-custom"></span>
|
||||||
|
<span class="checkbox-text">
|
||||||
|
<i class="fa fa-eye"></i>
|
||||||
|
Active (visible to job seekers)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<small class="form-hint">Uncheck to hide this job from public view</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<!-- Form Actions -->
|
||||||
<button type="submit" class="btn btn-primary" [disabled]="jobForm.invalid">
|
<div class="form-actions">
|
||||||
<i class="fa fa-save"></i> {{ editing ? 'Update Job' : 'Create Job' }}
|
<button type="submit" class="btn-primary" [disabled]="jobForm.invalid">
|
||||||
|
<i class="fa fa-save"></i>
|
||||||
|
<span>{{ editing ? 'Update Job' : 'Create Job' }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-secondary ml-2" (click)="resetJobForm()">
|
<button type="button" class="btn-cancel" (click)="resetJobForm()">
|
||||||
<i class="fa fa-times"></i> Cancel
|
<i class="fa fa-times"></i>
|
||||||
|
<span>Cancel</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Applications View -->
|
<!-- Applications View -->
|
||||||
<div *ngIf="showApplications && selectedJobForApplications" class="card">
|
<!-- Applications View -->
|
||||||
<div class="card-header">
|
<div *ngIf="showApplications && selectedJobForApplications" class="applications-container">
|
||||||
<h5 class="mb-0">Applications for: {{ selectedJobForApplications.title }}</h5>
|
<div class="applications-card">
|
||||||
|
<div class="applications-header">
|
||||||
|
<div class="header-info">
|
||||||
|
<h3>Applications</h3>
|
||||||
|
<p>{{ selectedJobForApplications.title }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<span class="application-count">{{ applications.length }} {{ applications.length === 1 ? 'Application' : 'Applications' }}</span>
|
||||||
<div *ngIf="applications.length > 0">
|
</div>
|
||||||
<table class="table table-striped">
|
|
||||||
|
<div class="applications-body">
|
||||||
|
<div *ngIf="applications.length > 0" class="table-wrapper">
|
||||||
|
<table class="applications-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Applicant</th>
|
<th>Applicant</th>
|
||||||
<th>Email</th>
|
<th>Contact</th>
|
||||||
<th>Phone</th>
|
|
||||||
<th>Experience</th>
|
<th>Experience</th>
|
||||||
|
<th>Resume</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Applied Date</th>
|
<th>Applied Date</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let application of applications">
|
<tr *ngFor="let application of applications; let i = index">
|
||||||
<td>{{ application.fullName }}</td>
|
|
||||||
<td>{{ application.email }}</td>
|
|
||||||
<td>{{ application.phone }}</td>
|
|
||||||
<td>{{ application.experience }}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<span class="badge" [ngClass]="getStatusBadgeClass(application.status || 'pending')">
|
<div class="applicant-info">
|
||||||
|
<div class="applicant-avatar">
|
||||||
|
<i class="fa fa-user"></i>
|
||||||
|
</div>
|
||||||
|
<span class="applicant-name">{{ application.fullName }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="contact-info">
|
||||||
|
<div class="contact-item">
|
||||||
|
<i class="fa fa-envelope"></i>
|
||||||
|
<span>{{ application.email }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="contact-item">
|
||||||
|
<i class="fa fa-phone"></i>
|
||||||
|
<span>{{ application.phone }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="experience-badge">{{ application.experience }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="resume-actions" *ngIf="hasResume(application)">
|
||||||
|
<button class="btn-resume btn-view" (click)="viewResume(application)" title="View Resume">
|
||||||
|
<i class="fa fa-eye"></i>
|
||||||
|
<span>View</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-resume btn-download" (click)="downloadResume(application)" title="Download Resume">
|
||||||
|
<i class="fa fa-download"></i>
|
||||||
|
<span>Download</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="no-resume" *ngIf="!hasResume(application)">
|
||||||
|
<i class="fa fa-file-excel"></i>
|
||||||
|
<span>No Resume</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge" [ngClass]="getStatusBadgeClass(application.status || 'pending')">
|
||||||
{{ application.status || 'PENDING' }}
|
{{ application.status || 'PENDING' }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ application.createdDate | date:'short' }}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-group" dropdown>
|
<span class="date-text">{{ application.createdDate | date:'MMM d, y' }}</span>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle" data-toggle="dropdown">
|
</td>
|
||||||
Status
|
<td>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<div class="dropdown" [class.active]="activeDropdownIndex === i">
|
||||||
|
<button class="btn-action btn-status" (click)="toggleDropdown(i, $event)">
|
||||||
|
<i class="fa fa-edit"></i>
|
||||||
|
<span>Status</span>
|
||||||
|
<i class="fa fa-chevron-down"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu">
|
||||||
<a class="dropdown-item" (click)="updateApplicationStatus(application, 'PENDING')">Pending</a>
|
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'PENDING')">
|
||||||
<a class="dropdown-item" (click)="updateApplicationStatus(application, 'REVIEWED')">Reviewed</a>
|
<i class="fa fa-clock"></i>
|
||||||
<a class="dropdown-item" (click)="updateApplicationStatus(application, 'SHORTLISTED')">Shortlisted</a>
|
Pending
|
||||||
<a class="dropdown-item" (click)="updateApplicationStatus(application, 'INTERVIEWED')">Interviewed</a>
|
</button>
|
||||||
<a class="dropdown-item" (click)="updateApplicationStatus(application, 'HIRED')">Hired</a>
|
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'REVIEWED')">
|
||||||
<a class="dropdown-item" (click)="updateApplicationStatus(application, 'REJECTED')">Rejected</a>
|
<i class="fa fa-eye"></i>
|
||||||
|
Reviewed
|
||||||
|
</button>
|
||||||
|
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'SHORTLISTED')">
|
||||||
|
<i class="fa fa-star"></i>
|
||||||
|
Shortlisted
|
||||||
|
</button>
|
||||||
|
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'INTERVIEWED')">
|
||||||
|
<i class="fa fa-comments"></i>
|
||||||
|
Interviewed
|
||||||
|
</button>
|
||||||
|
<button class="dropdown-item" (click)="updateApplicationStatus(application, 'HIRED')">
|
||||||
|
<i class="fa fa-check-circle"></i>
|
||||||
|
Hired
|
||||||
|
</button>
|
||||||
|
<button class="dropdown-item item-danger" (click)="updateApplicationStatus(application, 'REJECTED')">
|
||||||
|
<i class="fa fa-times-circle"></i>
|
||||||
|
Rejected
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm btn-danger ml-1" (click)="deleteApplication(application)">
|
<button class="btn-action btn-delete" (click)="deleteApplication(application)">
|
||||||
<i class="fa fa-trash"></i>
|
<i class="fa fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="applications.length === 0" class="alert alert-info">
|
|
||||||
No applications found for this job.
|
<!-- Empty State for Applications -->
|
||||||
|
<div *ngIf="applications.length === 0" class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<i class="fa fa-inbox"></i>
|
||||||
|
</div>
|
||||||
|
<h3>No applications yet</h3>
|
||||||
|
<p>This job hasn't received any applications</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Jobs List -->
|
<!-- Jobs List -->
|
||||||
<div *ngIf="!showJobForm && !showApplications">
|
<div *ngIf="!showJobForm && !showApplications" class="jobs-list-container">
|
||||||
<div *ngIf="jobs.length > 0">
|
<div *ngIf="jobs.length > 0" class="table-wrapper">
|
||||||
<table class="table table-striped">
|
<table class="jobs-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Title</th>
|
<th>Job Details</th>
|
||||||
<th>Department</th>
|
<th>Department</th>
|
||||||
<th>Location</th>
|
<th>Location</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
@ -210,39 +401,63 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let job of jobs">
|
<tr *ngFor="let job of jobs">
|
||||||
<td>
|
<td>
|
||||||
<strong>{{ job.title }}</strong>
|
<div class="job-details">
|
||||||
<br>
|
<h4>{{ job.title }}</h4>
|
||||||
<small class="text-muted">{{ job.experience }}</small>
|
<p>{{ job.experience }}</p>
|
||||||
</td>
|
</div>
|
||||||
<td>{{ job.department }}</td>
|
|
||||||
<td>{{ job.location }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge badge-secondary">{{ job.type }}</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span *ngIf="job.isActive" class="badge badge-success">Active</span>
|
<span class="department-text">{{ job.department }}</span>
|
||||||
<span *ngIf="!job.isActive" class="badge badge-danger">Inactive</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-sm btn-info" (click)="viewApplications(job)">
|
<div class="location-cell">
|
||||||
View Applications
|
<i class="fa fa-map-marker-alt"></i>
|
||||||
<span class="badge badge-light ml-1">{{ getApplicationCount(job.id) }}</span>
|
<span>{{ job.location }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="type-badge">{{ job.type }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge" [class.status-active]="job.isActive" [class.status-inactive]="!job.isActive">
|
||||||
|
<i class="fa" [class.fa-check-circle]="job.isActive" [class.fa-times-circle]="!job.isActive"></i>
|
||||||
|
{{ job.isActive ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn-applications" (click)="viewApplications(job)">
|
||||||
|
<i class="fa fa-file-alt"></i>
|
||||||
|
<span>View</span>
|
||||||
|
<span class="app-count">{{ getApplicationCount(job.id) }}</span>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-info btn-sm mr-2" (click)="editJob(job)">
|
<div class="action-buttons">
|
||||||
<i class="fa fa-edit"></i> Edit
|
<button class="btn-action btn-edit" (click)="editJob(job)" title="Edit">
|
||||||
|
<i class="fa fa-edit"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-danger btn-sm" (click)="deleteJob(job)">
|
<button class="btn-action btn-delete" (click)="deleteJob(job)" title="Delete">
|
||||||
<i class="fa fa-trash"></i> Delete
|
<i class="fa fa-trash-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="jobs.length === 0" class="alert alert-info">
|
|
||||||
No jobs available. Please create a new job.
|
<!-- Empty State for Jobs -->
|
||||||
|
<div *ngIf="jobs.length === 0" class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<i class="fa fa-briefcase"></i>
|
||||||
|
</div>
|
||||||
|
<h3>No jobs posted yet</h3>
|
||||||
|
<p>Get started by creating your first job posting</p>
|
||||||
|
<button class="btn-primary" (click)="showJobFormModal()">
|
||||||
|
<i class="fa fa-plus"></i>
|
||||||
|
<span>Create First Job</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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]
|
||||||
});
|
});
|
||||||
@ -382,4 +382,3 @@ export class EducationComponent implements OnInit {
|
|||||||
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;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
@ -0,0 +1,696 @@
|
|||||||
|
/* Event Form Layout */
|
||||||
|
.event-form-layout {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
width: 260px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-form-content {
|
||||||
|
margin-left: 260px;
|
||||||
|
flex: 1;
|
||||||
|
padding: 32px;
|
||||||
|
width: calc(100% - 260px);
|
||||||
|
max-width: 1800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header Section */
|
||||||
|
.event-form-header {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Grid */
|
||||||
|
.event-form {
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Card */
|
||||||
|
.form-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%);
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header i {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Elements */
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group.flex-1 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group.flex-2 {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-input::placeholder,
|
||||||
|
.form-textarea::placeholder {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 8px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox */
|
||||||
|
.checkbox-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label:hover {
|
||||||
|
border-color: #d1d5db;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-input:checked + .checkbox-label {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
background: #f0f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-custom {
|
||||||
|
position: relative;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-input:checked + .checkbox-label .checkbox-custom {
|
||||||
|
background: #3b82f6;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-input:checked + .checkbox-label .checkbox-custom::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
top: 2px;
|
||||||
|
width: 5px;
|
||||||
|
height: 10px;
|
||||||
|
border: solid white;
|
||||||
|
border-width: 0 2px 2px 0;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-text strong {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-text small {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload Section */
|
||||||
|
.upload-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 2px dashed #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f9fafb;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-label:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
background: #f0f9ff;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-label i {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-upload {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-upload:hover:not(:disabled) {
|
||||||
|
background: #2563eb;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-upload:disabled {
|
||||||
|
background: #9ca3af;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* URL Section */
|
||||||
|
.url-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image Preview */
|
||||||
|
.image-preview {
|
||||||
|
margin-top: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 250px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Array Items */
|
||||||
|
.array-item {
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.array-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.array-item-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.array-item-header span {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.array-item-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Simple Array Item */
|
||||||
|
.simple-array-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-array-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-array-item .form-input {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gallery URL Item */
|
||||||
|
.gallery-url-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-url-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-url-item > div:first-child {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-url-item .form-input {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-thumbnail {
|
||||||
|
width: 100px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-thumbnail img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-add {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: 2px dashed #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
color: #3b82f6;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
background: #f0f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
background: white;
|
||||||
|
color: #dc2626;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove-inline {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
background: white;
|
||||||
|
color: #dc2626;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove-inline:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Actions */
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 20px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary,
|
||||||
|
.btn-submit {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: white;
|
||||||
|
color: #6b7280;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit {
|
||||||
|
background: #22c55e;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:hover:not(:disabled) {
|
||||||
|
background: #16a34a;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:disabled {
|
||||||
|
background: #9ca3af;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner */
|
||||||
|
.spinner-border {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-right-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spinner 0.75s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-border-sm {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spinner {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.event-form-content {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.event-form-content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-wrapper {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions button {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.event-form-content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print Styles */
|
||||||
|
@media print {
|
||||||
|
.sidebar,
|
||||||
|
.form-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-form-content {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,268 +1,414 @@
|
|||||||
<app-menu></app-menu>
|
<div class="event-form-layout">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<app-menu class="sidebar"></app-menu>
|
||||||
|
|
||||||
<div class="container mt-4">
|
<!-- Main Content -->
|
||||||
<form [formGroup]="eventForm" (ngSubmit)="onSubmit()">
|
<div class="event-form-content">
|
||||||
<div class="row">
|
<!-- Header Section -->
|
||||||
<div class="col-md-6">
|
<div class="event-form-header">
|
||||||
<div class="form-group mb-3">
|
<div class="header-left">
|
||||||
<label for="code" class="form-label text-primary">Code</label>
|
<h1 class="page-title">{{ eventId ? 'Edit Event' : 'Create New Event' }}</h1>
|
||||||
<input id="code" formControlName="code" class="form-control" />
|
<p class="page-subtitle">{{ eventId ? 'Update event information and details' : 'Fill in the details to create a new event' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group mb-3">
|
|
||||||
<label for="year" class="form-label text-primary">Year</label>
|
|
||||||
<input id="year" formControlName="year" class="form-control" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group mb-3">
|
|
||||||
<label for="subject" class="form-label text-primary">Subject</label>
|
<!-- Form Container -->
|
||||||
<input id="subject" formControlName="subject" class="form-control" />
|
<form [formGroup]="eventForm" (ngSubmit)="onSubmit()" class="event-form">
|
||||||
|
<div class="form-grid">
|
||||||
|
<!-- Left Column -->
|
||||||
|
<div class="form-column">
|
||||||
|
<!-- Basic Information Section -->
|
||||||
|
<div class="form-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-info-circle"></i>
|
||||||
|
<h3>Basic Information</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group mb-3">
|
<div class="card-body">
|
||||||
<label for="title" class="form-label text-primary">Title</label>
|
<div class="form-row">
|
||||||
<input id="title" formControlName="title" class="form-control" />
|
<div class="form-group">
|
||||||
|
<label for="code">
|
||||||
|
<i class="bi bi-upc"></i>
|
||||||
|
Event Code
|
||||||
|
</label>
|
||||||
|
<input id="code" formControlName="code" class="form-input" placeholder="Enter event code">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group mb-3">
|
|
||||||
<label for="subTitle" class="form-label text-primary">Subtitle</label>
|
<div class="form-group">
|
||||||
<input id="subTitle" formControlName="subTitle" class="form-control" />
|
<label for="year">
|
||||||
|
<i class="bi bi-calendar4"></i>
|
||||||
|
Year *
|
||||||
|
</label>
|
||||||
|
<input id="year" formControlName="year" class="form-input" placeholder="2024">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group mb-3">
|
</div>
|
||||||
<label for="description" class="form-label text-primary">Description</label>
|
|
||||||
<textarea id="description" formControlName="description" class="form-control" rows="3"
|
<div class="form-group">
|
||||||
|
<label for="subject">
|
||||||
|
<i class="bi bi-bookmark"></i>
|
||||||
|
Subject *
|
||||||
|
</label>
|
||||||
|
<input id="subject" formControlName="subject" class="form-input" placeholder="Event subject or category">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="title">
|
||||||
|
<i class="bi bi-card-heading"></i>
|
||||||
|
Event Title *
|
||||||
|
</label>
|
||||||
|
<input id="title" formControlName="title" class="form-input" placeholder="Enter event title">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="subTitle">
|
||||||
|
<i class="bi bi-card-text"></i>
|
||||||
|
Subtitle
|
||||||
|
</label>
|
||||||
|
<input id="subTitle" formControlName="subTitle" class="form-input" placeholder="Enter subtitle (optional)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">
|
||||||
|
<i class="bi bi-file-text"></i>
|
||||||
|
Description *
|
||||||
|
</label>
|
||||||
|
<textarea id="description" formControlName="description" class="form-textarea" rows="3"
|
||||||
placeholder="Brief description for the event card"></textarea>
|
placeholder="Brief description for the event card"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group mb-3">
|
|
||||||
<label for="detail" class="form-label text-primary">Detail</label>
|
<div class="form-group">
|
||||||
<textarea id="detail" formControlName="detail" class="form-control" rows="3"
|
<label for="detail">
|
||||||
|
<i class="bi bi-file-earmark-text"></i>
|
||||||
|
Detailed Information *
|
||||||
|
</label>
|
||||||
|
<textarea id="detail" formControlName="detail" class="form-textarea" rows="4"
|
||||||
placeholder="Detailed information about the event"></textarea>
|
placeholder="Detailed information about the event"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group mb-3">
|
|
||||||
<label for="date" class="form-label text-primary">Date</label>
|
|
||||||
<input id="date" formControlName="date" class="form-control" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group mb-3">
|
|
||||||
<label for="phone" class="form-label text-primary">Phone</label>
|
|
||||||
<input id="phone" formControlName="phone" class="form-control" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group mb-3">
|
|
||||||
<label for="email" class="form-label text-primary">Email</label>
|
|
||||||
<input id="email" formControlName="email" class="form-control" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group mb-3">
|
|
||||||
<label for="isActive" class="form-label text-primary">Active</label>
|
|
||||||
<input id="isActive" type="checkbox" formControlName="isActive" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<!-- Contact Information Section -->
|
||||||
<!-- Main Image Section -->
|
<div class="form-card">
|
||||||
<div class="form-group mb-3">
|
<div class="card-header">
|
||||||
<label class="form-label text-primary">Main Image</label>
|
<i class="bi bi-telephone"></i>
|
||||||
|
<h3>Contact Information</h3>
|
||||||
<!-- Image Upload Section -->
|
</div>
|
||||||
<div class="card mb-2">
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h6 class="card-title">Upload Image File</h6>
|
<div class="form-row">
|
||||||
<div class="row">
|
<div class="form-group">
|
||||||
<div class="col-8">
|
<label for="date">
|
||||||
<input type="file"
|
<i class="bi bi-calendar-event"></i>
|
||||||
class="form-control"
|
Event Date *
|
||||||
accept="image/*"
|
</label>
|
||||||
(change)="onMainImageFileSelected($event)"
|
<input id="date" formControlName="date" class="form-input" placeholder="MM-DD-YYYY">
|
||||||
#mainImageFile>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
|
||||||
<button type="button"
|
<div class="form-group">
|
||||||
class="btn btn-primary btn-sm w-100"
|
<label for="phone">
|
||||||
(click)="uploadMainImage()"
|
<i class="bi bi-phone"></i>
|
||||||
[disabled]="!selectedMainImageFile || mainImageUploading">
|
Phone *
|
||||||
<span *ngIf="mainImageUploading" class="spinner-border spinner-border-sm me-1"></span>
|
</label>
|
||||||
{{ mainImageUploading ? 'Uploading...' : 'Upload' }}
|
<input id="phone" formControlName="phone" class="form-input" placeholder="Contact phone number">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">
|
||||||
|
<i class="bi bi-envelope"></i>
|
||||||
|
Email *
|
||||||
|
</label>
|
||||||
|
<input id="email" formControlName="email" class="form-input" placeholder="contact@example.com">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Registration/Booking Link -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="bookSeatLink">
|
||||||
|
<i class="bi bi-ticket-perforated"></i>
|
||||||
|
Book Seat Registration Link
|
||||||
|
</label>
|
||||||
|
<input id="bookSeatLink"
|
||||||
|
formControlName="bookSeatLink"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="https://example.com/register">
|
||||||
|
<p class="form-hint">Enter the URL where users can register/book seats for this event</p>
|
||||||
|
</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="checkbox-wrapper">
|
||||||
|
<input id="isActive" type="checkbox" formControlName="isActive" class="checkbox-input">
|
||||||
|
<label for="isActive" class="checkbox-label">
|
||||||
|
<span class="checkbox-custom"></span>
|
||||||
|
<span class="checkbox-text">
|
||||||
|
<strong>Active Event</strong>
|
||||||
|
<small>Display this event publicly</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Venues Section -->
|
||||||
|
<div class="form-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-geo-alt"></i>
|
||||||
|
<h3>Venues</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div formArrayName="venues">
|
||||||
|
<div *ngFor="let venue of venues.controls; let i = index" class="array-item">
|
||||||
|
<div class="array-item-header">
|
||||||
|
<span>Venue {{ i + 1 }}</span>
|
||||||
|
<button type="button" class="btn-remove" (click)="removeVenue(i)">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div [formGroupName]="i" class="array-item-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<input formControlName="title" placeholder="Venue title" class="form-input">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<input formControlName="date" placeholder="MM-DD-YYYY" class="form-input">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<input formControlName="address" placeholder="Address" class="form-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<input formControlName="info" placeholder="Additional info" class="form-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-add" (click)="addVenue()">
|
||||||
|
<i class="bi bi-plus"></i>
|
||||||
|
<span>Add Venue</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Highlights Section -->
|
||||||
|
<div class="form-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-star"></i>
|
||||||
|
<h3>Event Highlights</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div formArrayName="highlights">
|
||||||
|
<div *ngFor="let highlight of highlights.controls; let i = index" class="simple-array-item">
|
||||||
|
<input [formControlName]="i" class="form-input" placeholder="Event highlight">
|
||||||
|
<button type="button" class="btn-remove-inline" (click)="removeHighlight(i)">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-add" (click)="addHighlight()">
|
||||||
|
<i class="bi bi-plus"></i>
|
||||||
|
<span>Add Highlight</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column -->
|
||||||
|
<div class="form-column">
|
||||||
|
<!-- Main Event Image Section -->
|
||||||
|
<div class="form-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-image"></i>
|
||||||
|
<h3>Main Event Image</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Upload Section -->
|
||||||
|
<div class="upload-section">
|
||||||
|
<h4 class="upload-title">Upload Image File</h4>
|
||||||
|
<div class="upload-wrapper">
|
||||||
|
<div class="file-input-wrapper">
|
||||||
|
<input type="file"
|
||||||
|
class="file-input"
|
||||||
|
id="mainImageFile"
|
||||||
|
accept="image/*"
|
||||||
|
(change)="onMainImageFileSelected($event)"
|
||||||
|
#mainImageFile>
|
||||||
|
<label for="mainImageFile" class="file-label">
|
||||||
|
<i class="bi bi-cloud-upload"></i>
|
||||||
|
<span>Choose file</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="btn-upload"
|
||||||
|
(click)="uploadMainImage()"
|
||||||
|
[disabled]="!selectedMainImageFile || mainImageUploading">
|
||||||
|
<i *ngIf="mainImageUploading" class="spinner-border spinner-border-sm"></i>
|
||||||
|
<i *ngIf="!mainImageUploading" class="bi bi-upload"></i>
|
||||||
|
<span>{{ mainImageUploading ? 'Uploading...' : 'Upload' }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- URL Input Section -->
|
<!-- URL Input Section -->
|
||||||
<div class="card mb-2">
|
<div class="url-section">
|
||||||
<div class="card-body">
|
<h4 class="url-title">Or Enter Image URL</h4>
|
||||||
<h6 class="card-title">Or Enter Image URL</h6>
|
|
||||||
<input id="mainImage"
|
<input id="mainImage"
|
||||||
formControlName="mainImage"
|
formControlName="mainImage"
|
||||||
class="form-control"
|
class="form-input"
|
||||||
placeholder="https://images.unsplash.com/..." />
|
placeholder="https://images.unsplash.com/...">
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Image Preview -->
|
<!-- Image Preview -->
|
||||||
<div *ngIf="eventForm.get('mainImage')?.value" class="mt-2">
|
<div *ngIf="eventForm.get('mainImage')?.value" class="image-preview">
|
||||||
<img [src]="eventForm.get('mainImage')?.value"
|
<img [src]="eventForm.get('mainImage')?.value"
|
||||||
alt="Main Image Preview"
|
alt="Main Image Preview">
|
||||||
class="img-thumbnail"
|
|
||||||
style="max-width: 200px; max-height: 150px;">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<small class="form-text text-muted">This will be the primary image displayed for the event</small>
|
<p class="form-hint">This will be the primary image displayed for the event</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gallery Images Section -->
|
<!-- Gallery Images Section -->
|
||||||
<div class="form-group mb-3">
|
<div class="form-card">
|
||||||
<label class="form-label text-primary">Gallery Images</label>
|
<div class="card-header">
|
||||||
|
<i class="bi bi-images"></i>
|
||||||
<!-- Gallery Upload Section -->
|
<h3>Gallery Images</h3>
|
||||||
<div class="card mb-2">
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h6 class="card-title">Upload Gallery Images</h6>
|
<!-- Upload Section -->
|
||||||
<div class="row">
|
<div class="upload-section">
|
||||||
<div class="col-8">
|
<h4 class="upload-title">Upload Gallery Images</h4>
|
||||||
|
<div class="upload-wrapper">
|
||||||
|
<div class="file-input-wrapper">
|
||||||
<input type="file"
|
<input type="file"
|
||||||
class="form-control"
|
class="file-input"
|
||||||
|
id="galleryImageFiles"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
multiple
|
multiple
|
||||||
(change)="onGalleryImagesSelected($event)"
|
(change)="onGalleryImagesSelected($event)"
|
||||||
#galleryImageFiles>
|
#galleryImageFiles>
|
||||||
|
<label for="galleryImageFiles" class="file-label">
|
||||||
|
<i class="bi bi-cloud-upload"></i>
|
||||||
|
<span>Choose files</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="btn btn-primary btn-sm w-100"
|
class="btn-upload"
|
||||||
(click)="uploadGalleryImages()"
|
(click)="uploadGalleryImages()"
|
||||||
[disabled]="!selectedGalleryImageFiles?.length || galleryImagesUploading">
|
[disabled]="!selectedGalleryImageFiles?.length || galleryImagesUploading">
|
||||||
<span *ngIf="galleryImagesUploading" class="spinner-border spinner-border-sm me-1"></span>
|
<i *ngIf="galleryImagesUploading" class="spinner-border spinner-border-sm"></i>
|
||||||
{{ galleryImagesUploading ? 'Uploading...' : 'Upload' }}
|
<i *ngIf="!galleryImagesUploading" class="bi bi-upload"></i>
|
||||||
|
<span>{{ galleryImagesUploading ? 'Uploading...' : 'Upload' }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Manual Gallery URL Entry -->
|
<!-- Manual URL Entry -->
|
||||||
<div class="card mb-2">
|
<div class="url-section">
|
||||||
<div class="card-body">
|
<h4 class="url-title">Or Add Gallery URLs</h4>
|
||||||
<h6 class="card-title">Or Add Gallery URLs</h6>
|
|
||||||
<div formArrayName="galleryImages">
|
<div formArrayName="galleryImages">
|
||||||
<div *ngFor="let image of galleryImages.controls; let i = index" class="row mb-2">
|
<div *ngFor="let image of galleryImages.controls; let i = index" class="gallery-url-item">
|
||||||
<div class="col-8">
|
<div>
|
||||||
<input [formControlName]="i" class="form-control" placeholder="https://images.unsplash.com/..." />
|
<input [formControlName]="i" class="form-input" placeholder="https://images.unsplash.com/...">
|
||||||
</div>
|
<button type="button" class="btn-remove-inline" (click)="removeGalleryImage(i)">
|
||||||
<div class="col-4">
|
<i class="bi bi-x"></i>
|
||||||
<button type="button" class="btn btn-danger btn-sm w-100" (click)="removeGalleryImage(i)">
|
|
||||||
Remove
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Image Preview -->
|
<!-- Thumbnail Preview -->
|
||||||
<div class="col-12" *ngIf="galleryImages.at(i).value">
|
<div class="gallery-thumbnail" *ngIf="galleryImages.at(i).value">
|
||||||
<img [src]="galleryImages.at(i).value"
|
<img [src]="galleryImages.at(i).value" alt="Gallery Preview">
|
||||||
alt="Gallery Preview"
|
|
||||||
class="img-thumbnail mt-2"
|
|
||||||
style="max-width: 100px; max-height: 80px;">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm" (click)="addGalleryImage()">
|
</div>
|
||||||
Add Gallery URL
|
<button type="button" class="btn-add" (click)="addGalleryImage()">
|
||||||
|
<i class="bi bi-plus"></i>
|
||||||
|
<span>Add Gallery URL</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="form-hint">Add up to 4 gallery images for the event grid</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<small class="form-text text-muted">Add up to 4 gallery images for the event grid</small>
|
<!-- Organisers Section -->
|
||||||
|
<div class="form-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-people"></i>
|
||||||
|
<h3>Organisers</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group mb-3">
|
|
||||||
<label class="form-label text-primary">Venues</label>
|
|
||||||
<div formArrayName="venues">
|
|
||||||
<div *ngFor="let venue of venues.controls; let i = index" class="card mb-2">
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div [formGroupName]="i">
|
|
||||||
<div class="row mb-2">
|
|
||||||
<div class="col-12">
|
|
||||||
<input formControlName="title" placeholder="Title" class="form-control" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mb-2">
|
|
||||||
<div class="col-6">
|
|
||||||
<input formControlName="date" placeholder="Date" class="form-control" />
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<input formControlName="address" placeholder="Address" class="form-control" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mb-2">
|
|
||||||
<div class="col-12">
|
|
||||||
<input formControlName="info" placeholder="Info" class="form-control" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-danger btn-sm float-end" (click)="removeVenue(i)">
|
|
||||||
X
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-primary" (click)="addVenue()">
|
|
||||||
<i class="bi bi-plus"></i> Add Venue
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group mb-3">
|
|
||||||
<label class="form-label text-primary">Highlights</label>
|
|
||||||
<div formArrayName="highlights">
|
|
||||||
<div *ngFor="let highlight of highlights.controls; let i = index" class="card mb-2">
|
|
||||||
<div class="card-body">
|
|
||||||
<input [formControlName]="i" class="form-control" placeholder="Highlight" />
|
|
||||||
<button type="button" class="btn btn-danger btn-sm float-end mt-2" (click)="removeHighlight(i)">
|
|
||||||
X
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-primary" (click)="addHighlight()">
|
|
||||||
<i class="bi bi-plus"></i> Add Highlight
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group mb-3">
|
|
||||||
<label class="form-label text-primary">Organisers</label>
|
|
||||||
<div formArrayName="organisers">
|
<div formArrayName="organisers">
|
||||||
<div *ngFor="let organiser of organisers.controls; let i = index" class="card mb-2">
|
<div *ngFor="let organiser of organisers.controls; let i = index" class="simple-array-item">
|
||||||
<div class="card-body">
|
<input [formControlName]="i" class="form-input" placeholder="Organiser name">
|
||||||
<input [formControlName]="i" class="form-control" placeholder="Organiser" />
|
<button type="button" class="btn-remove-inline" (click)="removeOrganiser(i)">
|
||||||
<button type="button" class="btn btn-danger btn-sm float-end mt-2" (click)="removeOrganiser(i)">
|
<i class="bi bi-x"></i>
|
||||||
X
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-primary" (click)="addOrganiser()">
|
<button type="button" class="btn-add" (click)="addOrganiser()">
|
||||||
<i class="bi bi-plus"></i> Add Organiser
|
<i class="bi bi-plus"></i>
|
||||||
|
<span>Add Organiser</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group mb-3">
|
<!-- Fees Section -->
|
||||||
<label class="form-label text-primary">Fees</label>
|
<div class="form-card">
|
||||||
<div formArrayName="fees">
|
<div class="card-header">
|
||||||
<div *ngFor="let fee of fees.controls; let i = index" class="card mb-2">
|
<i class="bi bi-currency-rupee"></i>
|
||||||
|
<h3>Registration Fees</h3>
|
||||||
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div [formGroupName]="i">
|
<div formArrayName="fees">
|
||||||
<div class="row mb-2">
|
<div *ngFor="let fee of fees.controls; let i = index" class="array-item">
|
||||||
<div class="col-8">
|
<div class="array-item-header">
|
||||||
<input formControlName="description" placeholder="Description" class="form-control" />
|
<span>Fee {{ i + 1 }}</span>
|
||||||
</div>
|
<button type="button" class="btn-remove" (click)="removeFee(i)">
|
||||||
<div class="col-4">
|
<i class="bi bi-x"></i>
|
||||||
<input formControlName="cost" placeholder="Cost" type="number" class="form-control" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-danger btn-sm float-end mt-2" (click)="removeFee(i)">
|
|
||||||
X
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div [formGroupName]="i" class="array-item-body">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group flex-2">
|
||||||
|
<input formControlName="description" placeholder="Fee description" class="form-input">
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-primary" (click)="addFee()">
|
<div class="form-group flex-1">
|
||||||
<i class="bi bi-plus"></i> Add Fee
|
<input formControlName="cost" placeholder="Cost" type="number" class="form-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-add" (click)="addFee()">
|
||||||
|
<i class="bi bi-plus"></i>
|
||||||
|
<span>Add Fee</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-success">Submit</button>
|
|
||||||
|
<!-- Form Actions -->
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn-secondary" routerLink="/dashboard/event">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
<span>Cancel</span>
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn-submit" [disabled]="!eventForm.valid">
|
||||||
|
<i class="bi bi-check-circle"></i>
|
||||||
|
<span>{{ eventId ? 'Update Event' : 'Create Event' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
@ -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,6 +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: [''], // Registration/booking link
|
||||||
|
learnMoreLink: [''], // Additional information link
|
||||||
isActive: [true]
|
isActive: [true]
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -146,6 +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,
|
||||||
|
learnMoreLink: event.learnMoreLink,
|
||||||
isActive: event.isActive
|
isActive: event.isActive
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -177,7 +181,7 @@ export class EventFormComponent implements OnInit {
|
|||||||
}));
|
}));
|
||||||
} else if (controlName === 'fees') {
|
} else if (controlName === 'fees') {
|
||||||
array.push(this.fb.group({
|
array.push(this.fb.group({
|
||||||
description: [value.description || ''], // Changed from 'desc' to 'description'
|
description: [value.description || ''],
|
||||||
cost: [value.cost || '']
|
cost: [value.cost || '']
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
@ -254,7 +258,7 @@ export class EventFormComponent implements OnInit {
|
|||||||
|
|
||||||
addFee() {
|
addFee() {
|
||||||
this.fees.push(this.fb.group({
|
this.fees.push(this.fb.group({
|
||||||
description: [''], // Changed from 'desc' to 'description'
|
description: [''],
|
||||||
cost: ['']
|
cost: ['']
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,437 @@
|
|||||||
|
/* Event Layout */
|
||||||
|
.event-layout {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
width: 260px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-content {
|
||||||
|
margin-left: 260px;
|
||||||
|
flex: 1;
|
||||||
|
padding: 32px;
|
||||||
|
width: calc(100% - 260px);
|
||||||
|
max-width: 1600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header Section */
|
||||||
|
.event-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table Container */
|
||||||
|
.table-container {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Events Table */
|
||||||
|
.events-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-table thead {
|
||||||
|
background: #f9fafb;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-table thead th {
|
||||||
|
padding: 16px 20px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-table tbody tr {
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-table tbody tr:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-table tbody tr:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-table tbody td {
|
||||||
|
padding: 16px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #374151;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event Image Cell */
|
||||||
|
.event-image-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-thumbnail {
|
||||||
|
width: 60px;
|
||||||
|
height: 40px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-image {
|
||||||
|
width: 60px;
|
||||||
|
height: 40px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event Code Cell */
|
||||||
|
.event-code-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-badge {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3b82f6;
|
||||||
|
background: #eff6ff;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event Details Cell */
|
||||||
|
.event-details-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-subject {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-description {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Date Cell */
|
||||||
|
.date-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-cell i {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fees Cell */
|
||||||
|
.fees-cell {
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fee-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fee-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fee-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fee-cost {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fee-more {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #3b82f6;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-fees {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badge */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-inactive {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Buttons */
|
||||||
|
.action-buttons {
|
||||||
|
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;
|
||||||
|
font-size: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action i {
|
||||||
|
font-size: 14px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-edit:hover i {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #dc2626;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:hover i {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
padding: 80px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 36px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.event-content {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-table {
|
||||||
|
min-width: 1000px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.event-content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-details-cell {
|
||||||
|
max-width: 250px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.event-content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print Styles */
|
||||||
|
@media print {
|
||||||
|
.sidebar,
|
||||||
|
.header-actions,
|
||||||
|
.action-buttons {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-content {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,67 +1,128 @@
|
|||||||
<app-menu></app-menu>
|
<div class="event-layout">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<app-menu class="sidebar"></app-menu>
|
||||||
|
|
||||||
<div class="container mt-4">
|
<!-- Main Content -->
|
||||||
<h2 class="mb-4">Events</h2>
|
<div class="event-content">
|
||||||
<div class="mb-3">
|
<!-- Header Section -->
|
||||||
<a routerLink="/dashboard/eventForm" class="btn btn-primary">
|
<div class="event-header">
|
||||||
<i class="bi bi-plus"></i> Add New Event
|
<div class="header-left">
|
||||||
</a>
|
<h1 class="page-title">Events Management</h1>
|
||||||
|
<p class="page-subtitle">Manage and organize all your events</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="header-actions">
|
||||||
<table class="table table-bordered">
|
<button class="btn-primary" routerLink="/dashboard/eventForm">
|
||||||
|
<i class="fa fa-plus"></i>
|
||||||
|
<span>Add New Event</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Events Table -->
|
||||||
|
<div class="table-container">
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="events-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>Image</th>
|
||||||
<th>Code</th>
|
<th>Code</th>
|
||||||
<th>Year</th>
|
<th>Event Details</th>
|
||||||
<th>Subject</th>
|
|
||||||
<th>Title</th>
|
|
||||||
<th>Description</th>
|
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th>Fees</th>
|
<th>Fees</th>
|
||||||
<th>Main Image</th>
|
<th>Status</th>
|
||||||
<th>Active</th>
|
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let event of events">
|
<tr *ngFor="let event of events">
|
||||||
<td>{{ event.code }}</td>
|
|
||||||
<td>{{ event.year }}</td>
|
|
||||||
<td>{{ event.subject }}</td>
|
|
||||||
<td>{{ event.title }}</td>
|
|
||||||
<td>{{ event.description?.substring(0, 50) }}{{ event.description?.length > 50 ? '...' : '' }}</td>
|
|
||||||
<td>{{ event.date }}</td>
|
|
||||||
<td>
|
|
||||||
<span *ngIf="event.fee && event.fee.length > 0">
|
|
||||||
{{ event.fee[0].description }}: ₹{{ event.fee[0].cost }}
|
|
||||||
<span *ngIf="event.fee.length > 1"> (+{{ event.fee.length - 1 }} more)</span>
|
|
||||||
</span>
|
|
||||||
<span *ngIf="!event.fee || event.fee.length === 0" class="text-muted">No fees</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
|
<div class="event-image-cell">
|
||||||
<img *ngIf="event.mainImage"
|
<img *ngIf="event.mainImage"
|
||||||
[src]="event.mainImage"
|
[src]="event.mainImage"
|
||||||
alt="Event Image"
|
alt="Event Image"
|
||||||
style="width: 50px; height: 30px; object-fit: cover; border-radius: 4px;"
|
class="event-thumbnail"
|
||||||
onerror="this.style.display='none'">
|
onerror="this.style.display='none'">
|
||||||
<span *ngIf="!event.mainImage" class="text-muted">No image</span>
|
<div *ngIf="!event.mainImage" class="no-image">
|
||||||
|
<i class="fa fa-image"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge"
|
<div class="event-code-cell">
|
||||||
[ngClass]="event.isActive ? 'bg-success' : 'bg-secondary'">
|
<span class="code-badge">{{ event.code }}</span>
|
||||||
{{ event.isActive ? 'Yes' : 'No' }}
|
<span class="year-text">{{ event.year }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="event-details-cell">
|
||||||
|
<div class="event-title">{{ event.title }}</div>
|
||||||
|
<div class="event-subject">{{ event.subject }}</div>
|
||||||
|
<div class="event-description">
|
||||||
|
{{ event.description?.substring(0, 80) }}{{ event.description?.length > 80 ? '...' : '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="date-cell">
|
||||||
|
<i class="fa fa-calendar"></i>
|
||||||
|
<span>{{ event.date }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="fees-cell">
|
||||||
|
<div *ngIf="event.fee && event.fee.length > 0" class="fee-info">
|
||||||
|
<div class="fee-item">
|
||||||
|
<span class="fee-desc">{{ event.fee[0].description }}</span>
|
||||||
|
<span class="fee-cost">₹{{ event.fee[0].cost }}</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="event.fee.length > 1" class="fee-more">
|
||||||
|
+{{ event.fee.length - 1 }} more
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span *ngIf="!event.fee || event.fee.length === 0" class="no-fees">No fees</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge"
|
||||||
|
[class.status-active]="event.isActive"
|
||||||
|
[class.status-inactive]="!event.isActive">
|
||||||
|
<i class="fa"
|
||||||
|
[class.fa-check-circle]="event.isActive"
|
||||||
|
[class.fa-times-circle]="!event.isActive"></i>
|
||||||
|
{{ event.isActive ? 'Active' : 'Inactive' }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a [routerLink]="['/dashboard/eventForm', event.id]" class="btn btn-warning btn-sm me-2">
|
<div class="action-buttons">
|
||||||
<i class="bi bi-pencil"></i> Edit
|
<button class="btn-action btn-edit"
|
||||||
</a>
|
[routerLink]="['/dashboard/eventForm', event.id]"
|
||||||
<button (click)="deleteEvent(event.id)" class="btn btn-danger btn-sm">
|
title="Edit">
|
||||||
<i class="bi bi-trash"></i> Delete
|
<i class="fa fa-edit"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn-action btn-delete"
|
||||||
|
(click)="deleteEvent(event.id)"
|
||||||
|
title="Delete">
|
||||||
|
<i class="fa fa-trash-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div *ngIf="events.length === 0" class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<i class="fa fa-calendar-times"></i>
|
||||||
|
</div>
|
||||||
|
<h3>No events found</h3>
|
||||||
|
<p>Get started by creating your first event</p>
|
||||||
|
<button class="btn-primary" routerLink="/dashboard/eventForm">
|
||||||
|
<i class="fa fa-plus"></i>
|
||||||
|
<span>Add New Event</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
import { FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms';
|
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { EventService } from 'src/app/service/event.service';
|
import { EventService } from 'src/app/service/event.service';
|
||||||
|
import { environment } from 'src/environments/environment';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-event',
|
selector: 'app-event',
|
||||||
@ -9,7 +9,7 @@ import { EventService } from 'src/app/service/event.service';
|
|||||||
styleUrls: ['./event.component.css']
|
styleUrls: ['./event.component.css']
|
||||||
})
|
})
|
||||||
export class EventComponent implements OnInit {
|
export class EventComponent implements OnInit {
|
||||||
events: any[] = []; // Define the type according to your model
|
events: any[] = [];
|
||||||
|
|
||||||
constructor(private eventService: EventService, private router: Router) { }
|
constructor(private eventService: EventService, private router: Router) { }
|
||||||
|
|
||||||
@ -18,12 +18,37 @@ export class EventComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadEvents(): void {
|
loadEvents(): void {
|
||||||
this.eventService.getEvents().subscribe(events => this.events = events);
|
this.eventService.getEvents().subscribe(events => {
|
||||||
|
console.log('Raw events from API:', events);
|
||||||
|
|
||||||
|
// Convert relative URLs to absolute URLs using the backend domain
|
||||||
|
this.events = events.map(event => ({
|
||||||
|
...event,
|
||||||
|
mainImage: this.getFullImageUrl(event.mainImage)
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log('Processed events:', this.events);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert relative URL to full URL
|
||||||
|
private getFullImageUrl(imageUrl: string): string {
|
||||||
|
if (!imageUrl) return '';
|
||||||
|
|
||||||
|
// If it's already a full URL, return as-is
|
||||||
|
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
|
||||||
|
return imageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a relative URL, prepend the backend API URL
|
||||||
|
// Remove any leading slash to avoid double slashes
|
||||||
|
const cleanUrl = imageUrl.startsWith('/') ? imageUrl.substring(1) : imageUrl;
|
||||||
|
return `${environment.apiUrl}/${cleanUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteEvent(id: number): void {
|
deleteEvent(id: number): void {
|
||||||
if (confirm('Are you sure you want to delete this event?')) {
|
if (confirm('Are you sure you want to delete this event?')) {
|
||||||
// this.eventService.deleteEvent(id).subscribe(() => this.loadEvents());
|
this.eventService.deleteEvent(id).subscribe(() => this.loadEvents());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,20 +1,634 @@
|
|||||||
/* This ensures that the container takes at least the full viewport height */
|
/* Dashboard Container */
|
||||||
.min-vh-100 {
|
.dashboard-container {
|
||||||
|
display: flex;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
background: #f8f9fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Optional: Add more custom styles if needed */
|
/* Sidebar Styling */
|
||||||
.text-center {
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
width: 260px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content Area */
|
||||||
|
.main-content {
|
||||||
|
margin-left: 260px;
|
||||||
|
flex: 1;
|
||||||
|
padding: 32px;
|
||||||
|
width: calc(100% - 260px);
|
||||||
|
max-width: 1400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard Header */
|
||||||
|
.dashboard-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Refresh Button */
|
||||||
|
.btn-refresh {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-refresh:hover:not(:disabled) {
|
||||||
|
background: #f9fafb;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-refresh:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-refresh i.spinning {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading Container */
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 80px 20px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner > div {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: bounce 1.4s infinite ease-in-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner .bounce1 {
|
||||||
|
animation-delay: -0.32s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner .bounce2 {
|
||||||
|
animation-delay: -0.16s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 80%, 100% {
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: scale(1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error Alert */
|
||||||
|
.alert-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #991b1b;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error i {
|
||||||
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close-alert {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #991b1b;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close-alert:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard Content */
|
||||||
|
.dashboard-content {
|
||||||
|
animation: fadeIn 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick Stats Cards */
|
||||||
|
.quick-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-minimal {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-minimal:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-minimal-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-blue {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-purple {
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-green {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-orange {
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #f97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-minimal-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-minimal-content h3 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-minimal-content p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Grid */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Card */
|
||||||
|
.stats-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 20px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title i {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value-success {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value-info {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value-warning {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-badge {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-badge-success {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-badge-info {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recent Activities Section */
|
||||||
|
.recent-section {
|
||||||
|
margin-top: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header i {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activities-list {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-item:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-icon-wrapper {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-primary {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-success {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-details {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-details h4 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-details p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
.empty-icon {
|
||||||
color: #007bff; /* Bootstrap primary color or customize */
|
width: 64px;
|
||||||
font-size: 2.5rem; /* Adjust size as needed */
|
height: 64px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lead {
|
.empty-icon i {
|
||||||
color: #6c757d; /* Bootstrap secondary color or customize */
|
font-size: 28px;
|
||||||
font-size: 1.25rem; /* Adjust size as needed */
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.main-content {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-refresh {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-stats {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main-content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-minimal-content h3 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.main-content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-minimal {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-minimal-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header,
|
||||||
|
.card-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print Styles */
|
||||||
|
@media print {
|
||||||
|
.sidebar,
|
||||||
|
.btn-refresh {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card,
|
||||||
|
.stat-card-minimal {
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth Scrolling */
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus Styles for Accessibility */
|
||||||
|
.btn-refresh:focus,
|
||||||
|
.btn-close-alert:focus {
|
||||||
|
outline: 2px solid #3b82f6;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Hover Effect Enhancement */
|
||||||
|
.stats-card::before,
|
||||||
|
.stat-card-minimal::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, #3b82f6 0%, #8b5cf6 100%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-minimal {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card:hover::before,
|
||||||
|
.stat-card-minimal:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
@ -1,9 +1,288 @@
|
|||||||
|
<div class="dashboard-container">
|
||||||
|
<!-- Sidebar Menu -->
|
||||||
|
<app-menu class="sidebar"></app-menu>
|
||||||
|
|
||||||
<app-menu ></app-menu>
|
<!-- Main Dashboard Content -->
|
||||||
|
<div class="main-content">
|
||||||
|
<!-- Dashboard Header -->
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<h1 class="dashboard-title">Dashboard Overview</h1>
|
||||||
|
<p class="dashboard-subtitle">Welcome back! Here's what's happening today.</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn-refresh" (click)="refreshDashboard()" [disabled]="loading">
|
||||||
|
<i class="fa fa-sync-alt" [class.spinning]="loading"></i>
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="container d-flex justify-content-center align-items-center min-vh-100">
|
<!-- Loading Spinner -->
|
||||||
<div class="text-center">
|
<div *ngIf="loading" class="loading-container">
|
||||||
<h1>Welcome, CMC!</h1>
|
<div class="spinner">
|
||||||
|
<div class="bounce1"></div>
|
||||||
|
<div class="bounce2"></div>
|
||||||
|
<div class="bounce3"></div>
|
||||||
|
</div>
|
||||||
|
<p class="loading-text">Loading dashboard data...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Message -->
|
||||||
|
<div *ngIf="error && !loading" class="alert-error">
|
||||||
|
<i class="fa fa-exclamation-circle"></i>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
<button class="btn-close-alert" (click)="error = null">
|
||||||
|
<i class="fa fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard Content -->
|
||||||
|
<div *ngIf="!loading && !error" class="dashboard-content">
|
||||||
|
|
||||||
|
<!-- Quick Stats Summary Cards -->
|
||||||
|
<div class="quick-stats">
|
||||||
|
<div class="stat-card-minimal">
|
||||||
|
<div class="stat-minimal-icon stat-icon-blue">
|
||||||
|
<i class="fa fa-calendar-check"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-minimal-content">
|
||||||
|
<h3>{{ stats.events.upcoming }}</h3>
|
||||||
|
<p>Upcoming Events</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card-minimal">
|
||||||
|
<div class="stat-minimal-icon stat-icon-purple">
|
||||||
|
<i class="fa fa-clock"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-minimal-content">
|
||||||
|
<h3>{{ stats.careers.pendingApplications + stats.education.pendingApplications }}</h3>
|
||||||
|
<p>Pending Reviews</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card-minimal">
|
||||||
|
<div class="stat-minimal-icon stat-icon-green">
|
||||||
|
<i class="fa fa-briefcase"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-minimal-content">
|
||||||
|
<h3>{{ stats.careers.activeJobs }}</h3>
|
||||||
|
<p>Active Jobs</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card-minimal">
|
||||||
|
<div class="stat-minimal-icon stat-icon-orange">
|
||||||
|
<i class="fa fa-graduation-cap"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-minimal-content">
|
||||||
|
<h3>{{ stats.education.activeCourses }}</h3>
|
||||||
|
<p>Active Courses</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Stats Grid -->
|
||||||
|
<div class="stats-grid">
|
||||||
|
|
||||||
|
<!-- Events Card -->
|
||||||
|
<div class="stats-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">
|
||||||
|
<i class="fa fa-calendar-alt"></i>
|
||||||
|
<span>Events</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Total Events</span>
|
||||||
|
<span class="stat-value">{{ stats.events.total }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Upcoming</span>
|
||||||
|
<span class="stat-value stat-value-success">{{ stats.events.upcoming }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Active</span>
|
||||||
|
<span class="stat-value stat-value-info">{{ stats.events.active }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Blogs Card -->
|
||||||
|
<div class="stats-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">
|
||||||
|
<i class="fa fa-blog"></i>
|
||||||
|
<span>Blogs</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Total Posts</span>
|
||||||
|
<span class="stat-value">{{ stats.blogs.total }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Published</span>
|
||||||
|
<span class="stat-value stat-value-success">{{ stats.blogs.published }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Drafts</span>
|
||||||
|
<span class="stat-value stat-value-warning">{{ stats.blogs.drafts }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Careers Card -->
|
||||||
|
<div class="stats-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">
|
||||||
|
<i class="fa fa-briefcase"></i>
|
||||||
|
<span>Careers</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Active Jobs</span>
|
||||||
|
<span class="stat-value">{{ stats.careers.activeJobs }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Applications</span>
|
||||||
|
<span class="stat-value stat-value-info">{{ stats.careers.totalApplications }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Pending</span>
|
||||||
|
<span class="stat-value stat-value-warning">{{ stats.careers.pendingApplications }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Education Card -->
|
||||||
|
<div class="stats-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">
|
||||||
|
<i class="fa fa-graduation-cap"></i>
|
||||||
|
<span>Education</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Active Courses</span>
|
||||||
|
<span class="stat-value">{{ stats.education.activeCourses }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Enrollments</span>
|
||||||
|
<span class="stat-value stat-value-info">{{ stats.education.totalApplications }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Pending</span>
|
||||||
|
<span class="stat-value stat-value-warning">{{ stats.education.pendingApplications }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Professors Card -->
|
||||||
|
<div class="stats-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">
|
||||||
|
<i class="fa fa-chalkboard-teacher"></i>
|
||||||
|
<span>Professors</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Total</span>
|
||||||
|
<span class="stat-value">{{ stats.professors.total }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Active</span>
|
||||||
|
<span class="stat-value stat-value-success">{{ stats.professors.active }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Status</span>
|
||||||
|
<span class="stat-badge stat-badge-success">Active</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Testimonials Card -->
|
||||||
|
<div class="stats-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">
|
||||||
|
<i class="fa fa-quote-left"></i>
|
||||||
|
<span>Testimonials</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Total</span>
|
||||||
|
<span class="stat-value">{{ stats.testimonials.total }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Active</span>
|
||||||
|
<span class="stat-value stat-value-success">{{ stats.testimonials.active }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Status</span>
|
||||||
|
<span class="stat-badge stat-badge-success">Published</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Milestones Card -->
|
||||||
|
<div class="stats-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">
|
||||||
|
<i class="fa fa-flag-checkered"></i>
|
||||||
|
<span>Milestones</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Total</span>
|
||||||
|
<span class="stat-value">{{ stats.milestones.total }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Active</span>
|
||||||
|
<span class="stat-value stat-value-success">{{ stats.milestones.active }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Status</span>
|
||||||
|
<span class="stat-badge stat-badge-info">Tracking</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Activities Section -->
|
||||||
|
<div class="recent-section" *ngIf="recentActivities.length > 0">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>
|
||||||
|
<i class="fa fa-clock"></i>
|
||||||
|
Recent Activities
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="activities-list">
|
||||||
|
<div class="activity-item" *ngFor="let activity of recentActivities">
|
||||||
|
<div class="activity-icon-wrapper" [ngClass]="'activity-' + activity.color">
|
||||||
|
<i class="fa fa-{{ activity.icon }}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="activity-details">
|
||||||
|
<h4>{{ activity.type }}</h4>
|
||||||
|
<p>{{ activity.description }}</p>
|
||||||
|
<span class="activity-time">{{ getTimeAgo(activity.date) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="activity-item empty-state" *ngIf="recentActivities.length === 0">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<i class="fa fa-inbox"></i>
|
||||||
|
</div>
|
||||||
|
<p>No recent activities</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -1,4 +1,46 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { forkJoin } from 'rxjs';
|
||||||
|
import { EventService } from '../../service/event.service';
|
||||||
|
import { BlogService } from '../../service/blog.service';
|
||||||
|
import { CareerService } from '../../service/career.service';
|
||||||
|
import { EducationService } from '../../service/education.service';
|
||||||
|
import { MilestoneService } from '../../service/milestone.service';
|
||||||
|
import { DashboardService } from '../../service/dashboard.service';
|
||||||
|
|
||||||
|
interface DashboardStats {
|
||||||
|
events: {
|
||||||
|
total: number;
|
||||||
|
upcoming: number;
|
||||||
|
active: number;
|
||||||
|
};
|
||||||
|
blogs: {
|
||||||
|
total: number;
|
||||||
|
published: number;
|
||||||
|
drafts: number;
|
||||||
|
};
|
||||||
|
careers: {
|
||||||
|
activeJobs: number;
|
||||||
|
totalApplications: number;
|
||||||
|
pendingApplications: number;
|
||||||
|
};
|
||||||
|
education: {
|
||||||
|
activeCourses: number;
|
||||||
|
totalApplications: number;
|
||||||
|
pendingApplications: number;
|
||||||
|
};
|
||||||
|
professors: {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
};
|
||||||
|
testimonials: {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
};
|
||||||
|
milestones: {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
@ -6,10 +48,166 @@ import { Component, OnInit } from '@angular/core';
|
|||||||
styleUrls: ['./home.component.css']
|
styleUrls: ['./home.component.css']
|
||||||
})
|
})
|
||||||
export class HomeComponent implements OnInit {
|
export class HomeComponent implements OnInit {
|
||||||
|
stats: DashboardStats = {
|
||||||
|
events: { total: 0, upcoming: 0, active: 0 },
|
||||||
|
blogs: { total: 0, published: 0, drafts: 0 },
|
||||||
|
careers: { activeJobs: 0, totalApplications: 0, pendingApplications: 0 },
|
||||||
|
education: { activeCourses: 0, totalApplications: 0, pendingApplications: 0 },
|
||||||
|
professors: { total: 0, active: 0 },
|
||||||
|
testimonials: { total: 0, active: 0 },
|
||||||
|
milestones: { total: 0, active: 0 }
|
||||||
|
};
|
||||||
|
|
||||||
constructor() { }
|
loading = true;
|
||||||
|
error: string | null = null;
|
||||||
|
|
||||||
|
recentActivities: any[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private eventService: EventService,
|
||||||
|
private blogService: BlogService,
|
||||||
|
private careerService: CareerService,
|
||||||
|
private educationService: EducationService,
|
||||||
|
private milestoneService: MilestoneService,
|
||||||
|
private dashboardService: DashboardService
|
||||||
|
) { }
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
this.loadDashboardData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadDashboardData(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
forkJoin({
|
||||||
|
events: this.eventService.getEvents(),
|
||||||
|
blogs: this.blogService.getBlogs(),
|
||||||
|
jobs: this.careerService.getAllJobs(),
|
||||||
|
jobApplications: this.careerService.getAllApplications(),
|
||||||
|
courses: this.educationService.getAllCourses(),
|
||||||
|
courseApplications: this.educationService.getAllApplications(),
|
||||||
|
milestones: this.milestoneService.getAllMilestones(),
|
||||||
|
professors: this.dashboardService.getProfessors(),
|
||||||
|
testimonials: this.dashboardService.getTestimonials()
|
||||||
|
}).subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.calculateStats(data);
|
||||||
|
this.generateRecentActivities(data);
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error('Error loading dashboard data:', error);
|
||||||
|
this.error = 'Failed to load dashboard data. Please try again.';
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateStats(data: any): void {
|
||||||
|
// Events stats
|
||||||
|
this.stats.events.total = data.events?.length || 0;
|
||||||
|
const now = new Date();
|
||||||
|
this.stats.events.upcoming = data.events?.filter((e: any) =>
|
||||||
|
new Date(e.eventDate) > now
|
||||||
|
).length || 0;
|
||||||
|
this.stats.events.active = data.events?.filter((e: any) => e.isActive).length || 0;
|
||||||
|
|
||||||
|
// Blogs stats
|
||||||
|
this.stats.blogs.total = data.blogs?.length || 0;
|
||||||
|
this.stats.blogs.published = data.blogs?.filter((b: any) => b.posted).length || 0;
|
||||||
|
this.stats.blogs.drafts = data.blogs?.filter((b: any) => !b.posted).length || 0;
|
||||||
|
|
||||||
|
// Careers stats
|
||||||
|
this.stats.careers.activeJobs = data.jobs?.filter((j: any) => j.isActive).length || 0;
|
||||||
|
this.stats.careers.totalApplications = data.jobApplications?.length || 0;
|
||||||
|
this.stats.careers.pendingApplications = data.jobApplications?.filter(
|
||||||
|
(a: any) => a.status === 'PENDING' || !a.status
|
||||||
|
).length || 0;
|
||||||
|
|
||||||
|
// Education stats
|
||||||
|
this.stats.education.activeCourses = data.courses?.filter((c: any) => c.isActive).length || 0;
|
||||||
|
this.stats.education.totalApplications = data.courseApplications?.length || 0;
|
||||||
|
this.stats.education.pendingApplications = data.courseApplications?.filter(
|
||||||
|
(a: any) => a.status === 'PENDING' || !a.status
|
||||||
|
).length || 0;
|
||||||
|
|
||||||
|
// Milestones stats
|
||||||
|
this.stats.milestones.total = data.milestones?.length || 0;
|
||||||
|
this.stats.milestones.active = data.milestones?.filter((m: any) => m.isActive).length || 0;
|
||||||
|
|
||||||
|
// Professors stats
|
||||||
|
this.stats.professors.total = data.professors?.length || 0;
|
||||||
|
this.stats.professors.active = data.professors?.filter(
|
||||||
|
(p: any) => p.status === 'ACTIVE'
|
||||||
|
).length || 0;
|
||||||
|
|
||||||
|
// Testimonials stats
|
||||||
|
this.stats.testimonials.total = data.testimonials?.length || 0;
|
||||||
|
this.stats.testimonials.active = data.testimonials?.filter((t: any) => t.isActive).length || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateRecentActivities(data: any): void {
|
||||||
|
this.recentActivities = [];
|
||||||
|
|
||||||
|
// Add recent job applications
|
||||||
|
if (data.jobApplications && data.jobApplications.length > 0) {
|
||||||
|
const recentJobApps = data.jobApplications
|
||||||
|
.sort((a: any, b: any) => new Date(b.createdDate).getTime() - new Date(a.createdDate).getTime())
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
recentJobApps.forEach((app: any) => {
|
||||||
|
this.recentActivities.push({
|
||||||
|
type: 'Job Application',
|
||||||
|
description: `New application from ${app.fullName} for ${app.job?.title || 'Unknown Position'}`,
|
||||||
|
date: app.createdDate,
|
||||||
|
icon: 'briefcase',
|
||||||
|
color: 'primary'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add recent course applications
|
||||||
|
if (data.courseApplications && data.courseApplications.length > 0) {
|
||||||
|
const recentCourseApps = data.courseApplications
|
||||||
|
.sort((a: any, b: any) => new Date(b.createdDate).getTime() - new Date(a.createdDate).getTime())
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
recentCourseApps.forEach((app: any) => {
|
||||||
|
this.recentActivities.push({
|
||||||
|
type: 'Course Application',
|
||||||
|
description: `New enrollment from ${app.fullName} for ${app.course?.title || 'Unknown Course'}`,
|
||||||
|
date: app.createdDate,
|
||||||
|
icon: 'book',
|
||||||
|
color: 'success'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort all activities by date and limit to 5
|
||||||
|
this.recentActivities = this.recentActivities
|
||||||
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||||
|
.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTimeAgo(date: string): string {
|
||||||
|
const now = new Date();
|
||||||
|
const past = new Date(date);
|
||||||
|
const diffInMs = now.getTime() - past.getTime();
|
||||||
|
const diffInMinutes = Math.floor(diffInMs / 60000);
|
||||||
|
const diffInHours = Math.floor(diffInMs / 3600000);
|
||||||
|
const diffInDays = Math.floor(diffInMs / 86400000);
|
||||||
|
|
||||||
|
if (diffInMinutes < 60) {
|
||||||
|
return `${diffInMinutes} minute${diffInMinutes !== 1 ? 's' : ''} ago`;
|
||||||
|
} else if (diffInHours < 24) {
|
||||||
|
return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`;
|
||||||
|
} else {
|
||||||
|
return `${diffInDays} day${diffInDays !== 1 ? 's' : ''} ago`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshDashboard(): void {
|
||||||
|
this.loadDashboardData();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,69 +1,330 @@
|
|||||||
body,
|
/* Login Layout */
|
||||||
html {
|
.login-layout {
|
||||||
margin: 0;
|
min-height: 100vh;
|
||||||
padding: 0;
|
|
||||||
height: 100%;
|
|
||||||
background: #60a3bc !important;
|
|
||||||
}
|
|
||||||
.user_card {
|
|
||||||
height: 500px;
|
|
||||||
width: 600px;
|
|
||||||
margin-top: auto;
|
|
||||||
margin-bottom: auto;
|
|
||||||
background: #afbfd8;
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-direction: column;
|
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||||
padding: 10px;
|
padding: 20px;
|
||||||
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
position: relative;
|
||||||
-webkit-box-shadow: 0 4px 8px 0 rgba(0, 0, 0a, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
overflow: hidden;
|
||||||
-moz-box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
}
|
||||||
border-radius: 5px;
|
|
||||||
|
|
||||||
}
|
.login-layout::before {
|
||||||
.brand_logo_container {
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
height: 170px;
|
top: -50%;
|
||||||
width: 170px;
|
right: -20%;
|
||||||
top: -75px;
|
width: 600px;
|
||||||
|
height: 600px;
|
||||||
|
background: radial-gradient(circle, rgba(230, 72, 56, 0.05) 0%, transparent 70%);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #60a3bc;
|
pointer-events: none;
|
||||||
padding: 10px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
.brand_logo {
|
|
||||||
height: 150px;
|
.login-layout::after {
|
||||||
width: 150px;
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -50%;
|
||||||
|
left: -20%;
|
||||||
|
width: 500px;
|
||||||
|
height: 500px;
|
||||||
|
background: radial-gradient(circle, rgba(1, 32, 104, 0.04) 0%, transparent 70%);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2px solid white;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.form_container {
|
|
||||||
margin-top: 100px;
|
/* Login Container */
|
||||||
}
|
.login-container {
|
||||||
.login_btn {
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #1B4F72!important;
|
max-width: 380px;
|
||||||
color: white !important;
|
animation: fadeInUp 0.6s ease;
|
||||||
}
|
}
|
||||||
.login_btn:focus {
|
|
||||||
box-shadow: none !important;
|
@keyframes fadeInUp {
|
||||||
outline: 0px !important;
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
}
|
}
|
||||||
.login_container {
|
to {
|
||||||
padding: 0 2rem;
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
.input-group-text {
|
|
||||||
background: #1B4F72!important;
|
|
||||||
color: white !important;
|
|
||||||
border: 0 !important;
|
|
||||||
border-radius: 0.25rem 0 0 0.25rem !important;
|
|
||||||
}
|
}
|
||||||
.input_user,
|
|
||||||
.input_pass:focus {
|
/* Login Card */
|
||||||
box-shadow: none !important;
|
.login-card {
|
||||||
outline: 0px !important;
|
background: #ffffff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login Header */
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 32px 20px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-bottom: 3px solid #e64838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-wrapper {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-image {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: drop-shadow(0 2px 8px rgba(1, 32, 104, 0.15));
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #012068;
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login Form */
|
||||||
|
.login-form {
|
||||||
|
padding: 24px 32px 28px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Group */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group:last-of-type {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #012068;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input Wrapper */
|
||||||
|
.input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 14px 11px 38px;
|
||||||
|
border: 1.5px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #012068;
|
||||||
|
box-shadow: 0 0 0 3px rgba(1, 32, 104, 0.1);
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input::placeholder {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 14px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus + .input-icon {
|
||||||
|
color: #012068;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-error {
|
||||||
|
border-color: #e64838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-error:focus {
|
||||||
|
border-color: #e64838;
|
||||||
|
box-shadow: 0 0 0 3px rgba(230, 72, 56, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error Message */
|
||||||
|
.error-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #e64838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message i {
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login Button */
|
||||||
|
.btn-login {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: linear-gradient(135deg, #e64838 0%, #d63527 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4px 12px rgba(230, 72, 56, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(230, 72, 56, 0.35);
|
||||||
|
background: linear-gradient(135deg, #d63527 0%, #c62e1f 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login i {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login Footer */
|
||||||
|
.login-footer {
|
||||||
|
padding: 20px 32px 24px;
|
||||||
|
text-align: center;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link {
|
||||||
|
color: #e64838;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-left: 4px;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link:hover {
|
||||||
|
color: #d63527;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner Animation */
|
||||||
|
.fa-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.login-container {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
padding: 28px 24px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-image {
|
||||||
|
width: 55px;
|
||||||
|
height: 55px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form,
|
||||||
|
.login-footer {
|
||||||
|
padding-left: 24px;
|
||||||
|
padding-right: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
padding: 10px 12px 10px 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
padding: 11px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.login-card {
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-image {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print Styles */
|
||||||
|
@media print {
|
||||||
|
.login-layout {
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-layout::before,
|
||||||
|
.login-layout::after {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
.custom-checkbox .custom-control-input:checked~.custom-control-label::before {
|
|
||||||
background-color: #1B4F72!important;
|
|
||||||
}
|
}
|
||||||
@ -1,46 +1,80 @@
|
|||||||
<div class="container" style="margin-top: 100px;">
|
<div class="login-layout">
|
||||||
<div class="d-flex justify-content-center h-50">
|
<div class="login-container">
|
||||||
<div class="user_card">
|
<!-- Login Card -->
|
||||||
<div class="d-flex justify-content-center">
|
<div class="login-card">
|
||||||
<div style="margin-top: 10px;margin-bottom:-90px;">
|
<!-- Logo/Header Section -->
|
||||||
<h3>User Management Portal</h3>
|
<div class="login-header">
|
||||||
|
<div class="logo-wrapper">
|
||||||
|
<img src="assets/images/cmc/logo.png" alt="Logo" class="logo-image">
|
||||||
|
</div>
|
||||||
|
<h1 class="login-title">Welcome Back</h1>
|
||||||
|
<p class="login-subtitle">Sign in to continue</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login Form -->
|
||||||
|
<form #loginForm="ngForm" (ngSubmit)="onLogin(loginForm.value)" class="login-form">
|
||||||
|
<!-- Username Field -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
class="form-input"
|
||||||
|
name="username"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
ngModel
|
||||||
|
#usernameInput="ngModel"
|
||||||
|
required
|
||||||
|
[class.input-error]="usernameInput.invalid && usernameInput.touched">
|
||||||
|
<i class="fas fa-user input-icon"></i>
|
||||||
|
</div>
|
||||||
|
<div class="error-message" *ngIf="usernameInput.invalid && usernameInput.touched">
|
||||||
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
|
Please enter a username
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-center form_container">
|
|
||||||
<form #loginForm="ngForm" (ngSubmit)="onLogin(loginForm.value)">
|
<!-- Password Field -->
|
||||||
<div class="input-group mb-3">
|
<div class="form-group">
|
||||||
<div class="input-group-append">
|
<label for="password">Password</label>
|
||||||
<span class="input-group-text"><i class="fas fa-user"></i></span>
|
<div class="input-wrapper">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
class="form-input"
|
||||||
|
name="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
ngModel
|
||||||
|
#passwordInput="ngModel"
|
||||||
|
required
|
||||||
|
[class.input-error]="passwordInput.invalid && passwordInput.touched">
|
||||||
|
<i class="fas fa-lock input-icon"></i>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" class="form-control" name="username" placeholder="Username"
|
<div class="error-message" *ngIf="passwordInput.invalid && passwordInput.touched">
|
||||||
ngModel #usernameInput="ngModel" required>
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
|
Please enter a password
|
||||||
</div>
|
</div>
|
||||||
<span class="help-block" style="color:red;" *ngIf="usernameInput.invalid && usernameInput.touched">
|
|
||||||
Please enter a username</span>
|
|
||||||
<div class="input-group mb-2">
|
|
||||||
<div class="input-group-append">
|
|
||||||
<span class="input-group-text"><i class="fas fa-key"></i></span>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="password" class="form-control" name="password" placeholder="Password"
|
|
||||||
ngModel #passwordInput="ngModel" required>
|
<!-- Login Button -->
|
||||||
</div>
|
<button
|
||||||
<span class="help-block" style="color:red;" *ngIf="passwordInput.invalid && passwordInput.touched"
|
type="submit"
|
||||||
>Please enter a password.</span>
|
class="btn-login"
|
||||||
<div class="d-flex justify-content-center mt-3 login_container">
|
[disabled]="loginForm.invalid || showLoading">
|
||||||
<button type="submit" [disabled]="loginForm.invalid || showLoading" name="button" class="btn login_btn">
|
<i class="fas fa-spinner fa-spin" *ngIf="showLoading"></i>
|
||||||
<i class="fas fa-spinner fa-spin" *ngIf="showLoading"></i>
|
<i class="fas fa-sign-in-alt" *ngIf="!showLoading"></i>
|
||||||
<span>{{showLoading ? 'Loading...' : 'Login'}}</span>
|
<span>{{ showLoading ? 'Signing in...' : 'Sign In' }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
<div class="mt-4">
|
|
||||||
<div class="d-flex justify-content-center links">
|
|
||||||
Don't have an account? <a routerLink="/register" class="ml-2" style="color: #2C3E50;">Sign Up</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Register Link -->
|
||||||
|
<!-- <div class="login-footer">
|
||||||
|
<p class="footer-text">
|
||||||
|
Don't have an account?
|
||||||
|
<a routerLink="/register" class="footer-link">Sign Up</a>
|
||||||
|
</p>
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,297 @@
|
|||||||
|
/* Management Layout */
|
||||||
|
.management-layout {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
width: 260px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-content {
|
||||||
|
margin-left: 260px;
|
||||||
|
flex: 1;
|
||||||
|
width: calc(100% - 260px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header Section */
|
||||||
|
.management-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 32px 32px 24px 32px;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-welcome {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar-small {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation Tabs */
|
||||||
|
.management-nav {
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
padding: 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab:hover {
|
||||||
|
color: #3b82f6;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab.active {
|
||||||
|
color: #3b82f6;
|
||||||
|
border-bottom-color: #3b82f6;
|
||||||
|
background: linear-gradient(to bottom, rgba(59, 130, 246, 0.05) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab i {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Management Body */
|
||||||
|
.management-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 32px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.management-content {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-nav {
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-body {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.management-header {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-nav {
|
||||||
|
padding: 0 20px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab {
|
||||||
|
padding: 14px 20px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-welcome {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.management-header {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-nav {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab i {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-welcome {
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-text {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar-small {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print Styles */
|
||||||
|
@media print {
|
||||||
|
.sidebar,
|
||||||
|
.management-nav,
|
||||||
|
.header-right {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-content {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-header {
|
||||||
|
border-bottom: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-body > * {
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user