feat(admin): 홈페이지 관리자 시스템 구현
- Spring Security + JWT 인증 (8시간 토큰) - AdminUser / Recruit 엔터티 추가 - AdminController: 로그인, 대시보드, 뉴스/문의/채용 CRUD - React 어드민 SPA: /admin/* 라우트 (Header/Footer 없음) - 로그인, 대시보드, 뉴스 관리, 문의 관리, 채용공고 관리, 설정 - Jenkinsfile: 서버 환경 맞춤 CI/CD 파이프라인 - .gitignore 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
abd4dde1a8
commit
6e02e7efe0
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
# Backend
|
||||
backend/target/
|
||||
backend/data/
|
||||
backend/*.db
|
||||
|
||||
# Frontend
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
|
||||
# Secrets / OS
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
97
Jenkinsfile
vendored
Normal file
97
Jenkinsfile
vendored
Normal file
@ -0,0 +1,97 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
environment {
|
||||
DEPLOY_DIR = '/var/www/zioinfo'
|
||||
APP_DIR = '/opt/zioinfo/app'
|
||||
JAVA_HOME = '/usr/lib/jvm/java-21-openjdk-amd64'
|
||||
MVN = '/usr/bin/mvn'
|
||||
NODE_HOME = '/usr/bin'
|
||||
}
|
||||
|
||||
options {
|
||||
buildDiscarder(logRotator(numToKeepStr: '5'))
|
||||
timeout(time: 20, unit: 'MINUTES')
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
echo "브랜치: ${env.GIT_BRANCH ?: 'main'} | 커밋: ${env.GIT_COMMIT?.take(7) ?: '-'}"
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
|
||||
stage('Frontend Build') {
|
||||
steps {
|
||||
dir('frontend') {
|
||||
sh '''
|
||||
echo "=== [1/3] React 빌드 ==="
|
||||
npm ci --legacy-peer-deps --prefer-offline 2>/dev/null || npm install --legacy-peer-deps
|
||||
npm run build
|
||||
echo "빌드 결과: $(ls ../backend/src/main/resources/static/assets/ | wc -l) 파일"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Backend Build') {
|
||||
steps {
|
||||
dir('backend') {
|
||||
sh '''
|
||||
echo "=== [2/3] Spring Boot 빌드 ==="
|
||||
${MVN} clean package -DskipTests -q
|
||||
JAR=$(find target -name "*.jar" ! -name "*sources*" | head -1)
|
||||
echo "JAR: $JAR ($(du -sh $JAR | cut -f1))"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Deploy') {
|
||||
steps {
|
||||
sh '''
|
||||
echo "=== [3/3] 배포 ==="
|
||||
JAR=$(find backend/target -name "*.jar" ! -name "*sources*" | head -1)
|
||||
|
||||
# 앱 디렉터리 확인
|
||||
mkdir -p ${APP_DIR} ${DEPLOY_DIR}
|
||||
|
||||
# JAR 배포
|
||||
cp "$JAR" ${APP_DIR}/app.jar
|
||||
|
||||
# React 정적 파일 배포
|
||||
cp -r backend/src/main/resources/static/. ${DEPLOY_DIR}/
|
||||
|
||||
# Spring Boot 서비스 재시작
|
||||
systemctl restart zioinfo || true
|
||||
sleep 4
|
||||
|
||||
# 헬스체크
|
||||
for i in 1 2 3 4 5; do
|
||||
HTTP=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/company 2>/dev/null)
|
||||
if [ "$HTTP" = "200" ]; then
|
||||
echo "배포 성공 (Spring Boot HTTP $HTTP)"
|
||||
exit 0
|
||||
fi
|
||||
echo "헬스체크 ${i}/5 대기중 (HTTP: $HTTP)..."
|
||||
sleep 3
|
||||
done
|
||||
echo "경고: Spring Boot 응답 없음 — 서비스 상태 확인 필요"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
success {
|
||||
echo "✅ 배포 완료: ${currentBuild.displayName} (${currentBuild.durationString})"
|
||||
}
|
||||
failure {
|
||||
echo "❌ 배포 실패: ${currentBuild.displayName} — 로그 확인 필요"
|
||||
}
|
||||
always {
|
||||
cleanWs(cleanWhenNotBuilt: false, cleanWhenSuccess: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -67,6 +67,31 @@
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Security -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JWT -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.12.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.12.3</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.12.3</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
|
||||
@ -1,53 +1,64 @@
|
||||
package kr.co.zioinfo.web.config;
|
||||
|
||||
import kr.co.zioinfo.web.model.News;
|
||||
import kr.co.zioinfo.web.repository.NewsRepository;
|
||||
import kr.co.zioinfo.web.model.*;
|
||||
import kr.co.zioinfo.web.repository.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DataInitializer implements CommandLineRunner {
|
||||
|
||||
private final NewsRepository newsRepo;
|
||||
private final AdminUserRepository adminUserRepo;
|
||||
private final RecruitRepository recruitRepo;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
initAdmin();
|
||||
initNews();
|
||||
initRecruits();
|
||||
}
|
||||
|
||||
private void initAdmin() {
|
||||
if (adminUserRepo.existsByUsername("admin")) return;
|
||||
adminUserRepo.save(AdminUser.builder()
|
||||
.username("admin")
|
||||
.password(passwordEncoder.encode("Admin@2026!"))
|
||||
.displayName("관리자")
|
||||
.email("admin@zioinfo.co.kr")
|
||||
.enabled(true)
|
||||
.build());
|
||||
}
|
||||
|
||||
private void initNews() {
|
||||
if (newsRepo.count() > 0) return;
|
||||
|
||||
newsRepo.save(News.builder()
|
||||
.title("GUARDiA ITSM 2.0 정식 출시 — AI 기반 인프라 자율 운영 플랫폼")
|
||||
.category("보도자료")
|
||||
.summary("(주)지오정보기술이 공공기관 레거시 인프라 자동화를 위한 GUARDiA ITSM 2.0을 정식 출시했습니다.")
|
||||
.content("GUARDiA ITSM 2.0은 메신저 한 줄 명령으로 에이전트리스 SSH/SFTP 배포·운영을 자동화하는 온프레미스 플랫폼입니다. " +
|
||||
"1,000개 이상 관공서를 대상으로 하며 외부 클라우드 의존 없이 완전 폐쇄망 환경에서 동작합니다.")
|
||||
.content("GUARDiA ITSM 2.0은 메신저 한 줄 명령으로 에이전트리스 SSH/SFTP 배포·운영을 자동화하는 온프레미스 플랫폼입니다.")
|
||||
.visible(true).viewCount(128).build());
|
||||
|
||||
newsRepo.save(News.builder()
|
||||
.title("2026 공공기관 AI 인프라 혁신 박람회 참가")
|
||||
.category("공지사항")
|
||||
.summary("지오정보기술이 2026 공공기관 AI 인프라 혁신 박람회에 참가하여 GUARDiA 솔루션을 선보입니다.")
|
||||
.content("박람회 기간: 2026년 6월 15일~17일 / 장소: 코엑스 A홀 / 부스: A-215\n" +
|
||||
"GUARDiA ITSM 라이브 데모 및 도입 상담을 진행합니다.")
|
||||
.content("박람회 기간: 2026년 6월 15일~17일 / 장소: 코엑스 A홀 / 부스: A-215")
|
||||
.visible(true).viewCount(87).build());
|
||||
|
||||
newsRepo.save(News.builder()
|
||||
.title("행정안전부 공공SW 우수제품 선정")
|
||||
.category("보도자료")
|
||||
.summary("GUARDiA ITSM이 행정안전부 2026년 공공SW 우수제품으로 선정되었습니다.")
|
||||
.content("행정안전부는 공공기관 정보화 사업에 적합한 소프트웨어를 심사하여 우수제품을 선정합니다. " +
|
||||
"GUARDiA는 보안성, 안정성, 공공 적합성에서 높은 평가를 받았습니다.")
|
||||
.content("행정안전부는 공공기관 정보화 사업에 적합한 소프트웨어를 심사하여 우수제품을 선정합니다.")
|
||||
.visible(true).viewCount(214).build());
|
||||
|
||||
newsRepo.save(News.builder()
|
||||
.title("Spring 2026 개발자 컨퍼런스 기술 발표")
|
||||
.category("이벤트")
|
||||
.summary("지오정보기술 개발팀이 AI 에이전트 기반 ChatOps 구현 사례를 발표합니다.")
|
||||
.content("발표 주제: '1000개 관공서 인프라를 메신저 봇 하나로 관리하기'\n" +
|
||||
"일시: 2026년 5월 20일 / 장소: 서울 COEX 컨퍼런스룸")
|
||||
.visible(true).viewCount(63).build());
|
||||
|
||||
newsRepo.save(News.builder()
|
||||
.title("특허 등록 — 에이전트리스 레거시 인프라 자동화 방법")
|
||||
.category("공지사항")
|
||||
@ -55,4 +66,32 @@ public class DataInitializer implements CommandLineRunner {
|
||||
.content("특허명: 에이전트리스 레거시 인프라 자동화 시스템 및 방법\n등록번호: 10-2026-XXXXXXX")
|
||||
.visible(true).viewCount(41).build());
|
||||
}
|
||||
|
||||
private void initRecruits() {
|
||||
if (recruitRepo.count() > 0) return;
|
||||
|
||||
recruitRepo.save(Recruit.builder()
|
||||
.title("백엔드 개발자 (Java/Spring Boot)")
|
||||
.department("개발팀").jobType("정규직")
|
||||
.description("- GUARDiA ITSM 백엔드 API 개발\n- 성능 최적화 및 코드 리뷰\n- 공공기관 SI 프로젝트 참여")
|
||||
.requirements("- Spring Boot 실무 경력 3년 이상\n- JPA/Hibernate 경험\n- RESTful API 설계 능력")
|
||||
.preferred("- 공공기관 프로젝트 경험\n- MSA 아키텍처 이해\n- 보안 코딩 경험")
|
||||
.deadline(LocalDate.of(2026, 6, 30)).headcount(2).active(true).build());
|
||||
|
||||
recruitRepo.save(Recruit.builder()
|
||||
.title("프론트엔드 개발자 (React)")
|
||||
.department("개발팀").jobType("정규직")
|
||||
.description("- React SPA 개발 및 유지보수\n- 홈페이지 및 관리자 페이지 개발\n- UI/UX 개선")
|
||||
.requirements("- React 실무 경력 2년 이상\n- JavaScript/TypeScript 능숙\n- CSS 레이아웃 및 반응형 웹 경험")
|
||||
.preferred("- 공공기관 프로젝트 경험\n- 데이터 시각화 경험 (Chart.js 등)")
|
||||
.deadline(LocalDate.of(2026, 6, 30)).headcount(1).active(true).build());
|
||||
|
||||
recruitRepo.save(Recruit.builder()
|
||||
.title("인프라 엔지니어 (Linux/DevOps)")
|
||||
.department("인프라팀").jobType("정규직")
|
||||
.description("- 공공기관 서버 인프라 구축 및 운영\n- CI/CD 파이프라인 관리\n- 보안 취약점 점검")
|
||||
.requirements("- Linux 서버 운영 경력 3년 이상\n- Ansible/Terraform 경험\n- 네트워크 기초 지식")
|
||||
.preferred("- 공공기관 정보보호 인증 자격증 (정보처리기사 등)\n- Kubernetes 경험")
|
||||
.deadline(LocalDate.of(2026, 7, 31)).headcount(1).active(true).build());
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,61 @@
|
||||
package kr.co.zioinfo.web.config;
|
||||
|
||||
import kr.co.zioinfo.web.security.JwtAuthFilter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthFilter jwtAuthFilter;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(HttpMethod.POST, "/api/admin/login").permitAll()
|
||||
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||
.requestMatchers(HttpMethod.GET, "/api/**").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/inquiry").permitAll()
|
||||
.requestMatchers("/", "/**").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
cfg.setAllowedOriginPatterns(List.of("*"));
|
||||
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
cfg.setAllowedHeaders(List.of("*"));
|
||||
cfg.setAllowCredentials(true);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", cfg);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,182 @@
|
||||
package kr.co.zioinfo.web.controller;
|
||||
|
||||
import kr.co.zioinfo.web.model.*;
|
||||
import kr.co.zioinfo.web.repository.*;
|
||||
import kr.co.zioinfo.web.security.JwtUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.*;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminController {
|
||||
|
||||
private final AdminUserRepository adminUserRepo;
|
||||
private final NewsRepository newsRepo;
|
||||
private final InquiryRepository inquiryRepo;
|
||||
private final RecruitRepository recruitRepo;
|
||||
private final JwtUtil jwtUtil;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
// ── 로그인 ────────────────────────────────────────────────
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(@RequestBody Map<String, String> body) {
|
||||
String username = body.get("username");
|
||||
String password = body.get("password");
|
||||
|
||||
return adminUserRepo.findByUsername(username)
|
||||
.filter(u -> u.isEnabled() && passwordEncoder.matches(password, u.getPassword()))
|
||||
.map(u -> ResponseEntity.ok(Map.of(
|
||||
"token", jwtUtil.generate(u.getUsername()),
|
||||
"username", u.getUsername(),
|
||||
"displayName", Optional.ofNullable(u.getDisplayName()).orElse(u.getUsername()))))
|
||||
.orElse(ResponseEntity.status(401).body(Map.of("message", "아이디 또는 비밀번호가 올바르지 않습니다.")));
|
||||
}
|
||||
|
||||
// ── 대시보드 통계 ────────────────────────────────────────
|
||||
@GetMapping("/dashboard")
|
||||
public ResponseEntity<Map<String, Object>> dashboard() {
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
stats.put("totalNews", newsRepo.count());
|
||||
stats.put("visibleNews", newsRepo.countByVisibleTrue());
|
||||
stats.put("totalInquiries", inquiryRepo.count());
|
||||
stats.put("pendingInquiries", inquiryRepo.countByStatus("PENDING"));
|
||||
stats.put("totalRecruits", recruitRepo.count());
|
||||
stats.put("activeRecruits", recruitRepo.countByActiveTrue());
|
||||
stats.put("recentInquiries", inquiryRepo.findTop5ByOrderByCreatedAtDesc());
|
||||
stats.put("recentNews", newsRepo.findTop5ByOrderByCreatedAtDesc());
|
||||
return ResponseEntity.ok(stats);
|
||||
}
|
||||
|
||||
// ── 뉴스 관리 ────────────────────────────────────────────
|
||||
@GetMapping("/news")
|
||||
public ResponseEntity<Page<News>> adminNews(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
return ResponseEntity.ok(newsRepo.findAll(
|
||||
PageRequest.of(page, size, Sort.by("createdAt").descending())));
|
||||
}
|
||||
|
||||
@PostMapping("/news")
|
||||
public ResponseEntity<News> createNews(@RequestBody News news) {
|
||||
news.setId(null);
|
||||
news.setCreatedAt(null);
|
||||
return ResponseEntity.ok(newsRepo.save(news));
|
||||
}
|
||||
|
||||
@PutMapping("/news/{id}")
|
||||
public ResponseEntity<?> updateNews(@PathVariable Long id, @RequestBody News body) {
|
||||
return newsRepo.findById(id).map(n -> {
|
||||
n.setTitle(body.getTitle());
|
||||
n.setCategory(body.getCategory());
|
||||
n.setContent(body.getContent());
|
||||
n.setSummary(body.getSummary());
|
||||
n.setThumbnailUrl(body.getThumbnailUrl());
|
||||
n.setVisible(body.isVisible());
|
||||
return ResponseEntity.ok(newsRepo.save(n));
|
||||
}).orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@DeleteMapping("/news/{id}")
|
||||
public ResponseEntity<Void> deleteNews(@PathVariable Long id) {
|
||||
if (!newsRepo.existsById(id)) return ResponseEntity.notFound().build();
|
||||
newsRepo.deleteById(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PatchMapping("/news/{id}/visibility")
|
||||
public ResponseEntity<?> toggleVisibility(@PathVariable Long id) {
|
||||
return newsRepo.findById(id).map(n -> {
|
||||
n.setVisible(!n.isVisible());
|
||||
return ResponseEntity.ok(newsRepo.save(n));
|
||||
}).orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
// ── 문의 관리 ────────────────────────────────────────────
|
||||
@GetMapping("/inquiries")
|
||||
public ResponseEntity<Page<Inquiry>> adminInquiries(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(required = false) String status) {
|
||||
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
|
||||
Page<Inquiry> result = (status != null && !status.isBlank())
|
||||
? inquiryRepo.findByStatus(status, pageable)
|
||||
: inquiryRepo.findAll(pageable);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@GetMapping("/inquiries/{id}")
|
||||
public ResponseEntity<Inquiry> getInquiry(@PathVariable Long id) {
|
||||
return inquiryRepo.findById(id)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PatchMapping("/inquiries/{id}/status")
|
||||
public ResponseEntity<?> updateInquiryStatus(
|
||||
@PathVariable Long id, @RequestBody Map<String, String> body) {
|
||||
return inquiryRepo.findById(id).map(i -> {
|
||||
i.setStatus(body.getOrDefault("status", i.getStatus()));
|
||||
return ResponseEntity.ok(inquiryRepo.save(i));
|
||||
}).orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
// ── 채용공고 관리 ────────────────────────────────────────
|
||||
@GetMapping("/recruits")
|
||||
public ResponseEntity<Page<Recruit>> adminRecruits(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
return ResponseEntity.ok(recruitRepo.findAllByOrderByCreatedAtDesc(
|
||||
PageRequest.of(page, size)));
|
||||
}
|
||||
|
||||
@PostMapping("/recruits")
|
||||
public ResponseEntity<Recruit> createRecruit(@RequestBody Recruit recruit) {
|
||||
recruit.setId(null);
|
||||
recruit.setCreatedAt(null);
|
||||
return ResponseEntity.ok(recruitRepo.save(recruit));
|
||||
}
|
||||
|
||||
@PutMapping("/recruits/{id}")
|
||||
public ResponseEntity<?> updateRecruit(@PathVariable Long id, @RequestBody Recruit body) {
|
||||
return recruitRepo.findById(id).map(r -> {
|
||||
r.setTitle(body.getTitle());
|
||||
r.setDepartment(body.getDepartment());
|
||||
r.setJobType(body.getJobType());
|
||||
r.setDescription(body.getDescription());
|
||||
r.setRequirements(body.getRequirements());
|
||||
r.setPreferred(body.getPreferred());
|
||||
r.setDeadline(body.getDeadline());
|
||||
r.setHeadcount(body.getHeadcount());
|
||||
r.setActive(body.isActive());
|
||||
return ResponseEntity.ok(recruitRepo.save(r));
|
||||
}).orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@DeleteMapping("/recruits/{id}")
|
||||
public ResponseEntity<Void> deleteRecruit(@PathVariable Long id) {
|
||||
if (!recruitRepo.existsById(id)) return ResponseEntity.notFound().build();
|
||||
recruitRepo.deleteById(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// ── 비밀번호 변경 ────────────────────────────────────────
|
||||
@PutMapping("/password")
|
||||
public ResponseEntity<?> changePassword(
|
||||
@RequestBody Map<String, String> body,
|
||||
jakarta.servlet.http.HttpServletRequest req) {
|
||||
String authHeader = req.getHeader("Authorization");
|
||||
String username = jwtUtil.extractUsername(authHeader.substring(7));
|
||||
return adminUserRepo.findByUsername(username).map(u -> {
|
||||
if (!passwordEncoder.matches(body.get("currentPassword"), u.getPassword()))
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "현재 비밀번호가 올바르지 않습니다."));
|
||||
u.setPassword(passwordEncoder.encode(body.get("newPassword")));
|
||||
adminUserRepo.save(u);
|
||||
return ResponseEntity.ok(Map.of("message", "비밀번호가 변경되었습니다."));
|
||||
}).orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ package kr.co.zioinfo.web.controller;
|
||||
|
||||
import kr.co.zioinfo.web.model.Inquiry;
|
||||
import kr.co.zioinfo.web.model.News;
|
||||
import kr.co.zioinfo.web.repository.RecruitRepository;
|
||||
import kr.co.zioinfo.web.service.InquiryService;
|
||||
import kr.co.zioinfo.web.service.NewsService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@ -14,11 +15,11 @@ import java.util.*;
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
@RequiredArgsConstructor
|
||||
@CrossOrigin(origins = {"http://localhost:3000", "http://localhost:5173"})
|
||||
public class ApiController {
|
||||
|
||||
private final NewsService newsService;
|
||||
private final InquiryService inquiryService;
|
||||
private final RecruitRepository recruitRepo;
|
||||
|
||||
// ── 회사 정보 ────────────────────────────────────────────────
|
||||
@GetMapping("/company")
|
||||
@ -136,6 +137,12 @@ public class ApiController {
|
||||
));
|
||||
}
|
||||
|
||||
// ── 채용공고 (공개) ───────────────────────────────────────────
|
||||
@GetMapping("/recruits")
|
||||
public ResponseEntity<?> getRecruits() {
|
||||
return ResponseEntity.ok(recruitRepo.findByActiveTrueOrderByCreatedAtDesc());
|
||||
}
|
||||
|
||||
// ── 메뉴 구조 ────────────────────────────────────────────────
|
||||
@GetMapping("/menu")
|
||||
public ResponseEntity<List<Map<String, Object>>> getMenu() {
|
||||
|
||||
32
backend/src/main/java/kr/co/zioinfo/web/model/AdminUser.java
Normal file
32
backend/src/main/java/kr/co/zioinfo/web/model/AdminUser.java
Normal file
@ -0,0 +1,32 @@
|
||||
package kr.co.zioinfo.web.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity @Table(name = "admin_user")
|
||||
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public class AdminUser {
|
||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 50)
|
||||
private String username;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String password; // BCrypt encoded
|
||||
|
||||
@Column(length = 100)
|
||||
private String displayName;
|
||||
|
||||
@Column(length = 100)
|
||||
private String email;
|
||||
|
||||
private boolean enabled = true;
|
||||
|
||||
@CreatedDate
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
41
backend/src/main/java/kr/co/zioinfo/web/model/Recruit.java
Normal file
41
backend/src/main/java/kr/co/zioinfo/web/model/Recruit.java
Normal file
@ -0,0 +1,41 @@
|
||||
package kr.co.zioinfo.web.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity @Table(name = "recruit")
|
||||
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public class Recruit {
|
||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 200)
|
||||
private String title;
|
||||
|
||||
@Column(length = 50)
|
||||
private String department; // 개발팀, 영업팀, 기획팀 등
|
||||
|
||||
@Column(length = 20)
|
||||
private String jobType; // 정규직, 계약직, 인턴
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String description; // 담당업무
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String requirements; // 지원자격
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String preferred; // 우대사항
|
||||
|
||||
private LocalDate deadline;
|
||||
private int headcount = 1;
|
||||
private boolean active = true;
|
||||
|
||||
@CreatedDate
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package kr.co.zioinfo.web.repository;
|
||||
|
||||
import kr.co.zioinfo.web.model.AdminUser;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AdminUserRepository extends JpaRepository<AdminUser, Long> {
|
||||
Optional<AdminUser> findByUsername(String username);
|
||||
boolean existsByUsername(String username);
|
||||
}
|
||||
@ -1,4 +1,12 @@
|
||||
package kr.co.zioinfo.web.repository;
|
||||
|
||||
import kr.co.zioinfo.web.model.Inquiry;
|
||||
import org.springframework.data.domain.*;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
public interface InquiryRepository extends JpaRepository<Inquiry, Long> {}
|
||||
import java.util.List;
|
||||
|
||||
public interface InquiryRepository extends JpaRepository<Inquiry, Long> {
|
||||
Page<Inquiry> findByStatus(String status, Pageable p);
|
||||
long countByStatus(String status);
|
||||
List<Inquiry> findTop5ByOrderByCreatedAtDesc();
|
||||
}
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
package kr.co.zioinfo.web.repository;
|
||||
|
||||
import kr.co.zioinfo.web.model.News;
|
||||
import org.springframework.data.domain.*;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import java.util.List;
|
||||
|
||||
public interface NewsRepository extends JpaRepository<News, Long> {
|
||||
Page<News> findByVisibleTrue(Pageable p);
|
||||
Page<News> findByCategoryAndVisibleTrue(String category, Pageable p);
|
||||
long countByVisibleTrue();
|
||||
List<News> findTop5ByOrderByCreatedAtDesc();
|
||||
}
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
package kr.co.zioinfo.web.repository;
|
||||
|
||||
import kr.co.zioinfo.web.model.Recruit;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import java.util.List;
|
||||
|
||||
public interface RecruitRepository extends JpaRepository<Recruit, Long> {
|
||||
List<Recruit> findByActiveTrueOrderByCreatedAtDesc();
|
||||
Page<Recruit> findAllByOrderByCreatedAtDesc(Pageable pageable);
|
||||
long countByActiveTrue();
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
package kr.co.zioinfo.web.security;
|
||||
|
||||
import jakarta.servlet.*;
|
||||
import jakarta.servlet.http.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
String header = req.getHeader("Authorization");
|
||||
if (header != null && header.startsWith("Bearer ")) {
|
||||
String token = header.substring(7);
|
||||
if (jwtUtil.isValid(token)) {
|
||||
String username = jwtUtil.extractUsername(token);
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
username, null, List.of(new SimpleGrantedAuthority("ROLE_ADMIN")));
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
}
|
||||
}
|
||||
chain.doFilter(req, res);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
package kr.co.zioinfo.web.security;
|
||||
|
||||
import io.jsonwebtoken.*;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
|
||||
@Component
|
||||
public class JwtUtil {
|
||||
|
||||
private final SecretKey key;
|
||||
private final long expirationMs;
|
||||
|
||||
public JwtUtil(
|
||||
@Value("${zioinfo.jwt.secret:zioinfo-admin-secret-key-must-be-at-least-32-chars}") String secret,
|
||||
@Value("${zioinfo.jwt.expiration-ms:28800000}") long expirationMs) {
|
||||
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||
this.expirationMs = expirationMs;
|
||||
}
|
||||
|
||||
public String generate(String username) {
|
||||
return Jwts.builder()
|
||||
.subject(username)
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + expirationMs))
|
||||
.signWith(key)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public String extractUsername(String token) {
|
||||
return parse(token).getPayload().getSubject();
|
||||
}
|
||||
|
||||
public boolean isValid(String token) {
|
||||
try {
|
||||
parse(token);
|
||||
return true;
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Jws<Claims> parse(String token) {
|
||||
return Jwts.parser().verifyWith(key).build().parseSignedClaims(token);
|
||||
}
|
||||
}
|
||||
@ -32,6 +32,9 @@ spring:
|
||||
mail.smtp.starttls.enable: true
|
||||
|
||||
zioinfo:
|
||||
jwt:
|
||||
secret: zioinfo-admin-jwt-secret-key-must-be-at-least-32-chars-long
|
||||
expiration-ms: 28800000 # 8시간
|
||||
company:
|
||||
name: (주)지오정보기술
|
||||
email: info@zioinfo.co.kr
|
||||
|
||||
@ -1,42 +1,84 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import { Routes, Route, useLocation } from 'react-router-dom';
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import Header from './components/layout/Header';
|
||||
import Footer from './components/layout/Footer';
|
||||
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const GuardiaDetail = lazy(() => import('./pages/GuardiaDetail'));
|
||||
const Company = lazy(() => import('./pages/Company'));
|
||||
const Contact = lazy(() => import('./pages/Contact'));
|
||||
const NewsPage = lazy(() => import('./pages/NewsPage'));
|
||||
const Recruit = lazy(() => import('./pages/Recruit'));
|
||||
const NotFound = lazy(() => import('./pages/NotFound'));
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const GuardiaDetail = lazy(() => import('./pages/GuardiaDetail'));
|
||||
const SolutionPage = lazy(() => import('./pages/SolutionPage'));
|
||||
const Company = lazy(() => import('./pages/Company'));
|
||||
const Business = lazy(() => import('./pages/Business'));
|
||||
const Contact = lazy(() => import('./pages/Contact'));
|
||||
const Support = lazy(() => import('./pages/Support'));
|
||||
const NewsPage = lazy(() => import('./pages/NewsPage'));
|
||||
const Recruit = lazy(() => import('./pages/Recruit'));
|
||||
const NotFound = lazy(() => import('./pages/NotFound'));
|
||||
|
||||
// Admin
|
||||
const AdminLogin = lazy(() => import('./pages/admin/AdminLogin'));
|
||||
const AdminLayout = lazy(() => import('./pages/admin/AdminLayout'));
|
||||
const AdminDashboard = lazy(() => import('./pages/admin/AdminDashboard'));
|
||||
const AdminNews = lazy(() => import('./pages/admin/AdminNews'));
|
||||
const AdminInquiry = lazy(() => import('./pages/admin/AdminInquiry'));
|
||||
const AdminRecruit = lazy(() => import('./pages/admin/AdminRecruit'));
|
||||
const AdminSettings = lazy(() => import('./pages/admin/AdminSettings'));
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'center',
|
||||
height:'60vh',color:'var(--gray-400)',fontSize:'14px'}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '60vh', color: 'var(--gray-400)', fontSize: '14px' }}>
|
||||
로딩 중...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const location = useLocation();
|
||||
function PublicLayout({ children }) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/solution/guardia" element={<GuardiaDetail />} />
|
||||
<Route path="/company/*" element={<Company />} />
|
||||
<Route path="/support/contact" element={<Contact />} />
|
||||
<Route path="/news/*" element={<NewsPage />} />
|
||||
<Route path="/recruit/*" element={<Recruit />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<Suspense fallback={<Loading />}>{children}</Suspense>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const location = useLocation();
|
||||
const isAdmin = location.pathname.startsWith('/admin');
|
||||
|
||||
if (isAdmin) {
|
||||
return (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Routes>
|
||||
<Route path="/admin/login" element={<AdminLogin />} />
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<AdminDashboard />} />
|
||||
<Route path="news" element={<AdminNews />} />
|
||||
<Route path="inquiries" element={<AdminInquiry />} />
|
||||
<Route path="recruit" element={<AdminRecruit />} />
|
||||
<Route path="settings" element={<AdminSettings />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/admin/login" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/solution/guardia" element={<GuardiaDetail />} />
|
||||
<Route path="/solution/*" element={<SolutionPage />} />
|
||||
<Route path="/company/*" element={<Company />} />
|
||||
<Route path="/business/*" element={<Business />} />
|
||||
<Route path="/support/contact" element={<Contact />} />
|
||||
<Route path="/support/*" element={<Support />} />
|
||||
<Route path="/recruit/*" element={<Recruit />} />
|
||||
<Route path="/news/*" element={<NewsPage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
|
||||
93
frontend/src/pages/admin/AdminDashboard.jsx
Normal file
93
frontend/src/pages/admin/AdminDashboard.jsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const API = (path) => fetch(path, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('admin_token')}` },
|
||||
}).then(r => r.json());
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
API('/api/admin/dashboard')
|
||||
.then(setStats)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <p style={{ color: '#64748b', fontSize: 14 }}>로딩 중...</p>;
|
||||
if (!stats) return null;
|
||||
|
||||
const STAT_CARDS = [
|
||||
{ icon: '📰', label: '전체 뉴스', value: stats.totalNews, sub: `공개 ${stats.visibleNews}건`, color: 'blue' },
|
||||
{ icon: '📩', label: '전체 문의', value: stats.totalInquiries, sub: `미답변 ${stats.pendingInquiries}건`, color: stats.pendingInquiries > 0 ? 'red' : 'green' },
|
||||
{ icon: '👥', label: '채용공고', value: stats.totalRecruits, sub: `진행중 ${stats.activeRecruits}건`, color: 'green' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Stats */}
|
||||
<div className="admin-stats">
|
||||
{STAT_CARDS.map(s => (
|
||||
<div className="stat-card" key={s.label}>
|
||||
<div className={`stat-icon ${s.color}`}>{s.icon}</div>
|
||||
<div className="stat-info">
|
||||
<h4>{s.value}</h4>
|
||||
<p>{s.label}<br /><span style={{ fontSize: 11 }}>{s.sub}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{stats.pendingInquiries > 0 && (
|
||||
<div className="stat-card" style={{ borderLeft: '3px solid #ef4444' }}>
|
||||
<div className="stat-icon red">🔔</div>
|
||||
<div className="stat-info">
|
||||
<h4 style={{ color: '#ef4444' }}>{stats.pendingInquiries}</h4>
|
||||
<p>미답변 문의<br /><Link to="/admin/inquiries" style={{ fontSize: 11, color: '#ef4444' }}>바로가기 →</Link></p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent panels */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header">
|
||||
<h3>📰 최근 뉴스</h3>
|
||||
<Link to="/admin/news" className="btn btn-outline btn-sm">전체보기</Link>
|
||||
</div>
|
||||
<ul className="recent-list">
|
||||
{(stats.recentNews || []).map(n => (
|
||||
<li key={n.id}>
|
||||
<span className="rl-dot" />
|
||||
<span className="rl-title">{n.title}</span>
|
||||
<span className="rl-meta">{n.category}</span>
|
||||
</li>
|
||||
))}
|
||||
{!stats.recentNews?.length && (
|
||||
<li style={{ color: '#94a3b8', fontSize: 13 }}>등록된 뉴스가 없습니다.</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header">
|
||||
<h3>📩 최근 문의</h3>
|
||||
<Link to="/admin/inquiries" className="btn btn-outline btn-sm">전체보기</Link>
|
||||
</div>
|
||||
<ul className="recent-list">
|
||||
{(stats.recentInquiries || []).map(q => (
|
||||
<li key={q.id}>
|
||||
<span className="rl-dot" style={{ background: q.status === 'PENDING' ? '#ef4444' : '#22c55e' }} />
|
||||
<span className="rl-title">{q.subject}</span>
|
||||
<span className="rl-meta">{q.name}</span>
|
||||
</li>
|
||||
))}
|
||||
{!stats.recentInquiries?.length && (
|
||||
<li style={{ color: '#94a3b8', fontSize: 13 }}>접수된 문의가 없습니다.</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
152
frontend/src/pages/admin/AdminInquiry.jsx
Normal file
152
frontend/src/pages/admin/AdminInquiry.jsx
Normal file
@ -0,0 +1,152 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
const token = () => localStorage.getItem('admin_token');
|
||||
const authFetch = (url, opts = {}) =>
|
||||
fetch(url, { ...opts, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token()}`, ...opts.headers } });
|
||||
|
||||
const STATUS_LABEL = { PENDING: '미답변', ANSWERED: '답변완료', CLOSED: '종결' };
|
||||
const STATUS_BADGE = { PENDING: 'badge-red', ANSWERED: 'badge-green', CLOSED: 'badge-gray' };
|
||||
|
||||
export default function AdminInquiry() {
|
||||
const [page, setPage] = useState(0);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [data, setData] = useState({ content: [], totalPages: 0, totalElements: 0 });
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
const showToast = (msg, type = 'success') => { setToast({ msg, type }); setTimeout(() => setToast(null), 2500); };
|
||||
|
||||
const load = useCallback(() => {
|
||||
const q = filter ? `&status=${filter}` : '';
|
||||
authFetch(`/api/admin/inquiries?page=${page}&size=10${q}`)
|
||||
.then(r => r.json()).then(setData);
|
||||
}, [page, filter]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleStatus = async (id, status) => {
|
||||
const res = await authFetch(`/api/admin/inquiries/${id}/status`, {
|
||||
method: 'PATCH', body: JSON.stringify({ status }),
|
||||
});
|
||||
if (res.ok) {
|
||||
load();
|
||||
if (selected?.id === id) setSelected(p => ({ ...p, status }));
|
||||
showToast('상태가 변경되었습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = async (id) => {
|
||||
const res = await authFetch(`/api/admin/inquiries/${id}`);
|
||||
if (res.ok) setSelected(await res.json());
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{toast && <div className="admin-toast"><div className={`toast-item ${toast.type}`}>{toast.msg}</div></div>}
|
||||
|
||||
<div className="admin-card">
|
||||
<div className="admin-toolbar">
|
||||
<span style={{ fontSize: 13, color: '#64748b' }}>전체 {data.totalElements}건</span>
|
||||
<select className="admin-select" value={filter} onChange={e => { setFilter(e.target.value); setPage(0); }}>
|
||||
<option value="">전체 상태</option>
|
||||
<option value="PENDING">미답변</option>
|
||||
<option value="ANSWERED">답변완료</option>
|
||||
<option value="CLOSED">종결</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr><th>No</th><th>이름</th><th>제목</th><th>카테고리</th><th>상태</th><th>접수일</th><th>관리</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.content.map((q, i) => (
|
||||
<tr key={q.id} style={{ cursor: 'pointer' }}>
|
||||
<td style={{ color: '#94a3b8', fontSize: 12 }}>{data.totalElements - page * 10 - i}</td>
|
||||
<td>{q.name}</td>
|
||||
<td onClick={() => openDetail(q.id)}>
|
||||
<span className="truncate" style={{ display: 'block', color: '#4f6ef7', cursor: 'pointer' }}>{q.subject}</span>
|
||||
</td>
|
||||
<td><span className="badge badge-blue">{q.category || '기타'}</span></td>
|
||||
<td><span className={`badge ${STATUS_BADGE[q.status] || 'badge-gray'}`}>{STATUS_LABEL[q.status] || q.status}</span></td>
|
||||
<td style={{ fontSize: 12, color: '#94a3b8' }}>{q.createdAt?.slice(0, 10)}</td>
|
||||
<td>
|
||||
<div className="action-btns">
|
||||
{q.status === 'PENDING' && (
|
||||
<button className="btn btn-outline btn-sm" onClick={() => handleStatus(q.id, 'ANSWERED')}>답변완료</button>
|
||||
)}
|
||||
{q.status !== 'CLOSED' && (
|
||||
<button className="btn btn-outline btn-sm" onClick={() => handleStatus(q.id, 'CLOSED')}>종결</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!data.content.length && (
|
||||
<tr><td colSpan={7}><div className="empty-state"><div className="empty-icon">📩</div><p>접수된 문의가 없습니다.</p></div></td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{data.totalPages > 1 && (
|
||||
<div className="admin-pagination">
|
||||
<span className="admin-pagination-info">페이지 {page + 1} / {data.totalPages}</span>
|
||||
<div className="pagination-btns">
|
||||
<button disabled={page === 0} onClick={() => setPage(p => p - 1)}>‹</button>
|
||||
{Array.from({ length: Math.min(data.totalPages, 7) }, (_, i) => (
|
||||
<button key={i} className={page === i ? 'active' : ''} onClick={() => setPage(i)}>{i + 1}</button>
|
||||
))}
|
||||
<button disabled={page >= data.totalPages - 1} onClick={() => setPage(p => p + 1)}>›</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="modal-backdrop" onClick={e => e.target === e.currentTarget && setSelected(null)}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h3>문의 상세</h3>
|
||||
<button onClick={() => setSelected(null)}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
|
||||
{[['이름', selected.name], ['이메일', selected.email], ['연락처', selected.phone || '-'], ['유형', selected.category || '기타']].map(([l, v]) => (
|
||||
<div key={l}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#64748b', marginBottom: 3, textTransform: 'uppercase' }}>{l}</div>
|
||||
<div style={{ fontSize: 13.5 }}>{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#64748b', marginBottom: 4, textTransform: 'uppercase' }}>제목</div>
|
||||
<div style={{ fontWeight: 600, fontSize: 15 }}>{selected.subject}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#64748b', marginBottom: 6, textTransform: 'uppercase' }}>내용</div>
|
||||
<div style={{ background: '#f8fafc', borderRadius: 8, padding: '14px 16px', fontSize: 13.5, lineHeight: 1.7, whiteSpace: 'pre-wrap', border: '1px solid #e2e8f0' }}>
|
||||
{selected.content}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 12, color: '#64748b' }}>접수일: {selected.createdAt?.slice(0, 16)}</span>
|
||||
<span className={`badge ${STATUS_BADGE[selected.status] || 'badge-gray'}`}>{STATUS_LABEL[selected.status]}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
{selected.status === 'PENDING' && (
|
||||
<button className="btn btn-primary" onClick={() => handleStatus(selected.id, 'ANSWERED')}>답변완료 처리</button>
|
||||
)}
|
||||
{selected.status !== 'CLOSED' && (
|
||||
<button className="btn btn-outline" onClick={() => handleStatus(selected.id, 'CLOSED')}>종결</button>
|
||||
)}
|
||||
<button className="btn btn-outline" onClick={() => setSelected(null)}>닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
108
frontend/src/pages/admin/AdminLayout.jsx
Normal file
108
frontend/src/pages/admin/AdminLayout.jsx
Normal file
@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import './admin.css';
|
||||
|
||||
const NAV = [
|
||||
{ section: '메인' },
|
||||
{ path: '/admin/dashboard', icon: '📊', label: '대시보드' },
|
||||
{ section: '콘텐츠 관리' },
|
||||
{ path: '/admin/news', icon: '📰', label: '뉴스/공지사항' },
|
||||
{ path: '/admin/recruit', icon: '👥', label: '채용공고' },
|
||||
{ section: '고객 관리' },
|
||||
{ path: '/admin/inquiries', icon: '📩', label: '문의 관리', badgeKey: 'pendingInquiries' },
|
||||
{ section: '시스템' },
|
||||
{ path: '/admin/settings', icon: '⚙️', label: '설정' },
|
||||
];
|
||||
|
||||
export default function AdminLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [user, setUser] = useState(null);
|
||||
const [pageTitle, setPageTitle] = useState('대시보드');
|
||||
const [badges, setBadges] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('admin_token');
|
||||
if (!token) { navigate('/admin/login'); return; }
|
||||
const userData = JSON.parse(localStorage.getItem('admin_user') || '{}');
|
||||
setUser(userData);
|
||||
fetchBadges(token);
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = {
|
||||
'/admin/dashboard': '대시보드',
|
||||
'/admin/news': '뉴스/공지사항 관리',
|
||||
'/admin/inquiries': '문의 관리',
|
||||
'/admin/recruit': '채용공고 관리',
|
||||
'/admin/settings': '설정',
|
||||
};
|
||||
setPageTitle(map[location.pathname] || '관리자');
|
||||
}, [location.pathname]);
|
||||
|
||||
const fetchBadges = async (token) => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/dashboard', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
setBadges({ pendingInquiries: d.pendingInquiries || 0 });
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('admin_token');
|
||||
localStorage.removeItem('admin_user');
|
||||
navigate('/admin/login');
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="admin-wrap">
|
||||
<aside className="admin-sidebar">
|
||||
<div className="admin-sidebar-logo">
|
||||
<h2>ZioInfo Admin</h2>
|
||||
<span>(주)지오정보기술 관리자</span>
|
||||
</div>
|
||||
<nav className="admin-nav">
|
||||
{NAV.map((item, i) =>
|
||||
item.section ? (
|
||||
<div key={i} className="admin-nav-section">{item.section}</div>
|
||||
) : (
|
||||
<NavLink key={item.path} to={item.path}
|
||||
className={({ isActive }) => isActive ? 'active' : ''}>
|
||||
<span className="nav-icon">{item.icon}</span>
|
||||
{item.label}
|
||||
{item.badgeKey && badges[item.badgeKey] > 0 && (
|
||||
<span className="admin-nav-badge">{badges[item.badgeKey]}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
)}
|
||||
</nav>
|
||||
<div className="admin-sidebar-footer">
|
||||
<button onClick={logout}>🚪 로그아웃</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="admin-main">
|
||||
<div className="admin-topbar">
|
||||
<h1>{pageTitle}</h1>
|
||||
<div className="admin-topbar-right">
|
||||
<span className="admin-user-badge">👤 {user.displayName || user.username}</span>
|
||||
<a href="/" target="_blank" rel="noreferrer"
|
||||
style={{ fontSize: 12, color: '#64748b', textDecoration: 'none' }}>
|
||||
🌐 홈페이지 보기
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-content">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
frontend/src/pages/admin/AdminLogin.jsx
Normal file
66
frontend/src/pages/admin/AdminLogin.jsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import './admin.css';
|
||||
|
||||
export default function AdminLogin() {
|
||||
const [form, setForm] = useState({ username: '', password: '' });
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError(''); setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setError(data.message || '로그인 실패'); return; }
|
||||
localStorage.setItem('admin_token', data.token);
|
||||
localStorage.setItem('admin_user', JSON.stringify({ username: data.username, displayName: data.displayName }));
|
||||
navigate('/admin/dashboard');
|
||||
} catch {
|
||||
setError('서버 연결 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-login-page">
|
||||
<div className="admin-login-box">
|
||||
<div className="login-logo">
|
||||
<span className="login-badge">ADMIN</span>
|
||||
<h1>(주)지오정보기술</h1>
|
||||
<p>홈페이지 관리자 시스템</p>
|
||||
</div>
|
||||
{error && <div className="login-error">⚠ {error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="login-input-group">
|
||||
<label>아이디</label>
|
||||
<input
|
||||
type="text" placeholder="관리자 아이디" value={form.username} required
|
||||
onChange={e => setForm(p => ({ ...p, username: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="login-input-group">
|
||||
<label>비밀번호</label>
|
||||
<input
|
||||
type="password" placeholder="비밀번호" value={form.password} required
|
||||
onChange={e => setForm(p => ({ ...p, password: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="login-btn" disabled={loading}>
|
||||
{loading ? '로그인 중...' : '로그인'}
|
||||
</button>
|
||||
</form>
|
||||
<p style={{ textAlign: 'center', marginTop: 20, fontSize: 12, color: '#94a3b8' }}>
|
||||
홈페이지로 돌아가기: <a href="/" style={{ color: '#4f6ef7' }}>메인 페이지</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
frontend/src/pages/admin/AdminNews.jsx
Normal file
175
frontend/src/pages/admin/AdminNews.jsx
Normal file
@ -0,0 +1,175 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
const token = () => localStorage.getItem('admin_token');
|
||||
const authFetch = (url, opts = {}) =>
|
||||
fetch(url, { ...opts, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token()}`, ...opts.headers } });
|
||||
|
||||
const EMPTY = { title: '', category: '공지사항', summary: '', content: '', thumbnailUrl: '', visible: true };
|
||||
const CATS = ['공지사항', '보도자료', '이벤트'];
|
||||
|
||||
export default function AdminNews() {
|
||||
const [page, setPage] = useState(0);
|
||||
const [data, setData] = useState({ content: [], totalPages: 0, totalElements: 0 });
|
||||
const [modal, setModal] = useState(null); // null | 'create' | 'edit'
|
||||
const [form, setForm] = useState(EMPTY);
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
const showToast = (msg, type = 'success') => {
|
||||
setToast({ msg, type });
|
||||
setTimeout(() => setToast(null), 2500);
|
||||
};
|
||||
|
||||
const load = useCallback(() => {
|
||||
authFetch(`/api/admin/news?page=${page}&size=10`)
|
||||
.then(r => r.json()).then(setData);
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openCreate = () => { setForm(EMPTY); setEditId(null); setModal('form'); };
|
||||
const openEdit = (n) => { setForm({ ...n }); setEditId(n.id); setModal('form'); };
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
const url = editId ? `/api/admin/news/${editId}` : '/api/admin/news';
|
||||
const method = editId ? 'PUT' : 'POST';
|
||||
const res = await authFetch(url, { method, body: JSON.stringify(form) });
|
||||
setSaving(false);
|
||||
if (res.ok) { setModal(null); load(); showToast(editId ? '수정되었습니다.' : '등록되었습니다.'); }
|
||||
else showToast('저장 실패', 'error');
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
const res = await authFetch(`/api/admin/news/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) { load(); showToast('삭제되었습니다.'); }
|
||||
};
|
||||
|
||||
const toggleVisible = async (id) => {
|
||||
await authFetch(`/api/admin/news/${id}/visibility`, { method: 'PATCH' });
|
||||
load();
|
||||
};
|
||||
|
||||
const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
|
||||
|
||||
return (
|
||||
<>
|
||||
{toast && (
|
||||
<div className="admin-toast">
|
||||
<div className={`toast-item ${toast.type}`}>{toast.msg}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-card">
|
||||
<div className="admin-toolbar">
|
||||
<span style={{ fontSize: 13, color: '#64748b' }}>전체 {data.totalElements}건</span>
|
||||
<div className="admin-toolbar-right">
|
||||
<button className="btn btn-primary" onClick={openCreate}>+ 뉴스 등록</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>No</th><th>제목</th><th>카테고리</th><th>공개</th><th>조회수</th><th>등록일</th><th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.content.map((n, i) => (
|
||||
<tr key={n.id}>
|
||||
<td style={{ color: '#94a3b8', fontSize: 12 }}>{data.totalElements - page * 10 - i}</td>
|
||||
<td><span className="truncate" style={{ display: 'block' }}>{n.title}</span></td>
|
||||
<td><span className={`badge ${n.category === '보도자료' ? 'badge-blue' : n.category === '이벤트' ? 'badge-orange' : 'badge-gray'}`}>{n.category}</span></td>
|
||||
<td>
|
||||
<button onClick={() => toggleVisible(n.id)}
|
||||
className={`badge ${n.visible ? 'badge-green' : 'badge-red'}`}
|
||||
style={{ cursor: 'pointer', border: 'none' }}>
|
||||
{n.visible ? '공개' : '비공개'}
|
||||
</button>
|
||||
</td>
|
||||
<td>{n.viewCount}</td>
|
||||
<td style={{ fontSize: 12, color: '#94a3b8' }}>{n.createdAt?.slice(0, 10)}</td>
|
||||
<td>
|
||||
<div className="action-btns">
|
||||
<button className="btn btn-outline btn-sm" onClick={() => openEdit(n)}>수정</button>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(n.id)}>삭제</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!data.content.length && (
|
||||
<tr><td colSpan={7}><div className="empty-state"><div className="empty-icon">📰</div><p>등록된 뉴스가 없습니다.</p></div></td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{data.totalPages > 1 && (
|
||||
<div className="admin-pagination">
|
||||
<span className="admin-pagination-info">페이지 {page + 1} / {data.totalPages}</span>
|
||||
<div className="pagination-btns">
|
||||
<button disabled={page === 0} onClick={() => setPage(p => p - 1)}>‹</button>
|
||||
{Array.from({ length: data.totalPages }, (_, i) => (
|
||||
<button key={i} className={page === i ? 'active' : ''} onClick={() => setPage(i)}>{i + 1}</button>
|
||||
))}
|
||||
<button disabled={page >= data.totalPages - 1} onClick={() => setPage(p => p + 1)}>›</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modal === 'form' && (
|
||||
<div className="modal-backdrop" onClick={e => e.target === e.currentTarget && setModal(null)}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h3>{editId ? '뉴스 수정' : '뉴스 등록'}</h3>
|
||||
<button onClick={() => setModal(null)}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="form-group">
|
||||
<label>제목 *</label>
|
||||
<input className="form-control" value={form.title} onChange={e => set('title', e.target.value)} placeholder="뉴스 제목을 입력하세요" />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>카테고리</label>
|
||||
<select className="form-control" value={form.category} onChange={e => set('category', e.target.value)}>
|
||||
{CATS.map(c => <option key={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>공개 여부</label>
|
||||
<select className="form-control" value={form.visible} onChange={e => set('visible', e.target.value === 'true')}>
|
||||
<option value="true">공개</option>
|
||||
<option value="false">비공개</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>요약</label>
|
||||
<input className="form-control" value={form.summary || ''} onChange={e => set('summary', e.target.value)} placeholder="목록에 표시될 요약 문구" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>썸네일 URL</label>
|
||||
<input className="form-control" value={form.thumbnailUrl || ''} onChange={e => set('thumbnailUrl', e.target.value)} placeholder="https://..." />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>본문 내용 *</label>
|
||||
<textarea className="form-control" rows={8} value={form.content || ''} onChange={e => set('content', e.target.value)} placeholder="뉴스 본문 내용을 입력하세요" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-outline" onClick={() => setModal(null)}>취소</button>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !form.title}>
|
||||
{saving ? '저장 중...' : (editId ? '수정 완료' : '등록')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
169
frontend/src/pages/admin/AdminRecruit.jsx
Normal file
169
frontend/src/pages/admin/AdminRecruit.jsx
Normal file
@ -0,0 +1,169 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
const token = () => localStorage.getItem('admin_token');
|
||||
const authFetch = (url, opts = {}) =>
|
||||
fetch(url, { ...opts, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token()}`, ...opts.headers } });
|
||||
|
||||
const EMPTY = { title: '', department: '', jobType: '정규직', description: '', requirements: '', preferred: '', deadline: '', headcount: 1, active: true };
|
||||
const JOB_TYPES = ['정규직', '계약직', '인턴', '프리랜서'];
|
||||
|
||||
export default function AdminRecruit() {
|
||||
const [page, setPage] = useState(0);
|
||||
const [data, setData] = useState({ content: [], totalPages: 0, totalElements: 0 });
|
||||
const [modal, setModal] = useState(false);
|
||||
const [form, setForm] = useState(EMPTY);
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
const showToast = (msg, type = 'success') => { setToast({ msg, type }); setTimeout(() => setToast(null), 2500); };
|
||||
|
||||
const load = useCallback(() => {
|
||||
authFetch(`/api/admin/recruits?page=${page}&size=10`)
|
||||
.then(r => r.json()).then(setData);
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openCreate = () => { setForm(EMPTY); setEditId(null); setModal(true); };
|
||||
const openEdit = (r) => { setForm({ ...r, deadline: r.deadline || '' }); setEditId(r.id); setModal(true); };
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
const url = editId ? `/api/admin/recruits/${editId}` : '/api/admin/recruits';
|
||||
const res = await authFetch(url, { method: editId ? 'PUT' : 'POST', body: JSON.stringify(form) });
|
||||
setSaving(false);
|
||||
if (res.ok) { setModal(false); load(); showToast(editId ? '수정되었습니다.' : '등록되었습니다.'); }
|
||||
else showToast('저장 실패', 'error');
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
const res = await authFetch(`/api/admin/recruits/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) { load(); showToast('삭제되었습니다.'); }
|
||||
};
|
||||
|
||||
const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
|
||||
|
||||
return (
|
||||
<>
|
||||
{toast && <div className="admin-toast"><div className={`toast-item ${toast.type}`}>{toast.msg}</div></div>}
|
||||
|
||||
<div className="admin-card">
|
||||
<div className="admin-toolbar">
|
||||
<span style={{ fontSize: 13, color: '#64748b' }}>전체 {data.totalElements}건</span>
|
||||
<div className="admin-toolbar-right">
|
||||
<button className="btn btn-primary" onClick={openCreate}>+ 채용공고 등록</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr><th>No</th><th>공고명</th><th>부서</th><th>유형</th><th>모집인원</th><th>마감일</th><th>상태</th><th>관리</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.content.map((r, i) => (
|
||||
<tr key={r.id}>
|
||||
<td style={{ color: '#94a3b8', fontSize: 12 }}>{data.totalElements - page * 10 - i}</td>
|
||||
<td><span className="truncate" style={{ display: 'block' }}>{r.title}</span></td>
|
||||
<td>{r.department || '-'}</td>
|
||||
<td><span className={`badge ${r.jobType === '정규직' ? 'badge-blue' : r.jobType === '인턴' ? 'badge-orange' : 'badge-gray'}`}>{r.jobType}</span></td>
|
||||
<td>{r.headcount}명</td>
|
||||
<td style={{ fontSize: 12 }}>{r.deadline || '상시'}</td>
|
||||
<td><span className={`badge ${r.active ? 'badge-green' : 'badge-red'}`}>{r.active ? '진행중' : '마감'}</span></td>
|
||||
<td>
|
||||
<div className="action-btns">
|
||||
<button className="btn btn-outline btn-sm" onClick={() => openEdit(r)}>수정</button>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(r.id)}>삭제</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!data.content.length && (
|
||||
<tr><td colSpan={8}><div className="empty-state"><div className="empty-icon">👥</div><p>등록된 채용공고가 없습니다.</p></div></td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{data.totalPages > 1 && (
|
||||
<div className="admin-pagination">
|
||||
<span className="admin-pagination-info">페이지 {page + 1} / {data.totalPages}</span>
|
||||
<div className="pagination-btns">
|
||||
<button disabled={page === 0} onClick={() => setPage(p => p - 1)}>‹</button>
|
||||
{Array.from({ length: data.totalPages }, (_, i) => (
|
||||
<button key={i} className={page === i ? 'active' : ''} onClick={() => setPage(i)}>{i + 1}</button>
|
||||
))}
|
||||
<button disabled={page >= data.totalPages - 1} onClick={() => setPage(p => p + 1)}>›</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modal && (
|
||||
<div className="modal-backdrop" onClick={e => e.target === e.currentTarget && setModal(false)}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h3>{editId ? '채용공고 수정' : '채용공고 등록'}</h3>
|
||||
<button onClick={() => setModal(false)}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="form-group">
|
||||
<label>공고 제목 *</label>
|
||||
<input className="form-control" value={form.title} onChange={e => set('title', e.target.value)} placeholder="예: 백엔드 개발자 (Java/Spring)" />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>부서</label>
|
||||
<input className="form-control" value={form.department} onChange={e => set('department', e.target.value)} placeholder="개발팀, 영업팀 등" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>고용형태</label>
|
||||
<select className="form-control" value={form.jobType} onChange={e => set('jobType', e.target.value)}>
|
||||
{JOB_TYPES.map(t => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>모집 인원</label>
|
||||
<input type="number" min={1} className="form-control" value={form.headcount} onChange={e => set('headcount', parseInt(e.target.value))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>마감일</label>
|
||||
<input type="date" className="form-control" value={form.deadline} onChange={e => set('deadline', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>공고 상태</label>
|
||||
<select className="form-control" value={form.active} onChange={e => set('active', e.target.value === 'true')}>
|
||||
<option value="true">진행중</option>
|
||||
<option value="false">마감</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>담당 업무</label>
|
||||
<textarea className="form-control" rows={4} value={form.description} onChange={e => set('description', e.target.value)} placeholder="- 주요 담당 업무를 입력하세요" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>지원 자격</label>
|
||||
<textarea className="form-control" rows={4} value={form.requirements} onChange={e => set('requirements', e.target.value)} placeholder="- 필수 자격요건을 입력하세요" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>우대 사항</label>
|
||||
<textarea className="form-control" rows={3} value={form.preferred} onChange={e => set('preferred', e.target.value)} placeholder="- 우대 사항을 입력하세요" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-outline" onClick={() => setModal(false)}>취소</button>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !form.title}>
|
||||
{saving ? '저장 중...' : (editId ? '수정 완료' : '등록')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
91
frontend/src/pages/admin/AdminSettings.jsx
Normal file
91
frontend/src/pages/admin/AdminSettings.jsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const token = () => localStorage.getItem('admin_token');
|
||||
|
||||
export default function AdminSettings() {
|
||||
const navigate = useNavigate();
|
||||
const user = JSON.parse(localStorage.getItem('admin_user') || '{}');
|
||||
const [form, setForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
const [msg, setMsg] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleChange = async () => {
|
||||
if (form.newPassword !== form.confirmPassword) {
|
||||
setMsg({ text: '새 비밀번호가 일치하지 않습니다.', type: 'error' }); return;
|
||||
}
|
||||
if (form.newPassword.length < 8) {
|
||||
setMsg({ text: '비밀번호는 8자 이상이어야 합니다.', type: 'error' }); return;
|
||||
}
|
||||
setSaving(true);
|
||||
const res = await fetch('/api/admin/password', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token()}` },
|
||||
body: JSON.stringify({ currentPassword: form.currentPassword, newPassword: form.newPassword }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setSaving(false);
|
||||
if (res.ok) {
|
||||
setMsg({ text: '비밀번호가 변경되었습니다. 다시 로그인해주세요.', type: 'success' });
|
||||
setForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
setTimeout(() => {
|
||||
localStorage.removeItem('admin_token');
|
||||
navigate('/admin/login');
|
||||
}, 2000);
|
||||
} else {
|
||||
setMsg({ text: data.message || '변경 실패', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 520 }}>
|
||||
{/* 계정 정보 */}
|
||||
<div className="admin-card" style={{ marginBottom: 20 }}>
|
||||
<div className="admin-card-header"><h3>👤 계정 정보</h3></div>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{[['아이디', user.username], ['표시 이름', user.displayName || '-']].map(([l, v]) => (
|
||||
<div key={l} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#64748b', width: 80 }}>{l}</span>
|
||||
<span style={{ fontSize: 14 }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 변경 */}
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-header"><h3>🔒 비밀번호 변경</h3></div>
|
||||
{msg && (
|
||||
<div style={{
|
||||
padding: '10px 14px', borderRadius: 7, marginBottom: 16, fontSize: 13,
|
||||
background: msg.type === 'error' ? '#fff1f2' : '#f0fdf4',
|
||||
color: msg.type === 'error' ? '#dc2626' : '#16a34a',
|
||||
border: `1px solid ${msg.type === 'error' ? '#fecaca' : '#bbf7d0'}`,
|
||||
}}>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>현재 비밀번호</label>
|
||||
<input type="password" className="form-control" value={form.currentPassword}
|
||||
onChange={e => setForm(p => ({ ...p, currentPassword: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>새 비밀번호</label>
|
||||
<input type="password" className="form-control" value={form.newPassword}
|
||||
placeholder="8자 이상"
|
||||
onChange={e => setForm(p => ({ ...p, newPassword: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>새 비밀번호 확인</label>
|
||||
<input type="password" className="form-control" value={form.confirmPassword}
|
||||
onChange={e => setForm(p => ({ ...p, confirmPassword: e.target.value }))} />
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={handleChange}
|
||||
disabled={saving || !form.currentPassword || !form.newPassword}>
|
||||
{saving ? '변경 중...' : '비밀번호 변경'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
frontend/src/pages/admin/admin.css
Normal file
188
frontend/src/pages/admin/admin.css
Normal file
@ -0,0 +1,188 @@
|
||||
/* ===== Admin System Styles ===== */
|
||||
:root {
|
||||
--admin-sidebar-w: 220px;
|
||||
--admin-bg: #f0f2f5;
|
||||
--admin-sidebar-bg: #1a1d2e;
|
||||
--admin-sidebar-hover: #2a2d3e;
|
||||
--admin-accent: #4f6ef7;
|
||||
--admin-accent-hover: #3a5be0;
|
||||
--admin-text: #1e293b;
|
||||
--admin-muted: #64748b;
|
||||
--admin-border: #e2e8f0;
|
||||
--admin-card: #ffffff;
|
||||
--admin-danger: #ef4444;
|
||||
--admin-success: #22c55e;
|
||||
--admin-warning: #f59e0b;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.admin-wrap { display: flex; min-height: 100vh; background: var(--admin-bg); font-family: 'Pretendard', -apple-system, sans-serif; }
|
||||
|
||||
/* Sidebar */
|
||||
.admin-sidebar {
|
||||
width: var(--admin-sidebar-w);
|
||||
background: var(--admin-sidebar-bg);
|
||||
display: flex; flex-direction: column;
|
||||
position: fixed; top: 0; left: 0; height: 100vh;
|
||||
z-index: 100; transition: transform .25s;
|
||||
}
|
||||
.admin-sidebar-logo {
|
||||
padding: 20px 20px 16px;
|
||||
border-bottom: 1px solid rgba(255,255,255,.08);
|
||||
}
|
||||
.admin-sidebar-logo h2 { color: #fff; font-size: 15px; font-weight: 700; margin: 0; }
|
||||
.admin-sidebar-logo span { color: #7c85a8; font-size: 11px; }
|
||||
|
||||
.admin-nav { flex: 1; overflow-y: auto; padding: 12px 0; }
|
||||
.admin-nav-section { padding: 12px 16px 4px; color: #7c85a8; font-size: 10px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.admin-nav a {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 9px 20px; color: #b0b7cc; text-decoration: none;
|
||||
font-size: 13.5px; border-radius: 0; transition: all .15s;
|
||||
}
|
||||
.admin-nav a:hover { background: var(--admin-sidebar-hover); color: #fff; }
|
||||
.admin-nav a.active { background: var(--admin-accent); color: #fff; }
|
||||
.admin-nav a .nav-icon { width: 16px; text-align: center; flex-shrink: 0; }
|
||||
.admin-nav-badge { background: var(--admin-danger); color: #fff; font-size: 10px; padding: 1px 6px; border-radius: 10px; margin-left: auto; }
|
||||
|
||||
.admin-sidebar-footer { padding: 16px 20px; border-top: 1px solid rgba(255,255,255,.08); }
|
||||
.admin-sidebar-footer button { width: 100%; background: transparent; border: 1px solid rgba(255,255,255,.15); color: #b0b7cc; padding: 8px 12px; border-radius: 6px; font-size: 13px; cursor: pointer; transition: all .15s; }
|
||||
.admin-sidebar-footer button:hover { background: rgba(255,255,255,.08); color: #fff; }
|
||||
|
||||
/* Main */
|
||||
.admin-main { margin-left: var(--admin-sidebar-w); flex: 1; display: flex; flex-direction: column; min-height: 100vh; }
|
||||
.admin-topbar { background: var(--admin-card); border-bottom: 1px solid var(--admin-border); padding: 0 28px; height: 56px; display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; z-index: 50; }
|
||||
.admin-topbar h1 { font-size: 16px; font-weight: 600; color: var(--admin-text); margin: 0; }
|
||||
.admin-topbar-right { display: flex; align-items: center; gap: 12px; }
|
||||
.admin-user-badge { background: #e8ecff; color: var(--admin-accent); padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 600; }
|
||||
|
||||
.admin-content { padding: 28px; flex: 1; }
|
||||
|
||||
/* Cards */
|
||||
.admin-card { background: var(--admin-card); border-radius: 10px; border: 1px solid var(--admin-border); padding: 20px; }
|
||||
.admin-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.admin-card-header h3 { font-size: 14px; font-weight: 600; color: var(--admin-text); margin: 0; }
|
||||
|
||||
/* Stat Cards */
|
||||
.admin-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
||||
.stat-card { background: var(--admin-card); border-radius: 10px; padding: 20px; border: 1px solid var(--admin-border); display: flex; align-items: center; gap: 16px; }
|
||||
.stat-icon { width: 44px; height: 44px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0; }
|
||||
.stat-icon.blue { background: #eff2ff; }
|
||||
.stat-icon.green { background: #f0fdf4; }
|
||||
.stat-icon.orange { background: #fff7ed; }
|
||||
.stat-icon.red { background: #fff1f2; }
|
||||
.stat-info h4 { font-size: 22px; font-weight: 700; color: var(--admin-text); margin: 0 0 2px; }
|
||||
.stat-info p { font-size: 12px; color: var(--admin-muted); margin: 0; }
|
||||
|
||||
/* Table */
|
||||
.admin-table-wrap { overflow-x: auto; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
||||
.admin-table th { background: #f8fafc; color: var(--admin-muted); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; padding: 10px 14px; border-bottom: 1px solid var(--admin-border); text-align: left; white-space: nowrap; }
|
||||
.admin-table td { padding: 12px 14px; border-bottom: 1px solid #f1f5f9; color: var(--admin-text); vertical-align: middle; }
|
||||
.admin-table tr:last-child td { border-bottom: none; }
|
||||
.admin-table tr:hover td { background: #f8fafc; }
|
||||
.admin-table .truncate { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* Badges */
|
||||
.badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 20px; font-size: 11px; font-weight: 600; }
|
||||
.badge-green { background: #dcfce7; color: #16a34a; }
|
||||
.badge-red { background: #fee2e2; color: #dc2626; }
|
||||
.badge-blue { background: #dbeafe; color: #2563eb; }
|
||||
.badge-orange { background: #ffedd5; color: #ea580c; }
|
||||
.badge-gray { background: #f1f5f9; color: #64748b; }
|
||||
|
||||
/* Buttons */
|
||||
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 7px; font-size: 13px; font-weight: 500; cursor: pointer; border: none; transition: all .15s; text-decoration: none; }
|
||||
.btn-primary { background: var(--admin-accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--admin-accent-hover); }
|
||||
.btn-outline { background: transparent; color: var(--admin-text); border: 1px solid var(--admin-border); }
|
||||
.btn-outline:hover { background: #f8fafc; }
|
||||
.btn-danger { background: transparent; color: var(--admin-danger); border: 1px solid #fecaca; }
|
||||
.btn-danger:hover { background: #fff1f2; }
|
||||
.btn-sm { padding: 5px 10px; font-size: 12px; }
|
||||
.btn-icon { padding: 6px; border-radius: 6px; }
|
||||
|
||||
/* Action buttons row */
|
||||
.action-btns { display: flex; gap: 6px; align-items: center; }
|
||||
|
||||
/* Toolbar */
|
||||
.admin-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.admin-toolbar-right { margin-left: auto; display: flex; gap: 8px; }
|
||||
.admin-search { position: relative; }
|
||||
.admin-search input { padding: 8px 12px 8px 34px; border: 1px solid var(--admin-border); border-radius: 7px; font-size: 13px; outline: none; width: 200px; background: #fff; color: var(--admin-text); }
|
||||
.admin-search input:focus { border-color: var(--admin-accent); }
|
||||
.admin-search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--admin-muted); font-size: 14px; }
|
||||
.admin-select { padding: 8px 12px; border: 1px solid var(--admin-border); border-radius: 7px; font-size: 13px; outline: none; background: #fff; color: var(--admin-text); cursor: pointer; }
|
||||
.admin-select:focus { border-color: var(--admin-accent); }
|
||||
|
||||
/* Modal */
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 16px; }
|
||||
.modal { background: var(--admin-card); border-radius: 12px; width: 100%; max-width: 640px; max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,.2); }
|
||||
.modal-header { padding: 20px 24px 16px; border-bottom: 1px solid var(--admin-border); display: flex; align-items: center; justify-content: space-between; }
|
||||
.modal-header h3 { font-size: 15px; font-weight: 600; margin: 0; }
|
||||
.modal-header button { background: none; border: none; cursor: pointer; color: var(--admin-muted); font-size: 18px; line-height: 1; padding: 4px; }
|
||||
.modal-body { padding: 24px; }
|
||||
.modal-footer { padding: 16px 24px; border-top: 1px solid var(--admin-border); display: flex; justify-content: flex-end; gap: 10px; }
|
||||
|
||||
/* Form */
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; font-size: 12px; font-weight: 600; color: var(--admin-muted); text-transform: uppercase; letter-spacing: .04em; margin-bottom: 6px; }
|
||||
.form-control { width: 100%; padding: 9px 12px; border: 1px solid var(--admin-border); border-radius: 7px; font-size: 13.5px; color: var(--admin-text); outline: none; box-sizing: border-box; background: #fff; font-family: inherit; }
|
||||
.form-control:focus { border-color: var(--admin-accent); box-shadow: 0 0 0 3px rgba(79,110,247,.12); }
|
||||
textarea.form-control { resize: vertical; min-height: 100px; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.form-check { display: flex; align-items: center; gap: 8px; font-size: 13.5px; cursor: pointer; }
|
||||
.form-check input { width: 16px; height: 16px; cursor: pointer; accent-color: var(--admin-accent); }
|
||||
|
||||
/* Pagination */
|
||||
.admin-pagination { display: flex; align-items: center; justify-content: space-between; margin-top: 16px; }
|
||||
.admin-pagination-info { font-size: 12px; color: var(--admin-muted); }
|
||||
.pagination-btns { display: flex; gap: 4px; }
|
||||
.pagination-btns button { padding: 5px 10px; border: 1px solid var(--admin-border); background: #fff; color: var(--admin-text); border-radius: 5px; font-size: 12px; cursor: pointer; transition: all .15s; }
|
||||
.pagination-btns button:hover:not(:disabled) { background: var(--admin-accent); color: #fff; border-color: var(--admin-accent); }
|
||||
.pagination-btns button.active { background: var(--admin-accent); color: #fff; border-color: var(--admin-accent); }
|
||||
.pagination-btns button:disabled { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
/* Login */
|
||||
.admin-login-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #1a1d2e 0%, #2a2d4e 100%); padding: 16px; }
|
||||
.admin-login-box { background: #fff; border-radius: 14px; padding: 40px 36px; width: 100%; max-width: 380px; box-shadow: 0 20px 60px rgba(0,0,0,.3); }
|
||||
.admin-login-box .login-logo { text-align: center; margin-bottom: 28px; }
|
||||
.admin-login-box .login-logo h1 { font-size: 22px; font-weight: 800; color: var(--admin-text); margin: 8px 0 4px; }
|
||||
.admin-login-box .login-logo p { font-size: 12px; color: var(--admin-muted); margin: 0; }
|
||||
.admin-login-box .login-badge { display: inline-block; background: #eff2ff; color: var(--admin-accent); padding: 2px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; margin-bottom: 6px; }
|
||||
.login-input-group { margin-bottom: 14px; }
|
||||
.login-input-group label { display: block; font-size: 12px; font-weight: 600; color: var(--admin-muted); margin-bottom: 6px; }
|
||||
.login-input-group input { width: 100%; padding: 11px 14px; border: 1.5px solid var(--admin-border); border-radius: 8px; font-size: 14px; outline: none; box-sizing: border-box; transition: border-color .15s; }
|
||||
.login-input-group input:focus { border-color: var(--admin-accent); }
|
||||
.login-btn { width: 100%; padding: 12px; background: var(--admin-accent); color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; margin-top: 6px; transition: background .15s; }
|
||||
.login-btn:hover { background: var(--admin-accent-hover); }
|
||||
.login-error { background: #fff1f2; color: var(--admin-danger); font-size: 12.5px; padding: 9px 12px; border-radius: 7px; margin-bottom: 14px; border: 1px solid #fecaca; }
|
||||
|
||||
/* Dashboard recent list */
|
||||
.recent-list { list-style: none; padding: 0; margin: 0; }
|
||||
.recent-list li { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
|
||||
.recent-list li:last-child { border-bottom: none; }
|
||||
.recent-list .rl-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--admin-accent); flex-shrink: 0; }
|
||||
.recent-list .rl-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--admin-text); }
|
||||
.recent-list .rl-meta { color: var(--admin-muted); font-size: 11px; white-space: nowrap; }
|
||||
|
||||
/* Empty state */
|
||||
.empty-state { text-align: center; padding: 48px 0; color: var(--admin-muted); }
|
||||
.empty-state .empty-icon { font-size: 40px; margin-bottom: 12px; }
|
||||
.empty-state p { font-size: 13.5px; margin: 0; }
|
||||
|
||||
/* Toast */
|
||||
.admin-toast { position: fixed; bottom: 24px; right: 24px; z-index: 9999; display: flex; flex-direction: column; gap: 8px; }
|
||||
.toast-item { background: #1e293b; color: #fff; padding: 12px 20px; border-radius: 8px; font-size: 13px; animation: slideUp .2s ease; box-shadow: 0 4px 16px rgba(0,0,0,.2); }
|
||||
.toast-item.success { border-left: 3px solid var(--admin-success); }
|
||||
.toast-item.error { border-left: 3px solid var(--admin-danger); }
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.admin-sidebar { transform: translateX(-100%); }
|
||||
.admin-sidebar.open { transform: translateX(0); }
|
||||
.admin-main { margin-left: 0; }
|
||||
.form-row { grid-template-columns: 1fr; }
|
||||
.admin-stats { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user