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:
DESKTOP-TKLFCPRython 2026-05-30 18:40:24 +09:00
parent abd4dde1a8
commit 6e02e7efe0
25 changed files with 1748 additions and 41 deletions

16
.gitignore vendored Normal file
View 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
View 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)
}
}
}

View File

@ -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>

View File

@ -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());
}
}

View File

@ -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;
}
}

View File

@ -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());
}
}

View File

@ -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() {

View 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;
}

View 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;
}

View File

@ -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);
}

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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

View File

@ -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>
);
}

View 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>
</>
);
}

View 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>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
)}
</>
);
}

View 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>
)}
</>
);
}

View 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>
);
}

View 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; }
}