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> <artifactId>spring-boot-starter-mail</artifactId>
</dependency> </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 --> <!-- Lombok -->
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>

View File

@ -1,53 +1,64 @@
package kr.co.zioinfo.web.config; package kr.co.zioinfo.web.config;
import kr.co.zioinfo.web.model.News; import kr.co.zioinfo.web.model.*;
import kr.co.zioinfo.web.repository.NewsRepository; import kr.co.zioinfo.web.repository.*;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner; import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.time.LocalDate;
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class DataInitializer implements CommandLineRunner { public class DataInitializer implements CommandLineRunner {
private final NewsRepository newsRepo; private final NewsRepository newsRepo;
private final AdminUserRepository adminUserRepo;
private final RecruitRepository recruitRepo;
private final PasswordEncoder passwordEncoder;
@Override @Override
public void run(String... args) { 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; if (newsRepo.count() > 0) return;
newsRepo.save(News.builder() newsRepo.save(News.builder()
.title("GUARDiA ITSM 2.0 정식 출시 — AI 기반 인프라 자율 운영 플랫폼") .title("GUARDiA ITSM 2.0 정식 출시 — AI 기반 인프라 자율 운영 플랫폼")
.category("보도자료") .category("보도자료")
.summary("(주)지오정보기술이 공공기관 레거시 인프라 자동화를 위한 GUARDiA ITSM 2.0을 정식 출시했습니다.") .summary("(주)지오정보기술이 공공기관 레거시 인프라 자동화를 위한 GUARDiA ITSM 2.0을 정식 출시했습니다.")
.content("GUARDiA ITSM 2.0은 메신저 한 줄 명령으로 에이전트리스 SSH/SFTP 배포·운영을 자동화하는 온프레미스 플랫폼입니다. " + .content("GUARDiA ITSM 2.0은 메신저 한 줄 명령으로 에이전트리스 SSH/SFTP 배포·운영을 자동화하는 온프레미스 플랫폼입니다.")
"1,000개 이상 관공서를 대상으로 하며 외부 클라우드 의존 없이 완전 폐쇄망 환경에서 동작합니다.")
.visible(true).viewCount(128).build()); .visible(true).viewCount(128).build());
newsRepo.save(News.builder() newsRepo.save(News.builder()
.title("2026 공공기관 AI 인프라 혁신 박람회 참가") .title("2026 공공기관 AI 인프라 혁신 박람회 참가")
.category("공지사항") .category("공지사항")
.summary("지오정보기술이 2026 공공기관 AI 인프라 혁신 박람회에 참가하여 GUARDiA 솔루션을 선보입니다.") .summary("지오정보기술이 2026 공공기관 AI 인프라 혁신 박람회에 참가하여 GUARDiA 솔루션을 선보입니다.")
.content("박람회 기간: 2026년 6월 15일~17일 / 장소: 코엑스 A홀 / 부스: A-215\n" + .content("박람회 기간: 2026년 6월 15일~17일 / 장소: 코엑스 A홀 / 부스: A-215")
"GUARDiA ITSM 라이브 데모 및 도입 상담을 진행합니다.")
.visible(true).viewCount(87).build()); .visible(true).viewCount(87).build());
newsRepo.save(News.builder() newsRepo.save(News.builder()
.title("행정안전부 공공SW 우수제품 선정") .title("행정안전부 공공SW 우수제품 선정")
.category("보도자료") .category("보도자료")
.summary("GUARDiA ITSM이 행정안전부 2026년 공공SW 우수제품으로 선정되었습니다.") .summary("GUARDiA ITSM이 행정안전부 2026년 공공SW 우수제품으로 선정되었습니다.")
.content("행정안전부는 공공기관 정보화 사업에 적합한 소프트웨어를 심사하여 우수제품을 선정합니다. " + .content("행정안전부는 공공기관 정보화 사업에 적합한 소프트웨어를 심사하여 우수제품을 선정합니다.")
"GUARDiA는 보안성, 안정성, 공공 적합성에서 높은 평가를 받았습니다.")
.visible(true).viewCount(214).build()); .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() newsRepo.save(News.builder()
.title("특허 등록 — 에이전트리스 레거시 인프라 자동화 방법") .title("특허 등록 — 에이전트리스 레거시 인프라 자동화 방법")
.category("공지사항") .category("공지사항")
@ -55,4 +66,32 @@ public class DataInitializer implements CommandLineRunner {
.content("특허명: 에이전트리스 레거시 인프라 자동화 시스템 및 방법\n등록번호: 10-2026-XXXXXXX") .content("특허명: 에이전트리스 레거시 인프라 자동화 시스템 및 방법\n등록번호: 10-2026-XXXXXXX")
.visible(true).viewCount(41).build()); .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.Inquiry;
import kr.co.zioinfo.web.model.News; 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.InquiryService;
import kr.co.zioinfo.web.service.NewsService; import kr.co.zioinfo.web.service.NewsService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -14,11 +15,11 @@ import java.util.*;
@RestController @RestController
@RequestMapping("/api") @RequestMapping("/api")
@RequiredArgsConstructor @RequiredArgsConstructor
@CrossOrigin(origins = {"http://localhost:3000", "http://localhost:5173"})
public class ApiController { public class ApiController {
private final NewsService newsService; private final NewsService newsService;
private final InquiryService inquiryService; private final InquiryService inquiryService;
private final RecruitRepository recruitRepo;
// 회사 정보 // 회사 정보
@GetMapping("/company") @GetMapping("/company")
@ -136,6 +137,12 @@ public class ApiController {
)); ));
} }
// 채용공고 (공개)
@GetMapping("/recruits")
public ResponseEntity<?> getRecruits() {
return ResponseEntity.ok(recruitRepo.findByActiveTrueOrderByCreatedAtDesc());
}
// 메뉴 구조 // 메뉴 구조
@GetMapping("/menu") @GetMapping("/menu")
public ResponseEntity<List<Map<String, Object>>> getMenu() { 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; package kr.co.zioinfo.web.repository;
import kr.co.zioinfo.web.model.Inquiry; import kr.co.zioinfo.web.model.Inquiry;
import org.springframework.data.domain.*;
import org.springframework.data.jpa.repository.JpaRepository; 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; package kr.co.zioinfo.web.repository;
import kr.co.zioinfo.web.model.News; import kr.co.zioinfo.web.model.News;
import org.springframework.data.domain.*; import org.springframework.data.domain.*;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface NewsRepository extends JpaRepository<News, Long> { public interface NewsRepository extends JpaRepository<News, Long> {
Page<News> findByVisibleTrue(Pageable p); Page<News> findByVisibleTrue(Pageable p);
Page<News> findByCategoryAndVisibleTrue(String category, 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 mail.smtp.starttls.enable: true
zioinfo: zioinfo:
jwt:
secret: zioinfo-admin-jwt-secret-key-must-be-at-least-32-chars-long
expiration-ms: 28800000 # 8시간
company: company:
name: (주)지오정보기술 name: (주)지오정보기술
email: info@zioinfo.co.kr email: info@zioinfo.co.kr

View File

@ -1,42 +1,84 @@
import React, { Suspense, lazy } from 'react'; 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 Header from './components/layout/Header';
import Footer from './components/layout/Footer'; import Footer from './components/layout/Footer';
const Home = lazy(() => import('./pages/Home')); const Home = lazy(() => import('./pages/Home'));
const GuardiaDetail = lazy(() => import('./pages/GuardiaDetail')); const GuardiaDetail = lazy(() => import('./pages/GuardiaDetail'));
const Company = lazy(() => import('./pages/Company')); const SolutionPage = lazy(() => import('./pages/SolutionPage'));
const Contact = lazy(() => import('./pages/Contact')); const Company = lazy(() => import('./pages/Company'));
const NewsPage = lazy(() => import('./pages/NewsPage')); const Business = lazy(() => import('./pages/Business'));
const Recruit = lazy(() => import('./pages/Recruit')); const Contact = lazy(() => import('./pages/Contact'));
const NotFound = lazy(() => import('./pages/NotFound')); 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() { function Loading() {
return ( return (
<div style={{display:'flex',alignItems:'center',justifyContent:'center', <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center',
height:'60vh',color:'var(--gray-400)',fontSize:'14px'}}> height: '60vh', color: 'var(--gray-400)', fontSize: '14px' }}>
로딩 ... 로딩 ...
</div> </div>
); );
} }
export default function App() { function PublicLayout({ children }) {
const location = useLocation();
return ( return (
<> <>
<Header /> <Header />
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>{children}</Suspense>
<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>
<Footer /> <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; }
}