feat: real-data aggregation APIs + TOTP 2FA hardening + work screens (SCR-39~48)
- Aggregation packages: analytics/dashboard/ops/admin/catalog (+V13 indexes) - TOTP 2FA: SecretCipher(AES-256-GCM), V12 migration, OTP enroll/challenge UI, OTP_ENFORCE env - Common work screens SCR-39~48 wired to work/* contracts (gaps disabled+tooltip) - M2 booth overlap check (BOOTH_OVERLAP, compliance-v1.1) + 8 unit test classes - QA PASS (boundary cross-check), backend test + frontend tsc green Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c7b853be17
commit
16bfb238c1
@ -0,0 +1,35 @@
|
||||
package com.zioinfo.kintex.admin;
|
||||
|
||||
import com.zioinfo.kintex.admin.dto.AdminDashboardDto;
|
||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||
import com.zioinfo.kintex.common.ApiResponse;
|
||||
import com.zioinfo.kintex.system.SystemAccessGuard;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 관리자 백오피스 대시보드 API (SCR-14). 시스템관리자(홀매니저/ADMIN)만 접근.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/dashboard")
|
||||
public class AdminDashboardController {
|
||||
|
||||
private final AdminDashboardService service;
|
||||
private final SystemAccessGuard guard;
|
||||
|
||||
public AdminDashboardController(AdminDashboardService service, SystemAccessGuard guard) {
|
||||
this.service = service;
|
||||
this.guard = guard;
|
||||
}
|
||||
|
||||
/** GET — 전 행사 KPI·라이브 행사·(원천 있으면)관람객 추이. */
|
||||
@GetMapping
|
||||
public ApiResponse<AdminDashboardDto> get(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@RequestParam(required = false, defaultValue = "KINTEX") String tenant) {
|
||||
guard.requireAdmin(principal);
|
||||
return ApiResponse.ok(service.getDashboard(tenant));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
package com.zioinfo.kintex.admin;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 관리자 대시보드 집계 매퍼 — event·hall_assignment·app_user·company read-only.
|
||||
* 진행 여부는 오늘 날짜(start≤오늘≤end) 기준. 마이그레이션 불변.
|
||||
*/
|
||||
@Mapper
|
||||
public interface AdminDashboardMapper {
|
||||
|
||||
/** KPI 원자 집계 — 진행 행사·사용 홀·활성 사용자·등록업체 수. */
|
||||
@Select("""
|
||||
SELECT
|
||||
(SELECT count(*) FROM event
|
||||
WHERE start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE) AS ongoingEvents,
|
||||
(SELECT count(DISTINCT ha.hall_id) FROM hall_assignment ha JOIN event e ON e.id = ha.event_id
|
||||
WHERE e.start_date <= CURRENT_DATE AND e.end_date >= CURRENT_DATE) AS hallsInUse,
|
||||
(SELECT count(*) FROM app_user WHERE status = 'ACTIVE') AS activeUsers,
|
||||
(SELECT count(*) FROM company) AS companies,
|
||||
(SELECT count(*) FROM event
|
||||
WHERE start_date > CURRENT_DATE) AS upcomingEvents
|
||||
""")
|
||||
Map<String, Object> findKpiCounts();
|
||||
|
||||
/** 진행 중(라이브) 행사 — 대표 홀·부스 수·홀 수용량. 최대 20건. */
|
||||
@Select("""
|
||||
SELECT e.id AS eventId,
|
||||
e.name,
|
||||
COALESCE(h.label, '') AS hall,
|
||||
e.status,
|
||||
(SELECT count(*) FROM booth b JOIN layout l ON l.id = b.layout_id
|
||||
WHERE l.event_id = e.id) AS boothCount,
|
||||
COALESCE((SELECT sum(h2.booth_capacity) FROM hall_assignment ha2
|
||||
JOIN hall h2 ON h2.id = ha2.hall_id WHERE ha2.event_id = e.id), 0) AS capacity
|
||||
FROM event e
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT hall_id FROM hall_assignment ha
|
||||
WHERE ha.event_id = e.id ORDER BY is_primary DESC LIMIT 1
|
||||
) pa ON true
|
||||
LEFT JOIN hall h ON h.id = pa.hall_id
|
||||
WHERE e.start_date <= CURRENT_DATE AND e.end_date >= CURRENT_DATE
|
||||
ORDER BY e.start_date DESC
|
||||
LIMIT 20
|
||||
""")
|
||||
List<Map<String, Object>> findLiveEvents();
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
package com.zioinfo.kintex.admin;
|
||||
|
||||
import com.zioinfo.kintex.admin.dto.AdminDashboardDto;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 관리자 대시보드 서비스 — 실 테이블 집계로 KPI·라이브 행사를 조립.
|
||||
* 관람객 추이는 센서/집계 인프라 부재 → 빈배열. 테넌트는 실 데이터(KINTEX)만 반환.
|
||||
*/
|
||||
@Service
|
||||
public class AdminDashboardService {
|
||||
|
||||
private final AdminDashboardMapper mapper;
|
||||
|
||||
public AdminDashboardService(AdminDashboardMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public AdminDashboardDto getDashboard(String tenant) {
|
||||
Map<String, Object> c = mapper.findKpiCounts();
|
||||
long ongoing = lng(c == null ? null : c.get("ongoingEvents"));
|
||||
long halls = lng(c == null ? null : c.get("hallsInUse"));
|
||||
long users = lng(c == null ? null : c.get("activeUsers"));
|
||||
long companies = lng(c == null ? null : c.get("companies"));
|
||||
long upcoming = lng(c == null ? null : c.get("upcomingEvents"));
|
||||
|
||||
List<AdminDashboardDto.Kpi> kpis = new ArrayList<>();
|
||||
kpis.add(new AdminDashboardDto.Kpi("진행 행사", String.valueOf(ongoing), halls + "개 홀"));
|
||||
kpis.add(new AdminDashboardDto.Kpi("예정 행사", String.valueOf(upcoming), null));
|
||||
kpis.add(new AdminDashboardDto.Kpi("활성 사용자", String.valueOf(users), null));
|
||||
kpis.add(new AdminDashboardDto.Kpi("등록업체", String.valueOf(companies), null));
|
||||
|
||||
List<AdminDashboardDto.LiveEvent> live = new ArrayList<>();
|
||||
List<Map<String, Object>> rows = mapper.findLiveEvents();
|
||||
if (rows != null) {
|
||||
for (Map<String, Object> r : rows) {
|
||||
long booths = lng(r.get("boothCount"));
|
||||
long capacity = lng(r.get("capacity"));
|
||||
int occupancy = capacity > 0 ? (int) Math.min(100, Math.round(booths * 100.0 / capacity)) : 0;
|
||||
live.add(new AdminDashboardDto.LiveEvent(
|
||||
str(r.get("eventId")), str(r.get("name")), str(r.get("hall")),
|
||||
occupancy, "ongoing"));
|
||||
}
|
||||
}
|
||||
|
||||
// 관람객 추이: 센서/집계 원천 부재 → 빈배열(정직). 테넌트: 실 데이터 KINTEX 만.
|
||||
List<AdminDashboardDto.VisitorPoint> visitorTrend = new ArrayList<>();
|
||||
List<String> tenants = List.of("KINTEX");
|
||||
|
||||
return new AdminDashboardDto(kpis, visitorTrend, live, tenants);
|
||||
}
|
||||
|
||||
private static long lng(Object o) {
|
||||
if (o == null) return 0L;
|
||||
if (o instanceof Number n) return n.longValue();
|
||||
try {
|
||||
return Long.parseLong(String.valueOf(o));
|
||||
} catch (NumberFormatException e) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package com.zioinfo.kintex.admin.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 관리자 백오피스 대시보드 (SCR-14 / 06_backend_api_gaps §4).
|
||||
* 집계원: event(진행/라이브)·hall_assignment·app_user·company. 관람객 추이는 센서/집계 부재 → 빈배열(정직).
|
||||
*/
|
||||
public record AdminDashboardDto(
|
||||
List<Kpi> kpis,
|
||||
List<VisitorPoint> visitorTrend,
|
||||
List<LiveEvent> liveEvents,
|
||||
List<String> tenants
|
||||
) {
|
||||
public record Kpi(String label, String value, String sub) {
|
||||
}
|
||||
|
||||
/** 시간대별 관람객(원천 부재 시 미집계). */
|
||||
public record VisitorPoint(String hour, long count) {
|
||||
}
|
||||
|
||||
/** 라이브(진행 중) 행사 — 점유율=부스수/홀 수용. */
|
||||
public record LiveEvent(String eventId, String name, String hall, int occupancy, String status) {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package com.zioinfo.kintex.analytics;
|
||||
|
||||
import com.zioinfo.kintex.analytics.dto.AnalyticsDto;
|
||||
import com.zioinfo.kintex.auth.EventAccessGuard;
|
||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||
import com.zioinfo.kintex.common.ApiResponse;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 경영분석 BI API (SCR-13). 행사 멤버/홀매니저 열람.
|
||||
* perspective(operator|exhibitor)·period(event|d90|annual)로 집계 세트가 갈린다.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/events/{eventId}/analytics")
|
||||
public class AnalyticsController {
|
||||
|
||||
private final AnalyticsService service;
|
||||
private final EventAccessGuard guard;
|
||||
|
||||
public AnalyticsController(AnalyticsService service, EventAccessGuard guard) {
|
||||
this.service = service;
|
||||
this.guard = guard;
|
||||
}
|
||||
|
||||
/** GET — KPI·매출 추이·손익·(원천 있으면)업종·참가업체 성과. */
|
||||
@GetMapping
|
||||
public ApiResponse<AnalyticsDto> get(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId,
|
||||
@RequestParam(required = false, defaultValue = "operator") String perspective,
|
||||
@RequestParam(required = false, defaultValue = "event") String period) {
|
||||
guard.requireEventAccess(principal, eventId);
|
||||
return ApiResponse.ok(service.getAnalytics(eventId, perspective, period));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
package com.zioinfo.kintex.analytics;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 경영분석 BI 집계 매퍼 — booth(PostGIS ST_Area)·utility_order·master_data(요율) read-only.
|
||||
* 매출은 판매면적×임대 요율(원/㎡·12h)로 산출한다. 마이그레이션 불변.
|
||||
*/
|
||||
@Mapper
|
||||
public interface AnalyticsMapper {
|
||||
|
||||
/** 전시홀 임대 요율(원/㎡) — master_data RATE.exhibitionHallPerM2. 없으면 null. */
|
||||
@Select("""
|
||||
SELECT (value_json #>> '{}')::numeric
|
||||
FROM master_data WHERE category = 'RATE' AND code = 'exhibitionHallPerM2'
|
||||
""")
|
||||
Double findRentalRatePerM2();
|
||||
|
||||
/** 행사 부스 총 판매면적(㎡)·부스 수. */
|
||||
@Select("""
|
||||
SELECT COALESCE(sum(ST_Area(b.geom)), 0) AS salesArea,
|
||||
count(*) AS boothCount
|
||||
FROM booth b JOIN layout l ON l.id = b.layout_id
|
||||
WHERE l.event_id = #{eventId}
|
||||
""")
|
||||
Map<String, Object> findAreaAndCount(@Param("eventId") String eventId);
|
||||
|
||||
/** 월별 부스 생성 추이(period=YYYY-MM, 면적·부스 수). days>0 이면 최근 N일로 제한. */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT to_char(b.created_at, 'YYYY-MM') AS period,
|
||||
COALESCE(sum(ST_Area(b.geom)), 0) AS salesArea,
|
||||
count(*) AS booths
|
||||
FROM booth b JOIN layout l ON l.id = b.layout_id
|
||||
WHERE l.event_id = #{eventId}
|
||||
<if test="days > 0">AND b.created_at >= now() - make_interval(days => #{days})</if>
|
||||
GROUP BY to_char(b.created_at, 'YYYY-MM')
|
||||
ORDER BY period
|
||||
</script>
|
||||
""")
|
||||
List<Map<String, Object>> findMonthlyTrend(@Param("eventId") String eventId, @Param("days") int days);
|
||||
|
||||
/** 유틸리티 매출 — quote.total 합계(숫자 형태만, 없으면 0). jsonb {@code ?} 연산자는 JDBC 파라미터와 충돌하므로 회피. */
|
||||
@Select("""
|
||||
SELECT COALESCE(sum((quote->>'total')::numeric), 0)
|
||||
FROM utility_order
|
||||
WHERE event_id = #{eventId} AND quote->>'total' ~ '^[0-9]+(\\.[0-9]+)?$'
|
||||
""")
|
||||
Double findUtilityRevenue(@Param("eventId") String eventId);
|
||||
}
|
||||
@ -0,0 +1,128 @@
|
||||
package com.zioinfo.kintex.analytics;
|
||||
|
||||
import com.zioinfo.kintex.analytics.dto.AnalyticsDto;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 경영분석 BI 서비스 — 판매면적×임대 요율로 매출을 산출하고 부스/유틸리티 실적을 조립.
|
||||
* perspective(operator|exhibitor)에 따라 필드 세트가 갈린다. 원천 부재 지표는 빈배열(정직).
|
||||
*/
|
||||
@Service
|
||||
public class AnalyticsService {
|
||||
|
||||
private final AnalyticsMapper mapper;
|
||||
|
||||
public AnalyticsService(AnalyticsMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public AnalyticsDto getAnalytics(String eventId, String perspective, String period) {
|
||||
boolean exhibitor = "exhibitor".equalsIgnoreCase(perspective);
|
||||
int days = periodDays(period);
|
||||
|
||||
Double rate = mapper.findRentalRatePerM2();
|
||||
double ratePerM2 = rate == null ? 0.0 : rate;
|
||||
|
||||
Map<String, Object> ac = mapper.findAreaAndCount(eventId);
|
||||
double salesArea = dbl(ac == null ? null : ac.get("salesArea"));
|
||||
long boothCount = lng(ac == null ? null : ac.get("boothCount"));
|
||||
long rentalRevenue = Math.round(salesArea * ratePerM2);
|
||||
long utilityRevenue = Math.round(dbl(mapper.findUtilityRevenue(eventId)));
|
||||
|
||||
List<AnalyticsDto.Kpi> kpis = new ArrayList<>();
|
||||
kpis.add(new AnalyticsDto.Kpi("임대 매출", formatKrw(rentalRevenue), null, "flat"));
|
||||
kpis.add(new AnalyticsDto.Kpi("유틸리티 매출", formatKrw(utilityRevenue), null, "flat"));
|
||||
kpis.add(new AnalyticsDto.Kpi("판매 부스", String.valueOf(boothCount), null, "flat"));
|
||||
kpis.add(new AnalyticsDto.Kpi("판매 면적", Math.round(salesArea) + "㎡", null, "flat"));
|
||||
|
||||
List<AnalyticsDto.TrendPoint> trend = new ArrayList<>();
|
||||
List<Map<String, Object>> trendRows = mapper.findMonthlyTrend(eventId, days);
|
||||
if (trendRows != null) {
|
||||
for (Map<String, Object> r : trendRows) {
|
||||
long area = Math.round(dbl(r.get("salesArea")));
|
||||
trend.add(new AnalyticsDto.TrendPoint(
|
||||
str(r.get("period")), Math.round(area * ratePerM2), lng(r.get("booths"))));
|
||||
}
|
||||
}
|
||||
|
||||
// 손익(operator 관점) — 실 매출 항목만. 업종 분류 원천 부재 → sectors 미집계.
|
||||
List<AnalyticsDto.PnlRow> pnl = new ArrayList<>();
|
||||
List<AnalyticsDto.Sector> sectors = new ArrayList<>();
|
||||
if (!exhibitor) {
|
||||
pnl.add(new AnalyticsDto.PnlRow("부스 임대", rentalRevenue, grade(rentalRevenue)));
|
||||
pnl.add(new AnalyticsDto.PnlRow("유틸리티", utilityRevenue, grade(utilityRevenue)));
|
||||
}
|
||||
|
||||
// 참가업체 성과 — 방문자 리드(M13) 원천 부재 → 미집계(빈배열).
|
||||
List<AnalyticsDto.ExhibitorPerf> exhibitorPerf = new ArrayList<>();
|
||||
|
||||
return new AnalyticsDto(kpis, trend, sectors, pnl, exhibitorPerf);
|
||||
}
|
||||
|
||||
private static int periodDays(String period) {
|
||||
if (period == null) {
|
||||
return 0;
|
||||
}
|
||||
return switch (period.toLowerCase()) {
|
||||
case "d90" -> 90;
|
||||
case "annual" -> 365;
|
||||
default -> 0; // event = 전체 기간
|
||||
};
|
||||
}
|
||||
|
||||
/** 금액 등급 — 억 단위 임계(단순·결정적). 원천 임계 룰셋 확정 시 교체. */
|
||||
private static String grade(long amount) {
|
||||
if (amount >= 1_000_000_000L) return "A";
|
||||
if (amount >= 100_000_000L) return "B";
|
||||
return "C";
|
||||
}
|
||||
|
||||
/** 원화 축약 표기(억/만). */
|
||||
private static String formatKrw(long won) {
|
||||
if (won >= 100_000_000L) {
|
||||
double eok = won / 100_000_000.0;
|
||||
return "₩" + trim(eok) + "억";
|
||||
}
|
||||
if (won >= 10_000L) {
|
||||
double man = won / 10_000.0;
|
||||
return "₩" + trim(man) + "만";
|
||||
}
|
||||
return "₩" + won;
|
||||
}
|
||||
|
||||
private static String trim(double v) {
|
||||
double r = Math.round(v * 100.0) / 100.0;
|
||||
if (r == Math.floor(r)) {
|
||||
return String.valueOf((long) r);
|
||||
}
|
||||
return String.valueOf(r);
|
||||
}
|
||||
|
||||
private static double dbl(Object o) {
|
||||
if (o == null) return 0.0;
|
||||
if (o instanceof Number n) return n.doubleValue();
|
||||
try {
|
||||
return Double.parseDouble(String.valueOf(o));
|
||||
} catch (NumberFormatException e) {
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
private static long lng(Object o) {
|
||||
if (o == null) return 0L;
|
||||
if (o instanceof Number n) return n.longValue();
|
||||
try {
|
||||
return Long.parseLong(String.valueOf(o));
|
||||
} catch (NumberFormatException e) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
package com.zioinfo.kintex.analytics.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 경영분석 BI 집계 (SCR-13 / 06_backend_api_gaps §3).
|
||||
* 집계원: booth 면적(ST_Area)×임대 요율(master_data)·utility_order.quote·booth 월별 추이.
|
||||
* 업종(sectors)·리드(exhibitorPerf)는 원천(M13 방문자/업종 분류) 부재 → 빈배열(정직 반환).
|
||||
*/
|
||||
public record AnalyticsDto(
|
||||
List<Kpi> kpis,
|
||||
List<TrendPoint> trend,
|
||||
List<Sector> sectors,
|
||||
List<PnlRow> pnl,
|
||||
List<ExhibitorPerf> exhibitorPerf
|
||||
) {
|
||||
public record Kpi(String label, String value, String delta, String trend) {
|
||||
}
|
||||
|
||||
/** 월별 매출·부스 추이(period="YYYY-MM"). */
|
||||
public record TrendPoint(String period, long revenue, long booths) {
|
||||
}
|
||||
|
||||
/** 업종 구성(원천 부재 시 미집계). */
|
||||
public record Sector(String name, double sharePercent, long revenue) {
|
||||
}
|
||||
|
||||
/** 손익 항목(grade: A|B|C, 금액 기준 등급). */
|
||||
public record PnlRow(String item, long amount, String grade) {
|
||||
}
|
||||
|
||||
/** 참가업체 성과(리드·전환율 — M13 연동 필요). */
|
||||
public record ExhibitorPerf(String companyName, long leads, double conversionPercent) {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package com.zioinfo.kintex.catalog;
|
||||
|
||||
import com.zioinfo.kintex.auth.EventAccessGuard;
|
||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||
import com.zioinfo.kintex.catalog.dto.ExhibitionDto;
|
||||
import com.zioinfo.kintex.common.ApiResponse;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 전시 일정 카탈로그 API (SCR-15).
|
||||
* 인증 필수(로그인 사용자면 누구나) — 워크스페이스(내 행사)와 달리 전 홀 10년치 카탈로그를 제공한다.
|
||||
* <p>배열 직접 반환({@code ApiResponse<List<ExhibitionDto>>}) — 프론트 {@code exhibitionApi.list()} 계약 정합.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/events")
|
||||
public class EventCatalogController {
|
||||
|
||||
/** 카탈로그 상한(과도 응답 방지) — V10 시드 1,098건을 단일 응답으로 커버. */
|
||||
private static final int MAX_LIMIT = 2000;
|
||||
|
||||
private final EventCatalogService service;
|
||||
private final EventAccessGuard guard;
|
||||
|
||||
public EventCatalogController(EventCatalogService service, EventAccessGuard guard) {
|
||||
this.service = service;
|
||||
this.guard = guard;
|
||||
}
|
||||
|
||||
/** GET /api/events?year=&status=ongoing|upcoming|ended — 필터 없으면 전체(시작일 내림차순). */
|
||||
@GetMapping
|
||||
public ApiResponse<List<ExhibitionDto>> list(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false, defaultValue = "0") int page,
|
||||
@RequestParam(required = false, defaultValue = "2000") int size) {
|
||||
guard.require(principal);
|
||||
int limit = Math.min(Math.max(size, 1), MAX_LIMIT);
|
||||
int offset = Math.max(page, 0) * limit;
|
||||
return ApiResponse.ok(service.list(year, status, limit, offset));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
package com.zioinfo.kintex.catalog;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 전시 일정 카탈로그 매퍼 — {@code event} 테이블 read-only 조회(V10 시드 1,098건).
|
||||
* <p>hallLabel 은 {@code hall_assignment}(대표 홀 우선) → {@code hall.label} 조인으로 enrich(없으면 null).
|
||||
* category·estVisitors 는 원천 컬럼 부재 → 항상 null(후속 트랙 enrich). 마이그레이션을 변경하지 않는다.
|
||||
*
|
||||
* <p>status 필터는 DB 원문(active/ended)이 계약 값(ongoing|upcoming|ended)과 다르므로 <b>날짜 기준</b>으로 판정한다
|
||||
* — 프론트의 재계산 규칙과 동일: ended=end<오늘, ongoing=시작≤오늘≤종료, upcoming=시작>오늘.
|
||||
*/
|
||||
@Mapper
|
||||
public interface EventCatalogMapper {
|
||||
|
||||
/** 카탈로그 조회 — year(시작연도)·status(날짜 기준) 선택 필터. 시작일 내림차순. */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT e.id,
|
||||
e.name,
|
||||
to_char(e.start_date, 'YYYY-MM-DD') AS startDate,
|
||||
to_char(e.end_date, 'YYYY-MM-DD') AS endDate,
|
||||
e.status,
|
||||
h.label AS hallLabel,
|
||||
CAST(NULL AS varchar) AS category,
|
||||
CAST(NULL AS integer) AS estVisitors
|
||||
FROM event e
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT ha.hall_id
|
||||
FROM hall_assignment ha
|
||||
WHERE ha.event_id = e.id
|
||||
ORDER BY ha.is_primary DESC
|
||||
LIMIT 1
|
||||
) pa ON true
|
||||
LEFT JOIN hall h ON h.id = pa.hall_id
|
||||
<where>
|
||||
<if test="year != null and year != ''">
|
||||
AND extract(year FROM e.start_date) = CAST(#{year} AS integer)
|
||||
</if>
|
||||
<if test="status == 'ended'">AND e.end_date < CURRENT_DATE</if>
|
||||
<if test="status == 'ongoing'">AND e.start_date <= CURRENT_DATE AND e.end_date >= CURRENT_DATE</if>
|
||||
<if test="status == 'upcoming'">AND e.start_date > CURRENT_DATE</if>
|
||||
</where>
|
||||
ORDER BY e.start_date DESC NULLS LAST, e.id DESC
|
||||
LIMIT #{limit} OFFSET #{offset}
|
||||
</script>
|
||||
""")
|
||||
List<Map<String, Object>> findEvents(@Param("year") String year,
|
||||
@Param("status") String status,
|
||||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
/** 필터 조건 총 건수(페이징 메타). */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT count(*) FROM event e
|
||||
<where>
|
||||
<if test="year != null and year != ''">
|
||||
AND extract(year FROM e.start_date) = CAST(#{year} AS integer)
|
||||
</if>
|
||||
<if test="status == 'ended'">AND e.end_date < CURRENT_DATE</if>
|
||||
<if test="status == 'ongoing'">AND e.start_date <= CURRENT_DATE AND e.end_date >= CURRENT_DATE</if>
|
||||
<if test="status == 'upcoming'">AND e.start_date > CURRENT_DATE</if>
|
||||
</where>
|
||||
</script>
|
||||
""")
|
||||
long countEvents(@Param("year") String year, @Param("status") String status);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package com.zioinfo.kintex.catalog;
|
||||
|
||||
import com.zioinfo.kintex.catalog.dto.ExhibitionDto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 전시 일정 카탈로그 서비스 — 전 홀 10년치 행사 목록(멤버십 무관 조회). */
|
||||
public interface EventCatalogService {
|
||||
|
||||
/** year(시작연도)·status(ongoing|upcoming|ended, 날짜 기준) 선택 필터. 시작일 내림차순. */
|
||||
List<ExhibitionDto> list(String year, String status, int limit, int offset);
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
package com.zioinfo.kintex.catalog;
|
||||
|
||||
import com.zioinfo.kintex.catalog.dto.ExhibitionDto;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 전시 일정 카탈로그 서비스 구현 — {@link EventCatalogMapper} 조회 결과를 DTO로 매핑. */
|
||||
@Service
|
||||
public class EventCatalogServiceImpl implements EventCatalogService {
|
||||
|
||||
private final EventCatalogMapper mapper;
|
||||
|
||||
public EventCatalogServiceImpl(EventCatalogMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ExhibitionDto> list(String year, String status, int limit, int offset) {
|
||||
List<Map<String, Object>> rows = mapper.findEvents(nullIfBlank(year), nullIfBlank(status), limit, offset);
|
||||
List<ExhibitionDto> out = new ArrayList<>(rows == null ? 0 : rows.size());
|
||||
if (rows != null) {
|
||||
for (Map<String, Object> r : rows) {
|
||||
out.add(new ExhibitionDto(
|
||||
str(r.get("id")), str(r.get("name")),
|
||||
str(r.get("startDate")), str(r.get("endDate")), str(r.get("status")),
|
||||
str(r.get("hallLabel")), str(r.get("category")), intOrNull(r.get("estVisitors"))));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String nullIfBlank(String s) {
|
||||
return s == null || s.isBlank() || "all".equalsIgnoreCase(s) ? null : s;
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
|
||||
private static Integer intOrNull(Object o) {
|
||||
if (o == null) return null;
|
||||
if (o instanceof Number n) return n.intValue();
|
||||
try {
|
||||
return Integer.valueOf(String.valueOf(o));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package com.zioinfo.kintex.catalog.dto;
|
||||
|
||||
/**
|
||||
* 전시 일정 카탈로그 항목 (SCR-15 / 06_backend_api_gaps §1).
|
||||
* 필수(DB 보유): id·name·startDate·endDate·status. 선택(enrich·nullable): hallLabel·category·estVisitors.
|
||||
* <p>status 는 {@code event.status} DB 원문(active/ended 등)을 그대로 반환 — 프론트가 날짜 기준 재계산한다.
|
||||
*/
|
||||
public record ExhibitionDto(
|
||||
String id,
|
||||
String name,
|
||||
String startDate,
|
||||
String endDate,
|
||||
String status,
|
||||
String hallLabel,
|
||||
String category,
|
||||
Integer estVisitors
|
||||
) {
|
||||
}
|
||||
@ -35,6 +35,7 @@ public class SecurityConfig {
|
||||
.requestMatchers("/health", "/api/auth/login", "/ws/**",
|
||||
"/api/internal/render/callback",
|
||||
"/api/auth/login/secure", "/api/auth/otp/verify",
|
||||
"/api/auth/otp/enroll/setup", "/api/auth/otp/enroll/verify",
|
||||
"/api/auth/register", "/api/auth/password/forgot",
|
||||
"/api/auth/password/reset",
|
||||
// 공개 콘텐츠(로그인 슬라이드 등) + 업로드 정적 파일
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
package com.zioinfo.kintex.dashboard;
|
||||
|
||||
import com.zioinfo.kintex.auth.EventAccessGuard;
|
||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||
import com.zioinfo.kintex.common.ApiResponse;
|
||||
import com.zioinfo.kintex.dashboard.dto.DashboardDto;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 주최자 대시보드 API (SCR-02). 행사 멤버/홀매니저 열람.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/events/{eventId}/dashboard")
|
||||
public class DashboardController {
|
||||
|
||||
private final DashboardService service;
|
||||
private final EventAccessGuard guard;
|
||||
|
||||
public DashboardController(DashboardService service, EventAccessGuard guard) {
|
||||
this.service = service;
|
||||
this.guard = guard;
|
||||
}
|
||||
|
||||
/** GET — 마일스톤·KPI·참가업체·활동 피드 집계. */
|
||||
@GetMapping
|
||||
public ApiResponse<DashboardDto> get(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId) {
|
||||
guard.requireEventAccess(principal, eventId);
|
||||
return ApiResponse.ok(service.getDashboard(eventId));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
package com.zioinfo.kintex.dashboard;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 주최자 대시보드 집계 매퍼 — event·booth·design_plan·utility_order·render_job read-only.
|
||||
* booth 는 {@code layout.event_id} 경유로 행사에 귀속된다. 마이그레이션 불변.
|
||||
*/
|
||||
@Mapper
|
||||
public interface DashboardMapper {
|
||||
|
||||
/** 행사 헤더(마일스톤 파생용 시작/종료일). 없으면 null. */
|
||||
@Select("""
|
||||
SELECT to_char(start_date, 'YYYY-MM-DD') AS startDate,
|
||||
to_char(end_date, 'YYYY-MM-DD') AS endDate,
|
||||
name
|
||||
FROM event WHERE id = #{eventId}
|
||||
""")
|
||||
Map<String, Object> findEventHeader(@Param("eventId") String eventId);
|
||||
|
||||
/** KPI 원자 집계(단일 행) — 부스 수·목표 부스·설계 승인·유틸리티 신청·렌더 완료. */
|
||||
@Select("""
|
||||
SELECT
|
||||
(SELECT count(*) FROM booth b JOIN layout l ON l.id = b.layout_id
|
||||
WHERE l.event_id = #{eventId}) AS boothCount,
|
||||
(SELECT COALESCE(sum(h.booth_capacity), 0) FROM hall_assignment ha
|
||||
JOIN hall h ON h.id = ha.hall_id WHERE ha.event_id = #{eventId}) AS targetBooths,
|
||||
(SELECT count(*) FROM booth b JOIN layout l ON l.id = b.layout_id
|
||||
JOIN design_plan dp ON dp.booth_id = b.id
|
||||
WHERE l.event_id = #{eventId} AND dp.status = 'approved') AS designApproved,
|
||||
(SELECT count(*) FROM design_plan dp JOIN booth b ON b.id = dp.booth_id
|
||||
JOIN layout l ON l.id = b.layout_id WHERE l.event_id = #{eventId}) AS designTotal,
|
||||
(SELECT count(*) FROM utility_order u WHERE u.event_id = #{eventId}) AS utilityOrders,
|
||||
(SELECT count(*) FROM render_job r WHERE r.event_id = #{eventId} AND r.status = 'DONE') AS renderDone
|
||||
""")
|
||||
Map<String, Object> findKpiCounts(@Param("eventId") String eventId);
|
||||
|
||||
/** 참가업체 부스 현황(최신 설계안 상태 조인). 최대 100건. */
|
||||
@Select("""
|
||||
SELECT b.booth_no AS boothNo,
|
||||
b.assigned_company_name AS companyName,
|
||||
COALESCE(dp.status, 'draft') AS status
|
||||
FROM booth b
|
||||
JOIN layout l ON l.id = b.layout_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT status FROM design_plan d
|
||||
WHERE d.booth_id = b.id ORDER BY version DESC LIMIT 1
|
||||
) dp ON true
|
||||
WHERE l.event_id = #{eventId}
|
||||
ORDER BY b.booth_no NULLS LAST
|
||||
LIMIT 100
|
||||
""")
|
||||
List<Map<String, Object>> findExhibitors(@Param("eventId") String eventId);
|
||||
|
||||
/** 활동 피드 — 렌더잡 최근 이력(승인/위반 원천 부재 시 render 만). 최대 12건. */
|
||||
@Select("""
|
||||
SELECT r.id,
|
||||
r.status,
|
||||
r.shot_preset AS shotPreset,
|
||||
r.booth_id AS boothId,
|
||||
to_char(r.updated_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS at
|
||||
FROM render_job r
|
||||
WHERE r.event_id = #{eventId}
|
||||
ORDER BY r.updated_at DESC
|
||||
LIMIT 12
|
||||
""")
|
||||
List<Map<String, Object>> findRecentRenders(@Param("eventId") String eventId);
|
||||
}
|
||||
@ -0,0 +1,155 @@
|
||||
package com.zioinfo.kintex.dashboard;
|
||||
|
||||
import com.zioinfo.kintex.common.error.ApiException;
|
||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||
import com.zioinfo.kintex.dashboard.dto.DashboardDto;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 주최자 대시보드 서비스 — 실 테이블 집계로 KPI·참가업체·피드를 조립.
|
||||
* 마일스톤은 행사 시작일 기준 표준 일정을 파생(state 는 오늘 기준 판정) — 원천 값이 아닌 파생 스케줄.
|
||||
*/
|
||||
@Service
|
||||
public class DashboardService {
|
||||
|
||||
/** 시작일 기준 상대 마일스톤(라벨, 시작일로부터 -일수). 개장은 0. */
|
||||
private static final Object[][] MILESTONE_TEMPLATE = {
|
||||
{"홀 배정", 150},
|
||||
{"부스 배치 확정", 60},
|
||||
{"유틸리티 신청 마감", 25},
|
||||
{"신고서류 제출", 7},
|
||||
{"개장", 0},
|
||||
};
|
||||
|
||||
private final DashboardMapper mapper;
|
||||
|
||||
public DashboardService(DashboardMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public DashboardDto getDashboard(String eventId) {
|
||||
Map<String, Object> header = mapper.findEventHeader(eventId);
|
||||
if (header == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "행사를 찾을 수 없습니다.");
|
||||
}
|
||||
return new DashboardDto(
|
||||
buildMilestones(str(header.get("startDate"))),
|
||||
buildKpis(mapper.findKpiCounts(eventId)),
|
||||
buildExhibitors(mapper.findExhibitors(eventId)),
|
||||
buildFeed(mapper.findRecentRenders(eventId)));
|
||||
}
|
||||
|
||||
/** 행사 시작일에서 상대 일정을 파생. 시작일이 없으면 빈 목록(정직). */
|
||||
private List<DashboardDto.Milestone> buildMilestones(String startDate) {
|
||||
List<DashboardDto.Milestone> out = new ArrayList<>();
|
||||
LocalDate start = parseDate(startDate);
|
||||
if (start == null) {
|
||||
return out;
|
||||
}
|
||||
LocalDate today = LocalDate.now();
|
||||
for (Object[] tpl : MILESTONE_TEMPLATE) {
|
||||
String label = (String) tpl[0];
|
||||
int daysBefore = (int) tpl[1];
|
||||
LocalDate date = start.minusDays(daysBefore);
|
||||
String state;
|
||||
if (date.isBefore(today)) {
|
||||
state = "done";
|
||||
} else if (date.isEqual(today)) {
|
||||
state = "current";
|
||||
} else {
|
||||
state = "upcoming";
|
||||
}
|
||||
out.add(new DashboardDto.Milestone(label, date.toString(), state));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private List<DashboardDto.Kpi> buildKpis(Map<String, Object> c) {
|
||||
List<DashboardDto.Kpi> out = new ArrayList<>();
|
||||
if (c == null) {
|
||||
return out;
|
||||
}
|
||||
long boothCount = lng(c.get("boothCount"));
|
||||
long targetBooths = lng(c.get("targetBooths"));
|
||||
long designApproved = lng(c.get("designApproved"));
|
||||
long designTotal = lng(c.get("designTotal"));
|
||||
long utilityOrders = lng(c.get("utilityOrders"));
|
||||
long renderDone = lng(c.get("renderDone"));
|
||||
|
||||
String boothValue = targetBooths > 0 ? boothCount + "/" + targetBooths : String.valueOf(boothCount);
|
||||
out.add(new DashboardDto.Kpi("부스 배치", boothValue, null, "flat"));
|
||||
out.add(new DashboardDto.Kpi("설계 승인", designApproved + "/" + designTotal, null, "flat"));
|
||||
out.add(new DashboardDto.Kpi("유틸리티 신청", String.valueOf(utilityOrders), null, "flat"));
|
||||
out.add(new DashboardDto.Kpi("렌더 완료", String.valueOf(renderDone), null, "flat"));
|
||||
return out;
|
||||
}
|
||||
|
||||
private List<DashboardDto.Exhibitor> buildExhibitors(List<Map<String, Object>> rows) {
|
||||
List<DashboardDto.Exhibitor> out = new ArrayList<>();
|
||||
if (rows == null) {
|
||||
return out;
|
||||
}
|
||||
for (Map<String, Object> r : rows) {
|
||||
out.add(new DashboardDto.Exhibitor(
|
||||
str(r.get("companyName")), str(r.get("boothNo")), str(r.get("status"))));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private List<DashboardDto.Feed> buildFeed(List<Map<String, Object>> rows) {
|
||||
List<DashboardDto.Feed> out = new ArrayList<>();
|
||||
if (rows == null) {
|
||||
return out;
|
||||
}
|
||||
for (Map<String, Object> r : rows) {
|
||||
String status = str(r.get("status"));
|
||||
String preset = str(r.get("shotPreset"));
|
||||
String message = "이미지 생성(" + preset + ") " + renderStatusLabel(status);
|
||||
out.add(new DashboardDto.Feed(str(r.get("id")), "render", message, str(r.get("at"))));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String renderStatusLabel(String status) {
|
||||
if (status == null) {
|
||||
return "";
|
||||
}
|
||||
return switch (status) {
|
||||
case "DONE" -> "완료";
|
||||
case "FAILED" -> "실패";
|
||||
case "RUNNING" -> "진행 중";
|
||||
default -> "대기";
|
||||
};
|
||||
}
|
||||
|
||||
private static LocalDate parseDate(String s) {
|
||||
if (s == null || s.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(s);
|
||||
} catch (DateTimeParseException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static long lng(Object o) {
|
||||
if (o == null) return 0L;
|
||||
if (o instanceof Number n) return n.longValue();
|
||||
try {
|
||||
return Long.parseLong(String.valueOf(o));
|
||||
} catch (NumberFormatException e) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package com.zioinfo.kintex.dashboard.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 주최자 대시보드 집계 (SCR-02 / 06_backend_api_gaps §2).
|
||||
* 집계원: event·booth(layout)·design_plan·utility_order·render_job. 데이터 없으면 0/빈배열(정직 반환).
|
||||
*/
|
||||
public record DashboardDto(
|
||||
List<Milestone> milestones,
|
||||
List<Kpi> kpis,
|
||||
List<Exhibitor> exhibitors,
|
||||
List<Feed> feed
|
||||
) {
|
||||
/** 마일스톤 — 행사 시작일 기준 파생 일정(state 는 오늘 기준 판정). */
|
||||
public record Milestone(String label, String date, String state) {
|
||||
}
|
||||
|
||||
/** KPI — 이력 부재 시 delta=null·trend=flat. */
|
||||
public record Kpi(String label, String value, String delta, String trend) {
|
||||
}
|
||||
|
||||
/** 참가업체 부스 현황(booth + 최신 설계안 상태). */
|
||||
public record Exhibitor(String companyName, String boothNo, String status) {
|
||||
}
|
||||
|
||||
/** 활동 피드(렌더/승인/위반) — 현재 render_job 이력 원천. */
|
||||
public record Feed(String id, String kind, String message, String at) {
|
||||
}
|
||||
}
|
||||
@ -150,6 +150,9 @@ public class FloorplanServiceImpl implements FloorplanService {
|
||||
Integer exitsBlocked = boothMapper.countExitsBlocked(layoutId, hallId);
|
||||
metrics.put("layout.exits_blocked_count", exitsBlocked == null ? 0 : exitsBlocked);
|
||||
|
||||
Integer overlaps = boothMapper.countOverlappingBooths(layoutId);
|
||||
metrics.put("layout.overlap_count", overlaps == null ? 0 : overlaps);
|
||||
|
||||
BoothDto maxHeight = maxBy(booths, BoothDto::heightM);
|
||||
if (maxHeight != null && maxHeight.heightM() != null) {
|
||||
metrics.put("booth.height_m", maxHeight.heightM());
|
||||
|
||||
@ -38,4 +38,10 @@ public interface BoothMapper {
|
||||
/** 비상구를 차단하는 부스 수 — ST_Intersects(booth, exit_buffer). */
|
||||
Integer countExitsBlocked(@Param("layoutId") String layoutId,
|
||||
@Param("hallId") String hallId);
|
||||
|
||||
/**
|
||||
* 폴리곤 내부가 겹치는 부스 쌍의 수 — 부스는 서로 겹칠 수 없다(배치 무결성).
|
||||
* ST_Overlaps(경계 교차)뿐 아니라 포함·동일도 무효이므로 "내부 교차(ST_Intersects AND NOT ST_Touches)"로 판정한다.
|
||||
*/
|
||||
Integer countOverlappingBooths(@Param("layoutId") String layoutId);
|
||||
}
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
package com.zioinfo.kintex.ops;
|
||||
|
||||
import com.zioinfo.kintex.auth.EventAccessGuard;
|
||||
import com.zioinfo.kintex.auth.EventRole;
|
||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||
import com.zioinfo.kintex.common.ApiResponse;
|
||||
import com.zioinfo.kintex.ops.dto.OpsDto;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 홀 현장 운영 API (SCR-16). 홀매니저/주최자 열람.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/events/{eventId}/ops")
|
||||
public class OpsController {
|
||||
|
||||
private final OpsService service;
|
||||
private final EventAccessGuard guard;
|
||||
|
||||
public OpsController(OpsService service, EventAccessGuard guard) {
|
||||
this.service = service;
|
||||
this.guard = guard;
|
||||
}
|
||||
|
||||
/** GET — 홀 혼잡·HVAC·조명·주차 현황(센서 미연동 지표는 0/null). */
|
||||
@GetMapping
|
||||
public ApiResponse<OpsDto> get(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId) {
|
||||
guard.requireRole(principal, eventId, EventRole.ORGANIZER, EventRole.HALL_MANAGER);
|
||||
return ApiResponse.ok(service.getOps(eventId));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package com.zioinfo.kintex.ops;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 홀 현장 운영 매퍼 — hall_assignment·hall read-only(행사에 배정된 홀 목록).
|
||||
* 혼잡/HVAC/조명/주차 센서 원천이 없으므로 홀 목록만 조회한다. 마이그레이션 불변.
|
||||
*/
|
||||
@Mapper
|
||||
public interface OpsMapper {
|
||||
|
||||
/** 행사 배정 홀 목록(대표 홀 우선 정렬). */
|
||||
@Select("""
|
||||
SELECT h.id AS hallId, h.label
|
||||
FROM hall_assignment ha
|
||||
JOIN hall h ON h.id = ha.hall_id
|
||||
WHERE ha.event_id = #{eventId}
|
||||
ORDER BY ha.is_primary DESC, h.id
|
||||
""")
|
||||
List<Map<String, Object>> findAssignedHalls(@Param("eventId") String eventId);
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package com.zioinfo.kintex.ops;
|
||||
|
||||
import com.zioinfo.kintex.ops.dto.OpsDto;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 홀 현장 운영 서비스 — 배정 홀 목록만 실 조회. 혼잡/HVAC/조명/주차는 센서 부재 → 0/null/빈배열(정직).
|
||||
*/
|
||||
@Service
|
||||
public class OpsService {
|
||||
|
||||
private final OpsMapper mapper;
|
||||
|
||||
public OpsService(OpsMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public OpsDto getOps(String eventId) {
|
||||
List<OpsDto.Hall> halls = new ArrayList<>();
|
||||
List<Map<String, Object>> rows = mapper.findAssignedHalls(eventId);
|
||||
if (rows != null) {
|
||||
for (Map<String, Object> r : rows) {
|
||||
// 혼잡 센서(M14) 미연동 → estPeople=0, heat=smooth.
|
||||
halls.add(new OpsDto.Hall(str(r.get("hallId")), str(r.get("label")), 0L, "smooth"));
|
||||
}
|
||||
}
|
||||
// HVAC/조명/주차 센서·IoT 미연동 → null/빈배열.
|
||||
OpsDto.Hvac hvac = new OpsDto.Hvac(null, null, null);
|
||||
return new OpsDto(halls, hvac, new ArrayList<>(), new ArrayList<>());
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package com.zioinfo.kintex.ops.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 홀 현장 운영 현황 (SCR-16 / 06_backend_api_gaps §5).
|
||||
* 집계원: hall_assignment·hall(실 홀 목록). 혼잡/HVAC/조명/주차는 센서·IoT 미연동(PLANNING M14) → 0/null/빈배열(정직).
|
||||
* <p>하드웨어 연동 코드 없음 — 표시 전용. 실시간성 필요 시 WebSocket /topic/ops/{eventId} 확장 검토(현재 폴링 GET).
|
||||
*/
|
||||
public record OpsDto(
|
||||
List<Hall> halls,
|
||||
Hvac hvac,
|
||||
List<LightingZone> lighting,
|
||||
List<ParkingZone> parking
|
||||
) {
|
||||
/** 홀 혼잡(estPeople·heat) — 센서 부재 시 0/smooth. */
|
||||
public record Hall(String hallId, String label, long estPeople, String heat) {
|
||||
}
|
||||
|
||||
/** 공조 계측(센서 부재 시 null). */
|
||||
public record Hvac(Double tempC, Integer humidityPercent, Integer co2Ppm) {
|
||||
}
|
||||
|
||||
/** 조명 존 점등률. */
|
||||
public record LightingZone(String zone, int onPercent) {
|
||||
}
|
||||
|
||||
/** 주차 존 점유·수용. */
|
||||
public record ParkingZone(String zone, int occupancy, int capacity) {
|
||||
}
|
||||
}
|
||||
@ -37,12 +37,13 @@ public interface AccountSecurityMapper {
|
||||
+ "verify_method = 'OTP', updated_at = now() WHERE id = #{userId}")
|
||||
int saveOtpSecret(@Param("userId") String userId, @Param("secret") String secret);
|
||||
|
||||
/** OTP 활성(코드 확인 완료). */
|
||||
@Update("UPDATE app_user SET otp_enabled = true, updated_at = now() WHERE id = #{userId}")
|
||||
/** OTP 활성(코드 확인 완료) — 최초 활성 시각 기록. */
|
||||
@Update("UPDATE app_user SET otp_enabled = true, verify_method = 'OTP', "
|
||||
+ "otp_verified_at = COALESCE(otp_verified_at, now()), updated_at = now() WHERE id = #{userId}")
|
||||
int enableOtp(@Param("userId") String userId);
|
||||
|
||||
/** OTP 초기화(비활성 + 시크릿 폐기) — 본인 해제/관리자 리셋. */
|
||||
@Update("UPDATE app_user SET otp_secret = NULL, otp_enabled = false, "
|
||||
/** OTP 초기화(비활성 + 시크릿 폐기 + 검증시각 초기화) — 본인 해제/관리자 리셋. */
|
||||
@Update("UPDATE app_user SET otp_secret = NULL, otp_enabled = false, otp_verified_at = NULL, "
|
||||
+ "verify_method = 'EMAIL', updated_at = now() WHERE id = #{userId}")
|
||||
int resetOtp(@Param("userId") String userId);
|
||||
|
||||
|
||||
@ -31,6 +31,18 @@ public class OtpChallengeStore {
|
||||
return token;
|
||||
}
|
||||
|
||||
/** 토큰 조회(비소비) → userId. 만료/무효면 null. 등록(enroll) setup 단계에서 사용. */
|
||||
public String peek(String token) {
|
||||
if (token == null) {
|
||||
return null;
|
||||
}
|
||||
Entry e = store.get(token);
|
||||
if (e == null || e.expiresAt() < Instant.now().toEpochMilli()) {
|
||||
return null;
|
||||
}
|
||||
return e.userId();
|
||||
}
|
||||
|
||||
/** 토큰 소비(1회성) → userId. 만료/무효면 null. */
|
||||
public String consume(String token) {
|
||||
if (token == null) {
|
||||
|
||||
@ -0,0 +1,97 @@
|
||||
package com.zioinfo.kintex.security;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* OTP 시크릿 저장용 AES-256-GCM 봉인/해제(계약 §0-3: otp_secret 암호화 저장 필수).
|
||||
* <p>포맷 {@code enc:v1:base64(iv(12) || ciphertext || tag(16))}. 접두어가 없으면 평문으로 간주해
|
||||
* 그대로 반환한다(암호화 도입 이전 시크릿과의 하위호환·점진 이관). 키는 env 주입만
|
||||
* ({@code KINTEX_OTP_ENC_KEY}, base64 32바이트). 미주입 시 개발 전용 파생키를 쓰되 경고 로그를 남긴다.
|
||||
* 키·평문 시크릿은 로그/응답에 절대 남기지 않는다.
|
||||
*/
|
||||
@Component
|
||||
public class SecretCipher {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SecretCipher.class);
|
||||
private static final String PREFIX = "enc:v1:";
|
||||
private static final String TRANSFORM = "AES/GCM/NoPadding";
|
||||
private static final int IV_LEN = 12;
|
||||
private static final int TAG_BITS = 128;
|
||||
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
private final SecretKeySpec key;
|
||||
|
||||
public SecretCipher(SecurityPolicyProperties props) {
|
||||
this.key = resolveKey(props.getOtp().getEncKey());
|
||||
}
|
||||
|
||||
/** 평문 시크릿을 봉인(enc:v1:...). null/blank 는 그대로 반환. */
|
||||
public String seal(String plain) {
|
||||
if (plain == null || plain.isBlank()) {
|
||||
return plain;
|
||||
}
|
||||
try {
|
||||
byte[] iv = new byte[IV_LEN];
|
||||
random.nextBytes(iv);
|
||||
Cipher cipher = Cipher.getInstance(TRANSFORM);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
|
||||
byte[] ct = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] out = new byte[iv.length + ct.length];
|
||||
System.arraycopy(iv, 0, out, 0, iv.length);
|
||||
System.arraycopy(ct, 0, out, iv.length, ct.length);
|
||||
return PREFIX + Base64.getEncoder().encodeToString(out);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("OTP 시크릿 암호화 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 봉인 시크릿을 해제. 접두어가 없으면 평문으로 간주(하위호환). null 은 null. */
|
||||
public String open(String stored) {
|
||||
if (stored == null || stored.isBlank()) {
|
||||
return stored;
|
||||
}
|
||||
if (!stored.startsWith(PREFIX)) {
|
||||
return stored; // 이관 이전 평문
|
||||
}
|
||||
try {
|
||||
byte[] raw = Base64.getDecoder().decode(stored.substring(PREFIX.length()));
|
||||
byte[] iv = new byte[IV_LEN];
|
||||
System.arraycopy(raw, 0, iv, 0, IV_LEN);
|
||||
Cipher cipher = Cipher.getInstance(TRANSFORM);
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
|
||||
byte[] pt = cipher.doFinal(raw, IV_LEN, raw.length - IV_LEN);
|
||||
return new String(pt, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("OTP 시크릿 복호화 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
private SecretKeySpec resolveKey(String configured) {
|
||||
if (configured != null && !configured.isBlank()) {
|
||||
byte[] k = Base64.getDecoder().decode(configured.trim());
|
||||
if (k.length != 32) {
|
||||
throw new IllegalStateException("KINTEX_OTP_ENC_KEY 는 base64 32바이트(AES-256)여야 합니다.");
|
||||
}
|
||||
return new SecretKeySpec(k, "AES");
|
||||
}
|
||||
// 개발 전용 파생키 — 운영에서는 반드시 env 주입. 값은 로그에 남기지 않는다.
|
||||
log.warn("KINTEX_OTP_ENC_KEY 미주입 → 개발 전용 파생키 사용(운영 배포 전 반드시 주입 필요)");
|
||||
try {
|
||||
byte[] k = MessageDigest.getInstance("SHA-256")
|
||||
.digest("kintex-dev-otp-key-do-not-use-in-prod".getBytes(StandardCharsets.UTF_8));
|
||||
return new SecretKeySpec(k, "AES");
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("개발 파생키 생성 실패", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -31,8 +31,16 @@ public class SecurityPolicyProperties {
|
||||
public static class Otp {
|
||||
/** TOTP 발급자 표기(Authenticator 앱 라벨). */
|
||||
private String issuer = "KINTEX";
|
||||
/** OTP 시크릿 암호화 키(base64 32바이트, AES-256). env 주입만 — 미주입 시 개발 파생키. */
|
||||
private String encKey;
|
||||
/** 필수 역할(관람객 외)에 OTP 등록·검증 강제. 운영 true 권장, 데모 편의 시 false. */
|
||||
private boolean enforce = true;
|
||||
public String getIssuer() { return issuer; }
|
||||
public void setIssuer(String v) { this.issuer = v; }
|
||||
public String getEncKey() { return encKey; }
|
||||
public void setEncKey(String v) { this.encKey = v; }
|
||||
public boolean isEnforce() { return enforce; }
|
||||
public void setEnforce(boolean v) { this.enforce = v; }
|
||||
}
|
||||
|
||||
public static class AdminSeed {
|
||||
|
||||
@ -5,6 +5,7 @@ import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||
import com.zioinfo.kintex.auth.dto.LoginResponse;
|
||||
import com.zioinfo.kintex.common.ApiResponse;
|
||||
import com.zioinfo.kintex.security.dto.OtpConfirmRequest;
|
||||
import com.zioinfo.kintex.security.dto.OtpEnrollRequest;
|
||||
import com.zioinfo.kintex.security.dto.OtpSetupResponse;
|
||||
import com.zioinfo.kintex.security.dto.OtpStatusResponse;
|
||||
import com.zioinfo.kintex.security.dto.OtpVerifyRequest;
|
||||
@ -40,12 +41,24 @@ public class TwoFactorController {
|
||||
return ApiResponse.ok(service.secureLogin(req.email(), req.password()));
|
||||
}
|
||||
|
||||
/** 공개 — 2단계 OTP 검증 → 최종 토큰. */
|
||||
/** 공개 — 2단계 OTP 검증(등록 사용자) → 최종 토큰. */
|
||||
@PostMapping("/otp/verify")
|
||||
public ApiResponse<LoginResponse> verifyOtp(@Valid @RequestBody OtpVerifyRequest req) {
|
||||
return ApiResponse.ok(service.verifyOtp(req.challengeToken(), req.code()));
|
||||
}
|
||||
|
||||
/** 공개(프리오스) — 필수 역할 등록 강제: setup(시크릿·QR 발급). challengeToken 필요. */
|
||||
@PostMapping("/otp/enroll/setup")
|
||||
public ApiResponse<OtpSetupResponse> enrollSetup(@Valid @RequestBody OtpEnrollRequest req) {
|
||||
return ApiResponse.ok(service.enrollSetup(req.challengeToken()));
|
||||
}
|
||||
|
||||
/** 공개(프리오스) — 필수 역할 등록 강제: verify(코드 확인·활성화) → 최종 토큰. */
|
||||
@PostMapping("/otp/enroll/verify")
|
||||
public ApiResponse<LoginResponse> enrollVerify(@Valid @RequestBody OtpVerifyRequest req) {
|
||||
return ApiResponse.ok(service.enrollVerify(req.challengeToken(), req.code()));
|
||||
}
|
||||
|
||||
/** 인증 — OTP 등록 시작(시크릿·QR 발급). */
|
||||
@PostMapping("/otp/setup")
|
||||
public ApiResponse<OtpSetupResponse> setup(@AuthenticationPrincipal KintexPrincipal principal) {
|
||||
|
||||
@ -34,11 +34,14 @@ public class TwoFactorService {
|
||||
private final TotpService totpService;
|
||||
private final LoginAttemptService loginAttempts;
|
||||
private final OtpChallengeStore challengeStore;
|
||||
private final SecretCipher secretCipher;
|
||||
private final SecurityPolicyProperties props;
|
||||
|
||||
public TwoFactorService(AccountSecurityMapper securityMapper, UserMapper userMapper,
|
||||
JwtService jwtService, PasswordEncoder passwordEncoder,
|
||||
TotpService totpService, LoginAttemptService loginAttempts,
|
||||
OtpChallengeStore challengeStore) {
|
||||
OtpChallengeStore challengeStore, SecretCipher secretCipher,
|
||||
SecurityPolicyProperties props) {
|
||||
this.securityMapper = securityMapper;
|
||||
this.userMapper = userMapper;
|
||||
this.jwtService = jwtService;
|
||||
@ -46,9 +49,11 @@ public class TwoFactorService {
|
||||
this.totpService = totpService;
|
||||
this.loginAttempts = loginAttempts;
|
||||
this.challengeStore = challengeStore;
|
||||
this.secretCipher = secretCipher;
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
/** 1단계: 잠금 확인 → 자격검증 → (OTP면 챌린지, 아니면 토큰). */
|
||||
/** 1단계: 잠금 확인 → 자격검증 → (OTP등록자=챌린지, 필수역할 미등록=등록강제, 그 외=토큰). */
|
||||
public SecureLoginResponse secureLogin(String email, String rawPassword) {
|
||||
Map<String, Object> row = securityMapper.findAuthSecurityByEmail(email);
|
||||
loginAttempts.assertNotLocked(row);
|
||||
@ -59,11 +64,16 @@ public class TwoFactorService {
|
||||
}
|
||||
String userId = str(row.get("userId"));
|
||||
loginAttempts.recordSuccess(userId);
|
||||
boolean hallManager = bool(row.get("hallManager"));
|
||||
if (Boolean.TRUE.equals(bool(row.get("otpEnabled")))) {
|
||||
return SecureLoginResponse.otpRequired(challengeStore.issue(userId));
|
||||
}
|
||||
return SecureLoginResponse.ok(buildLoginResponse(userId,
|
||||
str(row.get("displayName")), bool(row.get("hallManager"))));
|
||||
// 미등록 — 필수 역할이면 등록 강제(프리오스 챌린지), 관람객 등은 선택(즉시 토큰).
|
||||
if (props.getOtp().isEnforce()
|
||||
&& requiresOtp(userId, hallManager, str(row.get("roleCode")))) {
|
||||
return SecureLoginResponse.otpEnroll(challengeStore.issue(userId));
|
||||
}
|
||||
return SecureLoginResponse.ok(buildLoginResponse(userId, str(row.get("displayName")), hallManager));
|
||||
}
|
||||
|
||||
/** 2단계: 챌린지 토큰 + OTP 코드 검증 → 최종 로그인 토큰. */
|
||||
@ -73,17 +83,42 @@ public class TwoFactorService {
|
||||
throw new ApiException(ErrorCode.OTP_REQUIRED, "2차 인증 세션이 만료되었습니다. 다시 로그인해 주세요.");
|
||||
}
|
||||
Map<String, Object> sec = securityMapper.findSecurityById(userId);
|
||||
if (sec == null || !totpService.verify(str(sec.get("otpSecret")), code)) {
|
||||
if (sec == null || !totpService.verify(secretCipher.open(str(sec.get("otpSecret"))), code)) {
|
||||
throw new ApiException(ErrorCode.OTP_INVALID);
|
||||
}
|
||||
return buildLoginResponse(userId, str(sec.get("displayName")), bool(sec.get("hallManager")));
|
||||
}
|
||||
|
||||
/** OTP 등록 시작 — 시크릿 발급·QR 반환(미활성, confirm 필요). */
|
||||
/** 로그인 중 등록 강제 — setup 단계(프리오스 챌린지 유지). 시크릿 발급·QR 반환. */
|
||||
public OtpSetupResponse enrollSetup(String challengeToken) {
|
||||
String userId = challengeStore.peek(challengeToken);
|
||||
if (userId == null) {
|
||||
throw new ApiException(ErrorCode.OTP_REQUIRED, "2차 인증 세션이 만료되었습니다. 다시 로그인해 주세요.");
|
||||
}
|
||||
Map<String, Object> sec = securityMapper.findSecurityById(userId);
|
||||
String label = sec != null ? str(sec.get("email")) : userId;
|
||||
return setupOtp(userId, label != null ? label : userId);
|
||||
}
|
||||
|
||||
/** 로그인 중 등록 강제 — verify 단계(챌린지 소비·활성화 후 최종 토큰). */
|
||||
public LoginResponse enrollVerify(String challengeToken, String code) {
|
||||
String userId = challengeStore.consume(challengeToken);
|
||||
if (userId == null) {
|
||||
throw new ApiException(ErrorCode.OTP_REQUIRED, "2차 인증 세션이 만료되었습니다. 다시 로그인해 주세요.");
|
||||
}
|
||||
Map<String, Object> sec = securityMapper.findSecurityById(userId);
|
||||
if (sec == null || !totpService.verify(secretCipher.open(str(sec.get("otpSecret"))), code)) {
|
||||
throw new ApiException(ErrorCode.OTP_INVALID);
|
||||
}
|
||||
securityMapper.enableOtp(userId);
|
||||
return buildLoginResponse(userId, str(sec.get("displayName")), bool(sec.get("hallManager")));
|
||||
}
|
||||
|
||||
/** OTP 등록 시작 — 시크릿 발급(봉인 저장)·QR 반환(미활성, confirm 필요). */
|
||||
public OtpSetupResponse setupOtp(String userId, String userLabel) {
|
||||
String secret = totpService.generateSecret();
|
||||
securityMapper.saveOtpSecret(userId, secret);
|
||||
return new OtpSetupResponse(secret,
|
||||
securityMapper.saveOtpSecret(userId, secretCipher.seal(secret)); // 저장은 암호문
|
||||
return new OtpSetupResponse(secret, // 화면 1회 표기는 평문
|
||||
totpService.otpAuthUri(secret, userLabel),
|
||||
totpService.qrImageDataUri(secret, userLabel));
|
||||
}
|
||||
@ -91,12 +126,30 @@ public class TwoFactorService {
|
||||
/** OTP 등록 확인 — 코드 검증 후 활성화. */
|
||||
public void confirmOtp(String userId, String code) {
|
||||
Map<String, Object> sec = securityMapper.findSecurityById(userId);
|
||||
if (sec == null || !totpService.verify(str(sec.get("otpSecret")), code)) {
|
||||
if (sec == null || !totpService.verify(secretCipher.open(str(sec.get("otpSecret"))), code)) {
|
||||
throw new ApiException(ErrorCode.OTP_INVALID);
|
||||
}
|
||||
securityMapper.enableOtp(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 필수 역할 판정(계약 §5B): 홀매니저·플랫폼 ADMIN/MANAGER·행사 역할 보유자(주최자/참가/장치)는 OTP 필수,
|
||||
* 순수 관람객(행사 역할 없음)만 선택. 행사 역할 유무는 event_member 로 판정.
|
||||
*/
|
||||
private boolean requiresOtp(String userId, boolean hallManager, String roleCode) {
|
||||
if (hallManager) {
|
||||
return true;
|
||||
}
|
||||
if (roleCode != null) {
|
||||
String r = roleCode.toUpperCase();
|
||||
if (r.equals("ADMIN") || r.equals("MANAGER")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
List<Map<String, Object>> roleRows = userMapper.findEventRoles(userId);
|
||||
return roleRows != null && !roleRows.isEmpty();
|
||||
}
|
||||
|
||||
/** OTP 초기화(본인 해제 또는 관리자 리셋). */
|
||||
public void resetOtp(String userId) {
|
||||
securityMapper.resetOtp(userId);
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
package com.zioinfo.kintex.security.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/** OTP 등록 강제 흐름(로그인 중) — setup 단계 요청. 프리오스 challengeToken 만 필요. */
|
||||
public record OtpEnrollRequest(@NotBlank String challengeToken) {
|
||||
}
|
||||
@ -4,8 +4,11 @@ import com.zioinfo.kintex.auth.dto.LoginResponse;
|
||||
|
||||
/**
|
||||
* 2FA 로그인 1단계 응답. status:
|
||||
* - "OK" → OTP 미사용. {@code login}에 최종 토큰/워크스페이스.
|
||||
* - "OTP_REQUIRED" → 2차 인증 필요. {@code challengeToken}으로 /otp/verify 진행.
|
||||
* - "OK" → OTP 미사용(관람객 등). {@code login}에 최종 토큰/워크스페이스.
|
||||
* - "OTP_REQUIRED" → OTP 등록 사용자. {@code challengeToken}으로 /otp/verify 진행(2단계 코드).
|
||||
* - "OTP_ENROLL" → 필수 역할이나 OTP 미등록 → 등록 강제. {@code challengeToken}으로
|
||||
* /otp/enroll/setup → /otp/enroll/verify 진행 후 최종 토큰.
|
||||
* challengeToken 은 짧은 수명(5분)·1회성 프리오스(pre-auth) 토큰(정식 JWT 아님).
|
||||
*/
|
||||
public record SecureLoginResponse(String status, String challengeToken, LoginResponse login) {
|
||||
|
||||
@ -16,4 +19,8 @@ public record SecureLoginResponse(String status, String challengeToken, LoginRes
|
||||
public static SecureLoginResponse otpRequired(String challengeToken) {
|
||||
return new SecureLoginResponse("OTP_REQUIRED", challengeToken, null);
|
||||
}
|
||||
|
||||
public static SecureLoginResponse otpEnroll(String challengeToken) {
|
||||
return new SecureLoginResponse("OTP_ENROLL", challengeToken, null);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,9 @@
|
||||
# - DB_URL / DB_USER / DB_PASSWORD : PostgreSQL(PostGIS) 접속
|
||||
# - REDIS_HOST / REDIS_PORT : Redis 작업 큐
|
||||
# - JWT_SECRET : JWT 서명 키(HS256, 최소 32바이트)
|
||||
# - KINTEX_OTP_ENC_KEY : OTP 시크릿 AES-256-GCM 키(base64 32바이트) — 운영 주입 필수
|
||||
# - OTP_ENFORCE : 필수 역할 OTP 등록 강제(기본 true; 데모 완화 시 false)
|
||||
# - ADMIN_SEED_ENABLED/ADMIN_EMAIL/ADMIN_PASSWORD : 최초 관리자 프로비저닝(하드코딩 시드 금지)
|
||||
# - GEMINI_API_KEY : 나노바나나 Python 워커 전용(백엔드는 큐 발행만; 여기서 미사용)
|
||||
server:
|
||||
port: ${SERVER_PORT:8080}
|
||||
@ -56,6 +59,10 @@ kintex:
|
||||
lock-minutes: ${LOGIN_LOCK_MINUTES:15}
|
||||
otp:
|
||||
issuer: ${OTP_ISSUER:KINTEX}
|
||||
# OTP 시크릿 AES-256-GCM 암호화 키(base64 32바이트). 운영은 env 주입 필수, 미주입 시 개발 파생키.
|
||||
enc-key: ${KINTEX_OTP_ENC_KEY:}
|
||||
# 필수 역할(관람객 외) OTP 등록·검증 강제. 데모 편의 시 OTP_ENFORCE=false 로 완화.
|
||||
enforce: ${OTP_ENFORCE:true}
|
||||
# 최초 관리자 프로비저닝 — 값이 모두 주입될 때만 멱등 upsert. 미주입 시 시드 안 함(기본 관리자 계정 없음).
|
||||
admin-seed:
|
||||
enabled: ${ADMIN_SEED_ENABLED:false}
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
-- ============================================================================
|
||||
-- V12 — 2차 인증(TOTP RFC6238) 완결: OTP 상태·로그인 실패 잠금 컬럼 멱등 정합.
|
||||
-- 보안 불변(계약 §0-3): otp_secret 은 AES-256-GCM 암호화 저장(SecretCipher), API 응답/로그 노출 금지.
|
||||
-- 주의: 아래 컬럼 다수는 V2/V7 에서 이미 생성됨 → 전부 IF NOT EXISTS 멱등 ALTER(재현·이관 안전).
|
||||
-- ============================================================================
|
||||
|
||||
-- 신규: OTP 최초 활성(검증 완료) 시각 — 감사/마이페이지 상태 표시용.
|
||||
ALTER TABLE app_user ADD COLUMN IF NOT EXISTS otp_verified_at timestamptz;
|
||||
|
||||
-- 기존 정합 재확인(멱등) — 신규 환경/부분 이관 대비.
|
||||
ALTER TABLE app_user ADD COLUMN IF NOT EXISTS otp_secret varchar(255);
|
||||
ALTER TABLE app_user ADD COLUMN IF NOT EXISTS otp_enabled boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE app_user ADD COLUMN IF NOT EXISTS verify_method varchar(10) NOT NULL DEFAULT 'EMAIL';
|
||||
ALTER TABLE app_user ADD COLUMN IF NOT EXISTS failed_login_count integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE app_user ADD COLUMN IF NOT EXISTS locked_until timestamptz;
|
||||
|
||||
-- 암호문(enc:v1:base64) 수용을 위해 폭 확장(V2 는 varchar(96)) — 멱등.
|
||||
ALTER TABLE app_user ALTER COLUMN otp_secret TYPE varchar(255);
|
||||
|
||||
COMMENT ON COLUMN app_user.otp_verified_at IS 'OTP 최초 활성(검증) 시각 — 감사/상태 표시';
|
||||
COMMENT ON COLUMN app_user.otp_secret IS 'TOTP 시크릿(AES-256-GCM 봉인, enc:v1:) — API 응답/로그 노출 금지(계약 §0-3)';
|
||||
|
||||
-- 잠금 만료 조회/스윕 최적화(부분 인덱스) — 멱등.
|
||||
CREATE INDEX IF NOT EXISTS idx_app_user_locked_until ON app_user (locked_until) WHERE locked_until IS NOT NULL;
|
||||
@ -0,0 +1,12 @@
|
||||
-- V13: 카탈로그·대시보드·경영분석 집계 API 성능 인덱스 (멱등 · read-only 스키마 불변)
|
||||
-- 근거: GET /api/events(카탈로그 1,098건 시작일 정렬·연도 필터) + 대시보드/BI 유틸리티 집계.
|
||||
-- 신규 테이블/컬럼 없음 — 인덱스만 추가(V12 인증 트랙과 무충돌).
|
||||
|
||||
-- 카탈로그: 시작일 내림차순 정렬 + 연도 필터(extract(year))의 스캔 비용 완화
|
||||
CREATE INDEX IF NOT EXISTS idx_event_start_date ON event (start_date DESC);
|
||||
|
||||
-- 카탈로그: status 원문 필터(보조)
|
||||
CREATE INDEX IF NOT EXISTS idx_event_status ON event (status);
|
||||
|
||||
-- 대시보드/경영분석: utility_order 를 event 단위로 집계(기존 인덱스는 booth 기준만 존재)
|
||||
CREATE INDEX IF NOT EXISTS idx_utility_order_event ON utility_order (event_id);
|
||||
@ -107,4 +107,17 @@
|
||||
AND ST_Intersects(bo.geom, ST_Buffer(ex.geom, ex.clearance_m))
|
||||
</select>
|
||||
|
||||
<!-- 내부가 겹치는 부스 쌍 수: 경계만 맞닿는(ST_Touches) 경우는 제외하고 내부 교차만 계수.
|
||||
ST_Overlaps는 포함/동일을 놓치므로 ST_Intersects AND NOT ST_Touches 로 포괄 판정. -->
|
||||
<select id="countOverlappingBooths" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM booth a
|
||||
JOIN booth b
|
||||
ON a.layout_id = b.layout_id
|
||||
AND a.id <![CDATA[<]]> b.id
|
||||
AND ST_Intersects(a.geom, b.geom)
|
||||
AND NOT ST_Touches(a.geom, b.geom)
|
||||
WHERE a.layout_id = #{layoutId}
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"rulesetVersion": "compliance-v1.0",
|
||||
"effectiveDate": "2026-07-11",
|
||||
"rulesetVersion": "compliance-v1.1",
|
||||
"effectiveDate": "2026-07-12",
|
||||
"source": "PLANNING.md v1.2 §7-1 규정 룰셋 / 참가업체·전시주최자 매뉴얼",
|
||||
"disclaimer": "본 룰셋은 사전 필터이며 최종 승인은 킨텍스 및 구조기술사의 판단에 따른다.",
|
||||
"rules": [
|
||||
@ -80,6 +80,17 @@
|
||||
"operator": "eq",
|
||||
"threshold": 0
|
||||
},
|
||||
{
|
||||
"code": "BOOTH_OVERLAP",
|
||||
"group": "layout",
|
||||
"label": "부스 간 겹침 금지(배치 무결성)",
|
||||
"module": ["M2"],
|
||||
"severity": "block",
|
||||
"metric": "layout.overlap_count",
|
||||
"operator": "eq",
|
||||
"threshold": 0,
|
||||
"note": "PostGIS ST_Intersects AND NOT ST_Touches(내부 교차) 기반 서버 산출값"
|
||||
},
|
||||
{
|
||||
"code": "CLEARANCE_WALL",
|
||||
"group": "clearance",
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
package com.zioinfo.kintex.analytics;
|
||||
|
||||
import com.zioinfo.kintex.analytics.dto.AnalyticsDto;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 경영분석 서비스 단위테스트 — 매출=면적×요율 산출, 억 단위 표기·등급, exhibitor 관점 pnl 제외.
|
||||
*/
|
||||
class AnalyticsServiceTest {
|
||||
|
||||
private AnalyticsMapper mapper;
|
||||
private AnalyticsService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mapper = mock(AnalyticsMapper.class);
|
||||
service = new AnalyticsService(mapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void computesRentalRevenueFromAreaAndRate() {
|
||||
when(mapper.findRentalRatePerM2()).thenReturn(2250.0);
|
||||
Map<String, Object> ac = new HashMap<>();
|
||||
ac.put("salesArea", 100000.0); // 100,000 ㎡
|
||||
ac.put("boothCount", 486);
|
||||
when(mapper.findAreaAndCount("e1")).thenReturn(ac);
|
||||
when(mapper.findUtilityRevenue("e1")).thenReturn(0.0);
|
||||
when(mapper.findMonthlyTrend("e1", 0)).thenReturn(List.of());
|
||||
|
||||
AnalyticsDto dto = service.getAnalytics("e1", "operator", "event");
|
||||
|
||||
// 100,000 * 2250 = 225,000,000 → "₩2.25억".
|
||||
AnalyticsDto.Kpi rental = dto.kpis().stream()
|
||||
.filter(k -> "임대 매출".equals(k.label())).findFirst().orElseThrow();
|
||||
assertEquals("₩2.25억", rental.value());
|
||||
|
||||
// operator 관점 → 부스 임대 pnl 존재, grade B(1억~10억).
|
||||
AnalyticsDto.PnlRow pnl = dto.pnl().stream()
|
||||
.filter(p -> "부스 임대".equals(p.item())).findFirst().orElseThrow();
|
||||
assertEquals(225_000_000L, pnl.amount());
|
||||
assertEquals("B", pnl.grade());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exhibitorPerspectiveOmitsPnl() {
|
||||
when(mapper.findRentalRatePerM2()).thenReturn(2250.0);
|
||||
when(mapper.findAreaAndCount("e1")).thenReturn(new HashMap<>());
|
||||
when(mapper.findUtilityRevenue("e1")).thenReturn(0.0);
|
||||
when(mapper.findMonthlyTrend("e1", 90)).thenReturn(List.of());
|
||||
|
||||
AnalyticsDto dto = service.getAnalytics("e1", "exhibitor", "d90");
|
||||
|
||||
assertTrue(dto.pnl().isEmpty());
|
||||
assertTrue(dto.sectors().isEmpty());
|
||||
assertTrue(dto.exhibitorPerf().isEmpty());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
package com.zioinfo.kintex.common.geo;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.zioinfo.kintex.common.error.ApiException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 지오메트리 코덱 단위테스트 — 부스 폴리곤 WKT 조립(링 닫힘 보정)·GeoJSON 파싱·중심 산출.
|
||||
* PostGIS ST_GeomFromText 입력 정합성(SRID 0, 미터)을 코드 레벨에서 보증한다.
|
||||
*/
|
||||
class GeometryCodecTest {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void toPolygonWkt_closesOpenRing() {
|
||||
// 6×3 부스(열린 링) → 닫힌 POLYGON WKT
|
||||
List<List<Double>> ring = List.of(
|
||||
List.of(0.0, 0.0), List.of(6.0, 0.0), List.of(6.0, 3.0), List.of(0.0, 3.0));
|
||||
String wkt = GeometryCodec.toPolygonWkt(ring);
|
||||
assertEquals("POLYGON((0 0,6 0,6 3,0 3,0 0))", wkt);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toPolygonWkt_keepsAlreadyClosedRing() {
|
||||
List<List<Double>> ring = List.of(
|
||||
List.of(0.0, 0.0), List.of(6.0, 0.0), List.of(6.0, 3.0),
|
||||
List.of(0.0, 3.0), List.of(0.0, 0.0));
|
||||
assertEquals("POLYGON((0 0,6 0,6 3,0 3,0 0))", GeometryCodec.toPolygonWkt(ring));
|
||||
}
|
||||
|
||||
@Test
|
||||
void toPolygonWkt_rejectsDegenerateRing() {
|
||||
assertThrows(ApiException.class,
|
||||
() -> GeometryCodec.toPolygonWkt(List.of(List.of(0.0, 0.0), List.of(1.0, 1.0))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsePolygonRing_readsGeoJsonOuterRing() {
|
||||
String geoJson = "{\"type\":\"Polygon\",\"coordinates\":[[[0,0],[6,0],[6,3],[0,3],[0,0]]]}";
|
||||
List<List<Double>> ring = GeometryCodec.parsePolygonRing(mapper, geoJson);
|
||||
assertEquals(5, ring.size());
|
||||
assertEquals(List.of(6.0, 3.0), ring.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void centroid_returnsArithmeticMean() {
|
||||
List<List<Double>> ring = List.of(
|
||||
List.of(0.0, 0.0), List.of(6.0, 0.0), List.of(6.0, 3.0), List.of(0.0, 3.0));
|
||||
assertEquals(List.of(3.0, 1.5), GeometryCodec.centroid(ring));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_handlesNullAndBlankSafely() {
|
||||
assertTrue(GeometryCodec.parsePolygonRing(mapper, null).isEmpty());
|
||||
assertTrue(GeometryCodec.parseLineCoords(mapper, " ").isEmpty());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
package com.zioinfo.kintex.dashboard;
|
||||
|
||||
import com.zioinfo.kintex.common.error.ApiException;
|
||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||
import com.zioinfo.kintex.dashboard.dto.DashboardDto;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 대시보드 서비스 단위테스트 — 마일스톤 파생(과거=done)·KPI 조립·미존재 행사 404.
|
||||
*/
|
||||
class DashboardServiceTest {
|
||||
|
||||
private DashboardMapper mapper;
|
||||
private DashboardService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mapper = mock(DashboardMapper.class);
|
||||
service = new DashboardService(mapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void throwsNotFoundWhenEventMissing() {
|
||||
when(mapper.findEventHeader("x")).thenReturn(null);
|
||||
ApiException ex = assertThrows(ApiException.class, () -> service.getDashboard("x"));
|
||||
assertEquals(ErrorCode.NOT_FOUND, ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void derivesMilestonesAndKpisFromRealCounts() {
|
||||
// 미래 시작일 → 개장 upcoming, 먼 과거 파생일(D-150 등)은 done.
|
||||
String futureStart = LocalDate.now().plusDays(10).toString();
|
||||
Map<String, Object> header = new HashMap<>();
|
||||
header.put("startDate", futureStart);
|
||||
when(mapper.findEventHeader("e1")).thenReturn(header);
|
||||
|
||||
Map<String, Object> counts = new HashMap<>();
|
||||
counts.put("boothCount", 12);
|
||||
counts.put("targetBooths", 20);
|
||||
counts.put("designApproved", 3);
|
||||
counts.put("designTotal", 8);
|
||||
counts.put("utilityOrders", 5);
|
||||
counts.put("renderDone", 7);
|
||||
when(mapper.findKpiCounts("e1")).thenReturn(counts);
|
||||
when(mapper.findExhibitors("e1")).thenReturn(List.of());
|
||||
when(mapper.findRecentRenders("e1")).thenReturn(List.of());
|
||||
|
||||
DashboardDto dto = service.getDashboard("e1");
|
||||
|
||||
// 마일스톤 5종, 개장(D-0)은 미래 → upcoming.
|
||||
assertEquals(5, dto.milestones().size());
|
||||
DashboardDto.Milestone opening = dto.milestones().get(dto.milestones().size() - 1);
|
||||
assertEquals("개장", opening.label());
|
||||
assertEquals("upcoming", opening.state());
|
||||
// 초기 D-150 파생일은 과거 → done.
|
||||
assertEquals("done", dto.milestones().get(0).state());
|
||||
|
||||
// KPI: 부스 배치 "12/20".
|
||||
assertTrue(dto.kpis().stream().anyMatch(k -> "부스 배치".equals(k.label()) && "12/20".equals(k.value())));
|
||||
assertTrue(dto.kpis().stream().anyMatch(k -> "설계 승인".equals(k.label()) && "3/8".equals(k.value())));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
package com.zioinfo.kintex.module.m4;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.zioinfo.kintex.module.m4.dto.UtilityQuote;
|
||||
import com.zioinfo.kintex.module.m4.dto.UtilityQuoteRequest;
|
||||
import com.zioinfo.kintex.module.m4.mapper.UtilityOrderMapper;
|
||||
import com.zioinfo.kintex.module.m4.mapper.WiringMapper;
|
||||
import com.zioinfo.kintex.rules.RuleProperties;
|
||||
import com.zioinfo.kintex.rules.RuleSetLoader;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* M4 유틸리티 자동 견적 계산 단위테스트 — 실제 요율 룰셋(rates-v1.json) 기준.
|
||||
* 소비전력 합산·신청용량 올림·분전반(50A/11kW) 산출·회선/급배수/압축공기 라인·합계 정합을 검증한다.
|
||||
*/
|
||||
class UtilityQuoteCalcTest {
|
||||
|
||||
private UtilityServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
RuleProperties props = new RuleProperties();
|
||||
props.setComplianceRuleset(new ClassPathResource("rulesets/compliance-v1.json"));
|
||||
props.setRateRuleset(new ClassPathResource("rulesets/rates-v1.json"));
|
||||
RuleSetLoader loader = new RuleSetLoader(props, new ObjectMapper());
|
||||
loader.load();
|
||||
service = new UtilityServiceImpl(loader, mock(WiringMapper.class),
|
||||
mock(UtilityOrderMapper.class), new ObjectMapper());
|
||||
}
|
||||
|
||||
@Test
|
||||
void quote_sumsPower_boxesCeil_andLineAmounts() {
|
||||
// 2대 × 5.0kW = 10kW → requestedKw 10, 분전반 ceil(10/11)=1
|
||||
UtilityQuoteRequest req = new UtilityQuoteRequest(
|
||||
List.of(new UtilityQuoteRequest.Device("LED 사이니지", 2, 5.0)),
|
||||
2, // network wired lines
|
||||
1, // plumbing outlets
|
||||
1); // compressed air outlets
|
||||
UtilityQuote q = service.quote(req);
|
||||
|
||||
assertEquals(10.0, q.totalPowerKw(), 1e-9);
|
||||
assertEquals(10, q.requestedKw());
|
||||
assertEquals(1, q.distributionBox50A());
|
||||
|
||||
// rates-v1.json: perKw 55000, box50A 100000, wired 150000, plumb 150000, air 150000
|
||||
long expected = 10L * 55000 // 전기
|
||||
+ 1L * 100000 // 분전반
|
||||
+ 2L * 150000 // 유선회선
|
||||
+ 1L * 150000 // 급배수
|
||||
+ 1L * 150000; // 압축공기
|
||||
assertEquals(expected, q.total());
|
||||
assertEquals("KRW", q.currency());
|
||||
assertEquals("rates-v1.0", q.rulesetVersion());
|
||||
assertEquals(5, q.lines().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void quote_fractionalKw_roundsUpRequestedCapacity() {
|
||||
// 3대 × 2.2kW = 6.6kW → requestedKw ceil = 7
|
||||
UtilityQuoteRequest req = new UtilityQuoteRequest(
|
||||
List.of(new UtilityQuoteRequest.Device("계측장비", 3, 2.2)),
|
||||
0, 0, 0);
|
||||
UtilityQuote q = service.quote(req);
|
||||
assertEquals(7, q.requestedKw());
|
||||
assertEquals(1, q.distributionBox50A()); // ceil(7/11)=1
|
||||
assertEquals(7L * 55000 + 100000, q.total());
|
||||
}
|
||||
|
||||
@Test
|
||||
void quote_noDemand_isZeroWithNoLines() {
|
||||
UtilityQuoteRequest req = new UtilityQuoteRequest(List.of(), 0, 0, 0);
|
||||
UtilityQuote q = service.quote(req);
|
||||
assertEquals(0, q.requestedKw());
|
||||
assertEquals(0, q.distributionBox50A());
|
||||
assertEquals(0L, q.total());
|
||||
assertTrue(q.lines().isEmpty());
|
||||
assertTrue(q.disclaimer() != null && !q.disclaimer().isBlank()); // 공시가 고지 항상 포함
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,145 @@
|
||||
package com.zioinfo.kintex.rules;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 규정 룰 엔진 단위테스트 — 실제 배포 룰셋(compliance-v1.json)을 로드해 평가 정확도를 검증한다.
|
||||
* 대상: 높이 5m 차단·통로폭·비상구·부스 겹침·홀별 바닥하중(lteHall)·방염·리깅(warn) + submittable 집계.
|
||||
*/
|
||||
class ComplianceRuleEngineTest {
|
||||
|
||||
private ComplianceRuleEngine engine;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
RuleProperties props = new RuleProperties();
|
||||
props.setComplianceRuleset(new ClassPathResource("rulesets/compliance-v1.json"));
|
||||
props.setRateRuleset(new ClassPathResource("rulesets/rates-v1.json"));
|
||||
RuleSetLoader loader = new RuleSetLoader(props, new ObjectMapper());
|
||||
loader.load();
|
||||
engine = new ComplianceRuleEngine(loader);
|
||||
}
|
||||
|
||||
private Violation find(ComplianceReport r, String code) {
|
||||
return r.violations().stream().filter(v -> code.equals(v.code())).findFirst().orElse(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadsRealRuleset() {
|
||||
ComplianceReport r = engine.evaluate("M2", Map.of("booth.height_m", 3.0), null);
|
||||
assertEquals("compliance-v1.1", r.rulesetVersion());
|
||||
assertNotNull(r.disclaimer());
|
||||
}
|
||||
|
||||
@Test
|
||||
void m2_allWithinLimits_isSubmittable() {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("booth.height_m", 3.0);
|
||||
m.put("layout.min_aisle_width_m", 3.5);
|
||||
m.put("layout.exits_blocked_count", 0);
|
||||
m.put("layout.overlap_count", 0);
|
||||
m.put("booth.floor_load_t_per_m2", 4.0);
|
||||
ComplianceReport r = engine.evaluate("M2", m, "H7");
|
||||
assertEquals(0, r.blockCount());
|
||||
assertTrue(r.submittable());
|
||||
assertTrue(r.passCount() >= 5);
|
||||
}
|
||||
|
||||
@Test
|
||||
void m2_heightOverFiveMeters_blocks() {
|
||||
ComplianceReport r = engine.evaluate("M2", Map.of("booth.height_m", 5.4), "H7");
|
||||
Violation v = find(r, "HEIGHT_MAX");
|
||||
assertNotNull(v);
|
||||
assertEquals("block", v.severity());
|
||||
assertFalse(r.submittable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void m2_aisleNarrowerThanThreeMeters_blocks() {
|
||||
ComplianceReport r = engine.evaluate("M2", Map.of("layout.min_aisle_width_m", 2.5), null);
|
||||
assertNotNull(find(r, "AISLE_WIDTH_MIN"));
|
||||
assertFalse(r.submittable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void m2_overlappingBooths_blocks_andReportsCount() {
|
||||
ComplianceReport r = engine.evaluate("M2", Map.of("layout.overlap_count", 2), null);
|
||||
Violation v = find(r, "BOOTH_OVERLAP");
|
||||
assertNotNull(v);
|
||||
assertEquals("block", v.severity());
|
||||
assertTrue(v.measured().contains("2"));
|
||||
assertFalse(r.submittable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void m2_floorLoad_hallSpecificLimit() {
|
||||
// 홀6 상한 2.0 t/㎡ — 3.0 은 초과(차단).
|
||||
ComplianceReport h6 = engine.evaluate("M2", Map.of("booth.floor_load_t_per_m2", 3.0), "H6");
|
||||
assertNotNull(find(h6, "FLOOR_LOAD"));
|
||||
// 홀7 상한 5.0 t/㎡ — 동일 3.0 은 통과.
|
||||
ComplianceReport h7 = engine.evaluate("M2", Map.of("booth.floor_load_t_per_m2", 3.0), "H7");
|
||||
assertEquals(null, find(h7, "FLOOR_LOAD"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void warnViolation_doesNotBlockSubmission() {
|
||||
// 인접 벽 이격 0.1m (< 0.3m) → CLEARANCE_WALL warn. warn 만이면 제출 가능(submittable true).
|
||||
ComplianceReport r = engine.evaluate("M2", Map.of("booth.clearance_wall_m", 0.1), null);
|
||||
Violation v = find(r, "CLEARANCE_WALL");
|
||||
assertNotNull(v);
|
||||
assertEquals("warn", v.severity());
|
||||
assertEquals(0, r.blockCount());
|
||||
assertEquals(1, r.warnCount());
|
||||
assertTrue(r.submittable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void riggingBetweenOperator_inRangeCurrentlyPasses() {
|
||||
// 현행 룰셋: RIGGING_RANGE operator=between → 6.5~8.5m 내이면 "통과"로 집계(위반 미발생).
|
||||
// 라벨 의도("구간 진입 시 D-7 필요")와 반대 — 갭 기록: _workspace/08_m2m5_contract_changes.md.
|
||||
ComplianceReport r = engine.evaluate("M3", Map.of("rigging.height_m", 7.0), null);
|
||||
assertEquals(null, find(r, "RIGGING_RANGE"));
|
||||
assertTrue(r.passCount() >= 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void m3_nonFireRetardantMaterials_blocks() {
|
||||
ComplianceReport r = engine.evaluate("M3", Map.of("materials.all_fire_retardant", false), null);
|
||||
assertNotNull(find(r, "FIRE_RETARDANT"));
|
||||
assertFalse(r.submittable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmeasuredMetrics_areSkipped_notCountedAsPass() {
|
||||
ComplianceReport r = engine.evaluate("M2", Map.of(), null);
|
||||
assertEquals(0, r.blockCount());
|
||||
assertEquals(0, r.warnCount());
|
||||
assertEquals(0, r.passCount());
|
||||
assertTrue(r.violations().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unloadedRuleset_degradesGracefully() {
|
||||
RuleProperties props = new RuleProperties();
|
||||
props.setComplianceRuleset(new ClassPathResource("rulesets/does-not-exist.json"));
|
||||
RuleSetLoader loader = new RuleSetLoader(props, new ObjectMapper());
|
||||
loader.load();
|
||||
ComplianceReport r = new ComplianceRuleEngine(loader)
|
||||
.evaluate("M2", Map.of("booth.height_m", 9.9), null);
|
||||
assertEquals("compliance-unloaded", r.rulesetVersion());
|
||||
assertTrue(r.submittable());
|
||||
assertEquals(List.of(), r.violations());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
package com.zioinfo.kintex.security;
|
||||
|
||||
import com.zioinfo.kintex.common.error.ApiException;
|
||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* 로그인 실패 잠금 카운터/정책 단위테스트 — 임계 초과 시 잠금, 성공 시 리셋, 계정 미존재 시 무변경.
|
||||
*/
|
||||
class LoginAttemptServiceTest {
|
||||
|
||||
private AccountSecurityMapper mapper;
|
||||
private LoginAttemptService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mapper = mock(AccountSecurityMapper.class);
|
||||
SecurityPolicyProperties props = new SecurityPolicyProperties();
|
||||
props.getLockout().setMaxFailedAttempts(5);
|
||||
props.getLockout().setLockMinutes(30);
|
||||
service = new LoginAttemptService(mapper, props);
|
||||
}
|
||||
|
||||
private Map<String, Object> row(int failedCount, OffsetDateTime lockedUntil) {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("failedCount", failedCount);
|
||||
m.put("lockedUntil", lockedUntil);
|
||||
return m;
|
||||
}
|
||||
|
||||
@Test
|
||||
void locksAccountWhenFailuresReachThreshold() {
|
||||
// 직전 4회 실패 → 이번 실패가 5회째 → 잠금.
|
||||
service.recordFailure("u@kintex.test", row(4, null));
|
||||
verify(mapper).incrementFailure("u@kintex.test");
|
||||
ArgumentCaptor<OffsetDateTime> until = ArgumentCaptor.forClass(OffsetDateTime.class);
|
||||
verify(mapper).lockUntil(org.mockito.ArgumentMatchers.eq("u@kintex.test"), until.capture());
|
||||
// 잠금 만료는 미래(대략 30분 후)여야 한다.
|
||||
org.junit.jupiter.api.Assertions.assertTrue(until.getValue().isAfter(OffsetDateTime.now()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotLockBeforeThreshold() {
|
||||
service.recordFailure("u@kintex.test", row(1, null)); // 2회째
|
||||
verify(mapper).incrementFailure("u@kintex.test");
|
||||
verify(mapper, never()).lockUntil(org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingAccountDoesNotMutateState() {
|
||||
service.recordFailure("nobody@kintex.test", null);
|
||||
verify(mapper, never()).incrementFailure(org.mockito.ArgumentMatchers.anyString());
|
||||
verify(mapper, never()).lockUntil(org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void assertNotLockedThrowsWhenLocked() {
|
||||
Map<String, Object> locked = row(5, OffsetDateTime.now().plusMinutes(10));
|
||||
ApiException ex = assertThrows(ApiException.class, () -> service.assertNotLocked(locked));
|
||||
assertEquals(ErrorCode.ACCOUNT_LOCKED, ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void assertNotLockedPassesWhenLockExpired() {
|
||||
Map<String, Object> expired = row(5, OffsetDateTime.now().minusMinutes(1));
|
||||
assertDoesNotThrow(() -> service.assertNotLocked(expired));
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordSuccessClearsLoginState() {
|
||||
service.recordSuccess("user-1");
|
||||
verify(mapper, times(1)).clearLoginState("user-1");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
package com.zioinfo.kintex.security;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* OTP 시크릿 봉인/해제(AES-256-GCM) 왕복·하위호환 단위테스트.
|
||||
*/
|
||||
class SecretCipherTest {
|
||||
|
||||
private SecretCipher newCipher() {
|
||||
// encKey 미주입 → 개발 파생키 사용(테스트 결정적).
|
||||
return new SecretCipher(new SecurityPolicyProperties());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sealThenOpenRoundTrips() {
|
||||
SecretCipher cipher = newCipher();
|
||||
String secret = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP";
|
||||
String sealed = cipher.seal(secret);
|
||||
assertTrue(sealed.startsWith("enc:v1:"), "봉인 접두어");
|
||||
org.junit.jupiter.api.Assertions.assertNotEquals(secret, sealed, "저장값은 평문과 달라야 함");
|
||||
assertEquals(secret, cipher.open(sealed), "복호화 왕복 일치");
|
||||
}
|
||||
|
||||
@Test
|
||||
void openTreatsUnprefixedAsPlaintext() {
|
||||
SecretCipher cipher = newCipher();
|
||||
// 이관 이전 평문 시크릿은 그대로 반환(하위호환).
|
||||
assertEquals("PLAINSECRET", cipher.open("PLAINSECRET"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void handlesNullAndBlank() {
|
||||
SecretCipher cipher = newCipher();
|
||||
assertNull(cipher.seal(null));
|
||||
assertNull(cipher.open(null));
|
||||
assertEquals("", cipher.seal(""));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
package com.zioinfo.kintex.security;
|
||||
|
||||
import dev.samstevens.totp.code.DefaultCodeGenerator;
|
||||
import dev.samstevens.totp.time.SystemTimeProvider;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* TotpService — RFC6238 코드 검증 핵심 로직 단위테스트(외부 리소스 無).
|
||||
*/
|
||||
class TotpServiceTest {
|
||||
|
||||
private TotpService newService() {
|
||||
SecurityPolicyProperties props = new SecurityPolicyProperties();
|
||||
props.getOtp().setIssuer("KINTEX-TEST");
|
||||
return new TotpService(props);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generatesSecretAndVerifiesCurrentCode() throws Exception {
|
||||
TotpService svc = newService();
|
||||
String secret = svc.generateSecret();
|
||||
assertNotNull(secret);
|
||||
assertFalse(secret.isBlank());
|
||||
|
||||
// 현재 타임스텝의 정상 코드는 검증 통과해야 한다.
|
||||
long currentBucket = new SystemTimeProvider().getTime() / 30;
|
||||
String validCode = new DefaultCodeGenerator().generate(secret, currentBucket);
|
||||
assertTrue(svc.verify(secret, validCode), "현재 타임스텝 코드는 유효해야 함");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWrongCodeAndBlankInputs() {
|
||||
TotpService svc = newService();
|
||||
String secret = svc.generateSecret();
|
||||
assertFalse(svc.verify(secret, "000000"), "임의 코드는 거부");
|
||||
assertFalse(svc.verify(secret, ""), "빈 코드는 거부");
|
||||
assertFalse(svc.verify(null, "123456"), "시크릿 null 은 거부");
|
||||
assertFalse(svc.verify(secret, null), "코드 null 은 거부");
|
||||
}
|
||||
|
||||
@Test
|
||||
void otpAuthUriContainsIssuerAndSecret() {
|
||||
TotpService svc = newService();
|
||||
String secret = svc.generateSecret();
|
||||
String uri = svc.otpAuthUri(secret, "user@kintex.test");
|
||||
assertTrue(uri.startsWith("otpauth://totp/"), "otpauth URI 스킴");
|
||||
assertTrue(uri.contains("KINTEX-TEST"), "issuer 포함");
|
||||
assertTrue(uri.contains(secret), "secret 포함");
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { LoginPage } from './screens/login/LoginPage';
|
||||
import { OtpSetupPage } from './screens/login/OtpSetupPage';
|
||||
import { BoothLayoutEditorPage } from './screens/floorplan/BoothLayoutEditorPage';
|
||||
import { LayoutComparisonPage } from './screens/floorplan/LayoutComparisonPage';
|
||||
import { OrganizerDashboardPage } from './screens/dashboard/OrganizerDashboardPage';
|
||||
@ -20,6 +21,16 @@ import { LoginSlideAdminPage } from './screens/admin/LoginSlideAdminPage';
|
||||
import { ComponentGuidePage } from './screens/styleguide/ComponentGuidePage';
|
||||
import { ContractorOnsiteChecklistPage } from './screens/mobile/ContractorOnsiteChecklistPage';
|
||||
import { ManagerFieldInspectionPage } from './screens/mobile/ManagerFieldInspectionPage';
|
||||
import { WorklogPage } from './screens/work/WorklogPage';
|
||||
import { WorkSchedulePage } from './screens/work/WorkSchedulePage';
|
||||
import { MessagePage } from './screens/work/MessagePage';
|
||||
import { NoticePage } from './screens/work/NoticePage';
|
||||
import { OpinionPage } from './screens/work/OpinionPage';
|
||||
import { SearchPage } from './screens/work/SearchPage';
|
||||
import { MeetingPage } from './screens/work/MeetingPage';
|
||||
import { ReportPage } from './screens/work/ReportPage';
|
||||
import { NotificationCenterPage } from './screens/work/NotificationCenterPage';
|
||||
import { MyPage } from './screens/work/MyPage';
|
||||
import { AppShell } from './components/layout/AppShell';
|
||||
|
||||
/** 인증 가드 — 미인증 시 로그인으로. (역할별 라우팅은 화면 추가 시 확장) */
|
||||
@ -33,6 +44,15 @@ export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
{/* 2차 인증(OTP) 관리 — 인증 필요, 셸 없이 단독 카드. */}
|
||||
<Route
|
||||
path="/otp-setup"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<OtpSetupPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 인증 필요 — 사이드바 셸 안 (데스크톱 1440) */}
|
||||
<Route
|
||||
@ -97,6 +117,19 @@ export function App() {
|
||||
<Route path="/admin/login-slides" element={<LoginSlideAdminPage />} />
|
||||
{/* SCR-17 컴포넌트 가이드 (내부 전용 · 네비 비노출) */}
|
||||
<Route path="/_styleguide" element={<ComponentGuidePage />} />
|
||||
|
||||
{/* §5B 공통 업무 기능 (SCR-39~48) — 역할 공통 영역 */}
|
||||
<Route path="/work/worklog" element={<WorklogPage />} />
|
||||
<Route path="/work/schedule" element={<WorkSchedulePage />} />
|
||||
<Route path="/work/message" element={<MessagePage />} />
|
||||
<Route path="/work/notice" element={<NoticePage />} />
|
||||
<Route path="/work/opinion" element={<OpinionPage />} />
|
||||
<Route path="/work/search" element={<SearchPage />} />
|
||||
<Route path="/work/meeting" element={<MeetingPage />} />
|
||||
<Route path="/work/report" element={<ReportPage />} />
|
||||
{/* SCR-47 알림센터 · SCR-48 마이페이지 */}
|
||||
<Route path="/notifications" element={<NotificationCenterPage />} />
|
||||
<Route path="/me" element={<MyPage />} />
|
||||
</Route>
|
||||
|
||||
{/* 모바일 전용 (390) — 셸 없이 전폭, 조회·현장 */}
|
||||
|
||||
@ -33,7 +33,7 @@ export function getAccessToken(): string | null {
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
/** 인증 헤더 생략(공개 경로). */
|
||||
@ -102,6 +102,8 @@ export const api = {
|
||||
request<T>(path, { ...opts, method: 'POST', body }),
|
||||
put: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
request<T>(path, { ...opts, method: 'PUT', body }),
|
||||
patch: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
request<T>(path, { ...opts, method: 'PATCH', body }),
|
||||
del: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
request<T>(path, { ...opts, method: 'DELETE' }),
|
||||
/** multipart 업로드(POST FormData) — 파일 업로드 경로 전용. */
|
||||
|
||||
@ -5,12 +5,19 @@
|
||||
import { api } from './client';
|
||||
import type {
|
||||
AcceptInviteRequest,
|
||||
AdminDashboardData,
|
||||
AnalyticsData,
|
||||
AnalyticsPeriod,
|
||||
AnalyticsPerspective,
|
||||
AutoLayoutOption,
|
||||
AutoLayoutRequest,
|
||||
ComplianceReport,
|
||||
DashboardData,
|
||||
OpsData,
|
||||
DesignPlanDto,
|
||||
DesignSaveRequest,
|
||||
DesignSpec,
|
||||
ExhibitionDto,
|
||||
ForgotPasswordRequest,
|
||||
KintexPrincipal,
|
||||
LayoutDto,
|
||||
@ -19,23 +26,67 @@ import type {
|
||||
LoginResponse,
|
||||
LoginSlideDto,
|
||||
LoginSlideUpdateRequest,
|
||||
MeetingActionDto,
|
||||
MeetingActionRequest,
|
||||
MeetingDetail,
|
||||
MeetingDto,
|
||||
MeetingSaveRequest,
|
||||
MessageDto,
|
||||
MessageSendRequest,
|
||||
NoticeDto,
|
||||
NoticeSaveRequest,
|
||||
NotificationDto,
|
||||
OpinionCommentDto,
|
||||
OpinionDetail,
|
||||
OpinionDto,
|
||||
OpinionSaveRequest,
|
||||
OtpSetupResponse,
|
||||
OtpStatusResponse,
|
||||
PageResponse,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
RenderJobDto,
|
||||
RenderJobRequest,
|
||||
ReportDto,
|
||||
ReportSaveRequest,
|
||||
ResetPasswordRequest,
|
||||
ScheduleDto,
|
||||
ScheduleSaveRequest,
|
||||
SearchResultItem,
|
||||
SecureLoginResponse,
|
||||
UtilityOrderDto,
|
||||
UtilityQuote,
|
||||
UtilityQuoteRequest,
|
||||
WiringRequest,
|
||||
WiringResult,
|
||||
WorkStatsDto,
|
||||
WorklogDto,
|
||||
WorklogSaveRequest,
|
||||
WorkspaceDto,
|
||||
} from './types';
|
||||
|
||||
// ── 인증·워크스페이스 (SCR-01) ──
|
||||
export const authApi = {
|
||||
// 레거시 단일 단계 로그인(하위호환 유지). 신규 UI 는 secureLogin 사용.
|
||||
login: (body: LoginRequest) =>
|
||||
api.post<LoginResponse>('/api/auth/login', body, { anonymous: true }),
|
||||
// 2FA/잠금 적용 1단계 — status(OK|OTP_REQUIRED|OTP_ENROLL) 분기.
|
||||
secureLogin: (body: LoginRequest) =>
|
||||
api.post<SecureLoginResponse>('/api/auth/login/secure', body, { anonymous: true }),
|
||||
// 2단계(등록 사용자) — challengeToken + 6자리 → 최종 토큰.
|
||||
otpVerify: (challengeToken: string, code: string) =>
|
||||
api.post<LoginResponse>('/api/auth/otp/verify', { challengeToken, code }, { anonymous: true }),
|
||||
// 등록 강제 setup — challengeToken 으로 시크릿·QR 발급(프리오스).
|
||||
otpEnrollSetup: (challengeToken: string) =>
|
||||
api.post<OtpSetupResponse>('/api/auth/otp/enroll/setup', { challengeToken }, { anonymous: true }),
|
||||
// 등록 강제 verify — 코드 확인·활성화 → 최종 토큰.
|
||||
otpEnrollVerify: (challengeToken: string, code: string) =>
|
||||
api.post<LoginResponse>('/api/auth/otp/enroll/verify', { challengeToken, code }, { anonymous: true }),
|
||||
// 마이페이지(인증) — 본인 OTP 등록/확인/상태/해제.
|
||||
otpSetup: () => api.post<OtpSetupResponse>('/api/auth/otp/setup'),
|
||||
otpConfirm: (code: string) => api.post<void>('/api/auth/otp/confirm', { code }),
|
||||
otpStatus: () => api.get<OtpStatusResponse>('/api/auth/otp/status'),
|
||||
otpResetSelf: () => api.post<void>('/api/auth/otp/reset'),
|
||||
workspaces: () => api.get<WorkspaceDto[]>('/api/auth/workspaces'),
|
||||
me: () => api.get<KintexPrincipal>('/api/auth/me'),
|
||||
acceptInvite: (body: AcceptInviteRequest) =>
|
||||
@ -51,6 +102,28 @@ export const authApi = {
|
||||
api.post<void>('/api/auth/password/reset', body, { anonymous: true }),
|
||||
};
|
||||
|
||||
// ── 전시 일정 카탈로그 (SCR-15) ──
|
||||
// 제안 계약(백엔드 갭): `GET /api/events` → ExhibitionDto[] (전 홀 10년치 카탈로그).
|
||||
// 인증 workspaces(내가 멤버인 행사)와 달리 이 경로는 전체 행사 카탈로그를 제공한다.
|
||||
// 백엔드 미구현 시 NOT_IMPLEMENTED(501)/NOT_FOUND(404) → 호출부에서 크롤링 샘플 폴백.
|
||||
export const exhibitionApi = {
|
||||
list: (params?: { year?: string; status?: string }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.year && params.year !== 'all') qs.set('year', params.year);
|
||||
if (params?.status && params.status !== 'all') qs.set('status', params.status);
|
||||
const q = qs.toString();
|
||||
return api.get<ExhibitionDto[]>(`/api/events${q ? `?${q}` : ''}`);
|
||||
},
|
||||
};
|
||||
|
||||
// ── 시스템관리 — 사용자(관리자) ──
|
||||
// 사용자 관리 화면은 후속(현재 API 계약만). OTP 초기화는 SystemAccessGuard.requireAdmin + @Audited.
|
||||
export const adminUserApi = {
|
||||
// 관리자 — 대상 사용자 OTP 강제 초기화(분실/기기 교체 대응). 다음 로그인 시 재등록.
|
||||
otpReset: (userId: string) =>
|
||||
api.post<void>(`/api/admin/users/${encodeURIComponent(userId)}/otp/reset`),
|
||||
};
|
||||
|
||||
// ── 로그인 슬라이드 (SCR-01 캐러셀 · 관리자) ──
|
||||
export const loginSlideApi = {
|
||||
// 공개 — 활성만, 최신 등록순 최대 5건(인증 불필요).
|
||||
@ -146,3 +219,165 @@ export const renderApi = {
|
||||
body,
|
||||
),
|
||||
};
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* 대시보드 계열 집계 (SCR-02·13·14·16) — 실 API
|
||||
* 근거: 백엔드 com.zioinfo.kintex.{dashboard,analytics,admin,ops} 컨트롤러(정본).
|
||||
* 실패(NETWORK/NOT_FOUND) 시 각 화면이 샘플 폴백으로 강등.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
// ── SCR-02 주최자 대시보드 집계 ──
|
||||
export const dashboardApi = {
|
||||
get: (eventId: string) =>
|
||||
api.get<DashboardData>(`/api/events/${encodeURIComponent(eventId)}/dashboard`),
|
||||
};
|
||||
|
||||
// ── SCR-13 경영분석 BI 집계 ──
|
||||
export const analyticsApi = {
|
||||
get: (eventId: string, perspective: AnalyticsPerspective, period: AnalyticsPeriod) =>
|
||||
api.get<AnalyticsData>(
|
||||
`/api/events/${encodeURIComponent(eventId)}/analytics?perspective=${perspective}&period=${period}`,
|
||||
),
|
||||
};
|
||||
|
||||
// ── SCR-14 관리자 백오피스 대시보드 ──
|
||||
export const adminApi = {
|
||||
dashboard: (tenant?: string) =>
|
||||
api.get<AdminDashboardData>(
|
||||
`/api/admin/dashboard${tenant ? `?tenant=${encodeURIComponent(tenant)}` : ''}`,
|
||||
),
|
||||
};
|
||||
|
||||
// ── SCR-16 홀 현장 운영 ──
|
||||
export const opsApi = {
|
||||
get: (eventId: string) =>
|
||||
api.get<OpsData>(`/api/events/${encodeURIComponent(eventId)}/ops`),
|
||||
};
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* §5B 공통 업무 기능 (SCR-39~48) — /api/work/*
|
||||
* 근거: 백엔드 com.zioinfo.kintex.work.* 컨트롤러 시그니처(정본).
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
function qs(params: Record<string, string | number | boolean | undefined | null>): string {
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== null && v !== '') sp.set(k, String(v));
|
||||
}
|
||||
const s = sp.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
// ── SCR-39 업무일지 ──
|
||||
export interface WorklogListParams {
|
||||
eventId?: string;
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
status?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
export const worklogApi = {
|
||||
list: (p: WorklogListParams = {}) =>
|
||||
api.get<PageResponse<WorklogDto>>(`/api/work/worklogs${qs({ ...p })}`),
|
||||
get: (id: string) => api.get<WorklogDto>(`/api/work/worklogs/${encodeURIComponent(id)}`),
|
||||
create: (body: WorklogSaveRequest) => api.post<WorklogDto>('/api/work/worklogs', body),
|
||||
update: (id: string, body: WorklogSaveRequest) =>
|
||||
api.put<WorklogDto>(`/api/work/worklogs/${encodeURIComponent(id)}`, body),
|
||||
remove: (id: string) => api.del<void>(`/api/work/worklogs/${encodeURIComponent(id)}`),
|
||||
};
|
||||
|
||||
// ── SCR-40 통합 일정 ──
|
||||
export const scheduleWorkApi = {
|
||||
list: (p: { eventId?: string; fromAt?: string; toAt?: string } = {}) =>
|
||||
api.get<ScheduleDto[]>(`/api/work/schedules${qs({ ...p })}`),
|
||||
get: (id: string) => api.get<ScheduleDto>(`/api/work/schedules/${encodeURIComponent(id)}`),
|
||||
create: (body: ScheduleSaveRequest) => api.post<ScheduleDto>('/api/work/schedules', body),
|
||||
update: (id: string, body: ScheduleSaveRequest) =>
|
||||
api.put<ScheduleDto>(`/api/work/schedules/${encodeURIComponent(id)}`, body),
|
||||
remove: (id: string) => api.del<void>(`/api/work/schedules/${encodeURIComponent(id)}`),
|
||||
};
|
||||
|
||||
// ── SCR-41 쪽지 ──
|
||||
export const messageApi = {
|
||||
inbox: (page = 0, size = 20) =>
|
||||
api.get<PageResponse<MessageDto>>(`/api/work/messages/inbox${qs({ page, size })}`),
|
||||
sent: (page = 0, size = 20) =>
|
||||
api.get<PageResponse<MessageDto>>(`/api/work/messages/sent${qs({ page, size })}`),
|
||||
unreadCount: () => api.get<{ unread: number }>('/api/work/messages/unread-count'),
|
||||
send: (body: MessageSendRequest) => api.post<{ id: string }>('/api/work/messages', body),
|
||||
read: (id: string) => api.post<void>(`/api/work/messages/${encodeURIComponent(id)}/read`),
|
||||
remove: (id: string) => api.del<void>(`/api/work/messages/${encodeURIComponent(id)}`),
|
||||
};
|
||||
|
||||
// ── SCR-42 공지 (조회=인증, 쓰기=매니저) ──
|
||||
export const noticeApi = {
|
||||
list: (p: { eventId?: string; category?: string; keyword?: string; page?: number; size?: number } = {}) =>
|
||||
api.get<PageResponse<NoticeDto>>(`/api/work/notices${qs({ ...p })}`),
|
||||
get: (id: string) => api.get<NoticeDto>(`/api/work/notices/${encodeURIComponent(id)}`),
|
||||
create: (body: NoticeSaveRequest) => api.post<NoticeDto>('/api/work/notices', body),
|
||||
update: (id: string, body: NoticeSaveRequest) =>
|
||||
api.put<NoticeDto>(`/api/work/notices/${encodeURIComponent(id)}`, body),
|
||||
remove: (id: string) => api.del<void>(`/api/work/notices/${encodeURIComponent(id)}`),
|
||||
};
|
||||
|
||||
// ── SCR-43 의견접수 (조회/등록=인증, 상태·답변=매니저) ──
|
||||
export const opinionApi = {
|
||||
list: (p: { eventId?: string; status?: string; page?: number; size?: number } = {}) =>
|
||||
api.get<PageResponse<OpinionDto>>(`/api/work/opinions${qs({ ...p })}`),
|
||||
get: (id: string) => api.get<OpinionDetail>(`/api/work/opinions/${encodeURIComponent(id)}`),
|
||||
create: (body: OpinionSaveRequest) => api.post<OpinionDto>('/api/work/opinions', body),
|
||||
changeStatus: (id: string, value: string) =>
|
||||
api.patch<void>(`/api/work/opinions/${encodeURIComponent(id)}/status${qs({ value })}`),
|
||||
comment: (id: string, content: string) =>
|
||||
api.post<OpinionCommentDto>(`/api/work/opinions/${encodeURIComponent(id)}/comments`, { content }),
|
||||
remove: (id: string) => api.del<void>(`/api/work/opinions/${encodeURIComponent(id)}`),
|
||||
};
|
||||
|
||||
// ── SCR-44 통합검색 ──
|
||||
export const searchApi = {
|
||||
search: (q: string, limit = 30) =>
|
||||
api.get<SearchResultItem[]>(`/api/work/search${qs({ q, limit })}`),
|
||||
};
|
||||
|
||||
// ── SCR-45 회의록 ──
|
||||
export const meetingApi = {
|
||||
list: (p: { eventId?: string; page?: number; size?: number } = {}) =>
|
||||
api.get<PageResponse<MeetingDto>>(`/api/work/meetings${qs({ ...p })}`),
|
||||
get: (id: string) => api.get<MeetingDetail>(`/api/work/meetings/${encodeURIComponent(id)}`),
|
||||
create: (body: MeetingSaveRequest) => api.post<MeetingDto>('/api/work/meetings', body),
|
||||
update: (id: string, body: MeetingSaveRequest) =>
|
||||
api.put<MeetingDto>(`/api/work/meetings/${encodeURIComponent(id)}`, body),
|
||||
saveMinutes: (id: string, minutes: string) =>
|
||||
api.put<void>(`/api/work/meetings/${encodeURIComponent(id)}/minutes`, { minutes }),
|
||||
addAction: (id: string, body: MeetingActionRequest) =>
|
||||
api.post<MeetingActionDto>(`/api/work/meetings/${encodeURIComponent(id)}/actions`, body),
|
||||
actionStatus: (actionId: string, value: string) =>
|
||||
api.patch<void>(`/api/work/meetings/actions/${encodeURIComponent(actionId)}/status${qs({ value })}`),
|
||||
remove: (id: string) => api.del<void>(`/api/work/meetings/${encodeURIComponent(id)}`),
|
||||
};
|
||||
|
||||
// ── SCR-46 업무보고 + 통계 ──
|
||||
export const reportApi = {
|
||||
list: (p: { eventId?: string; reportType?: string; page?: number; size?: number } = {}) =>
|
||||
api.get<PageResponse<ReportDto>>(`/api/work/reports${qs({ ...p })}`),
|
||||
get: (id: string) => api.get<ReportDto>(`/api/work/reports/${encodeURIComponent(id)}`),
|
||||
create: (body: ReportSaveRequest) => api.post<ReportDto>('/api/work/reports', body),
|
||||
update: (id: string, body: ReportSaveRequest) =>
|
||||
api.put<ReportDto>(`/api/work/reports/${encodeURIComponent(id)}`, body),
|
||||
remove: (id: string) => api.del<void>(`/api/work/reports/${encodeURIComponent(id)}`),
|
||||
};
|
||||
export const workStatsApi = {
|
||||
worklog: (p: { eventId?: string; fromDate?: string; toDate?: string } = {}) =>
|
||||
api.get<WorkStatsDto>(`/api/work/stats/worklog${qs({ ...p })}`),
|
||||
};
|
||||
|
||||
// ── SCR-47 알림센터 ──
|
||||
export const notificationApi = {
|
||||
list: (p: { unreadOnly?: boolean; page?: number; size?: number } = {}) =>
|
||||
api.get<PageResponse<NotificationDto>>(`/api/work/notifications${qs({ ...p })}`),
|
||||
unreadCount: () => api.get<{ unread: number }>('/api/work/notifications/unread-count'),
|
||||
read: (id: string) => api.post<void>(`/api/work/notifications/${encodeURIComponent(id)}/read`),
|
||||
readAll: () => api.post<void>('/api/work/notifications/read-all'),
|
||||
remove: (id: string) => api.del<void>(`/api/work/notifications/${encodeURIComponent(id)}`),
|
||||
};
|
||||
|
||||
@ -49,6 +49,22 @@ export interface WorkspaceDto {
|
||||
myRole: EventRole;
|
||||
dday: number;
|
||||
}
|
||||
// ── 전시 일정 카탈로그 (SCR-15 · M1·M6) ──
|
||||
// 원천: `event` 테이블(V10 seed_exhibitions_10yr — kintex.com 크롤링 10년치 1,098건).
|
||||
// 제안 계약: `GET /api/events` → ExhibitionDto[] (백엔드 갭 06_backend_api_gaps.md §1).
|
||||
// DB 컬럼(id·name·start_date·end_date·status)은 필수, 나머지는 후속 enrich(nullable).
|
||||
export type ExhibitionStatus = 'ongoing' | 'upcoming' | 'ended';
|
||||
export interface ExhibitionDto {
|
||||
id: string;
|
||||
name: string;
|
||||
startDate: string; // "2026-08-11"
|
||||
endDate: string; // "2026-08-14"
|
||||
status: ExhibitionStatus | string; // DB 원문(ended 등) — 프론트는 날짜 기준 재계산
|
||||
hallLabel?: string | null; // 배정 홀(후속 enrich) — 예 "제2전시장 홀7"
|
||||
category?: string | null; // 카테고리(후속 enrich)
|
||||
estVisitors?: number | null; // 추정 인원(후속 enrich)
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
@ -64,6 +80,28 @@ export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// ── 2차 인증(OTP·TOTP) — 로그인 2단계 + 등록 강제 ──
|
||||
// 1단계 /api/auth/login/secure 응답 status:
|
||||
// - OK → OTP 미사용(관람객). login 에 최종 토큰.
|
||||
// - OTP_REQUIRED → 등록 사용자. challengeToken 으로 /otp/verify(6자리) → 최종 토큰.
|
||||
// - OTP_ENROLL → 필수 역할 미등록. challengeToken 으로 /otp/enroll/setup → /otp/enroll/verify.
|
||||
export type SecureLoginStatus = 'OK' | 'OTP_REQUIRED' | 'OTP_ENROLL';
|
||||
export interface SecureLoginResponse {
|
||||
status: SecureLoginStatus;
|
||||
challengeToken: string | null;
|
||||
login: LoginResponse | null;
|
||||
}
|
||||
// 시크릿·otpauth URI·QR(data:image/png;base64) — 등록 화면 1회 표기용(시크릿은 저장/재노출 금지).
|
||||
export interface OtpSetupResponse {
|
||||
secret: string;
|
||||
otpAuthUri: string;
|
||||
qrImageDataUri: string;
|
||||
}
|
||||
export interface OtpStatusResponse {
|
||||
otpEnabled: boolean;
|
||||
verifyMethod: string;
|
||||
}
|
||||
export interface AcceptInviteRequest {
|
||||
inviteCode: string;
|
||||
companyRegistrationNo: string;
|
||||
@ -322,3 +360,360 @@ export interface LoginSlideUpdateRequest {
|
||||
sortOrder?: number | null;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* §5B 공통 업무 기능 (SCR-39~48) — 백엔드 com.zioinfo.kintex.work.*
|
||||
* ★ 각 DTO는 대응 컨트롤러/레코드 시그니처와 정확히 일치(추측 금지).
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
// ── SCR-39 업무일지 (work/worklog) ──
|
||||
export interface WorklogDto {
|
||||
id: string;
|
||||
eventId: string | null;
|
||||
writerId: string;
|
||||
writerName: string;
|
||||
workDate: string; // YYYY-MM-DD
|
||||
title: string;
|
||||
workType: string | null;
|
||||
progress: string | null;
|
||||
status: string | null;
|
||||
content: string | null;
|
||||
hours: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
export interface WorklogSaveRequest {
|
||||
eventId?: string | null;
|
||||
workDate: string;
|
||||
title: string;
|
||||
workType?: string | null;
|
||||
progress?: string | null;
|
||||
status?: string | null;
|
||||
content?: string | null;
|
||||
hours?: number | null;
|
||||
}
|
||||
|
||||
// ── SCR-40 통합 일정 (work/schedule) ──
|
||||
export interface ScheduleDto {
|
||||
id: string;
|
||||
eventId: string | null;
|
||||
ownerId: string;
|
||||
ownerName: string;
|
||||
title: string;
|
||||
scheduleType: string | null;
|
||||
importance: string | null;
|
||||
startAt: string; // ISO-8601
|
||||
endAt: string | null;
|
||||
allDay: boolean;
|
||||
location: string | null;
|
||||
content: string | null;
|
||||
color: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
export interface ScheduleSaveRequest {
|
||||
eventId?: string | null;
|
||||
title: string;
|
||||
scheduleType?: string | null;
|
||||
importance?: string | null;
|
||||
startAt: string;
|
||||
endAt?: string | null;
|
||||
allDay?: boolean | null;
|
||||
location?: string | null;
|
||||
content?: string | null;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
// ── SCR-41 쪽지 (work/message) ──
|
||||
export interface MessageDto {
|
||||
id: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
title: string;
|
||||
content: string;
|
||||
recvType: string | null; // RECV | REF (수신함 뷰)
|
||||
readAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
export interface MessageSendRequest {
|
||||
title?: string | null;
|
||||
content?: string | null;
|
||||
recipientIds: string[]; // RECV
|
||||
refIds?: string[]; // REF
|
||||
}
|
||||
|
||||
// ── SCR-42 공지 (work/notice) ──
|
||||
export interface NoticeDto {
|
||||
id: string;
|
||||
eventId: string | null;
|
||||
category: string | null;
|
||||
title: string;
|
||||
content: string | null;
|
||||
pinned: boolean;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
viewCount: number;
|
||||
publishedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
export interface NoticeSaveRequest {
|
||||
eventId?: string | null;
|
||||
category?: string | null;
|
||||
title: string;
|
||||
content?: string | null;
|
||||
pinned?: boolean | null;
|
||||
}
|
||||
|
||||
// ── SCR-43 의견접수 (work/opinion) ──
|
||||
export interface OpinionDto {
|
||||
id: string;
|
||||
eventId: string | null;
|
||||
category: string | null;
|
||||
title: string;
|
||||
content: string | null;
|
||||
status: string; // 접수 RECEIVED | 검토중 REVIEWING | 답변완료 ANSWERED (백엔드 권위)
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
secretYn: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
export interface OpinionCommentDto {
|
||||
id: string;
|
||||
opinionId: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
export interface OpinionDetail {
|
||||
opinion: OpinionDto;
|
||||
comments: OpinionCommentDto[];
|
||||
}
|
||||
export interface OpinionSaveRequest {
|
||||
eventId?: string | null;
|
||||
category?: string | null;
|
||||
title: string;
|
||||
content?: string | null;
|
||||
secretYn?: string | null;
|
||||
}
|
||||
|
||||
// ── SCR-44 통합검색 (work/search) ──
|
||||
// type: WORKLOG | NOTICE | MEETING | REPORT | OPINION
|
||||
export type SearchResultType = 'WORKLOG' | 'NOTICE' | 'MEETING' | 'REPORT' | 'OPINION' | string;
|
||||
export interface SearchResultItem {
|
||||
type: SearchResultType;
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ── SCR-45 회의록 (work/meeting) ──
|
||||
export interface MeetingDto {
|
||||
id: string;
|
||||
eventId: string | null;
|
||||
title: string;
|
||||
location: string | null;
|
||||
meetingAt: string; // ISO-8601
|
||||
organizerId: string;
|
||||
organizerName: string;
|
||||
content: string | null;
|
||||
minutes: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
export interface MeetingActionDto {
|
||||
id: string;
|
||||
meetingId: string;
|
||||
seq: number;
|
||||
actionItem: string;
|
||||
assigneeId: string | null;
|
||||
dueDate: string | null;
|
||||
status: string; // OPEN | DONE …
|
||||
}
|
||||
export interface MeetingDetail {
|
||||
meeting: MeetingDto;
|
||||
actions: MeetingActionDto[];
|
||||
}
|
||||
export interface MeetingSaveRequest {
|
||||
eventId?: string | null;
|
||||
title: string;
|
||||
location?: string | null;
|
||||
meetingAt: string;
|
||||
content?: string | null;
|
||||
}
|
||||
export interface MeetingActionRequest {
|
||||
actionItem: string;
|
||||
assigneeId?: string | null;
|
||||
dueDate?: string | null;
|
||||
}
|
||||
|
||||
// ── SCR-46 업무보고 (work/report) + 통계 (work/stats) ──
|
||||
export type ReportType = 'DAILY' | 'WEEKLY' | 'MONTHLY' | string;
|
||||
export interface ReportDto {
|
||||
id: string;
|
||||
eventId: string | null;
|
||||
reportType: string;
|
||||
periodFrom: string | null;
|
||||
periodTo: string | null;
|
||||
title: string;
|
||||
content: string | null;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
export interface ReportSaveRequest {
|
||||
eventId?: string | null;
|
||||
reportType?: string | null;
|
||||
periodFrom?: string | null;
|
||||
periodTo?: string | null;
|
||||
title: string;
|
||||
content?: string | null;
|
||||
}
|
||||
export interface StatItem {
|
||||
name: string;
|
||||
count: number;
|
||||
hours: number;
|
||||
}
|
||||
export interface WorkStatsDto {
|
||||
totalCount: number;
|
||||
totalHours: number;
|
||||
byStatus: StatItem[];
|
||||
byType: StatItem[];
|
||||
byWriter: StatItem[];
|
||||
}
|
||||
|
||||
// ── SCR-47 알림센터 (work/notification) ──
|
||||
export interface NotificationDto {
|
||||
id: string;
|
||||
recipientId: string;
|
||||
eventId: string | null;
|
||||
notiType: string;
|
||||
title: string;
|
||||
message: string | null;
|
||||
link: string | null;
|
||||
readAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* 대시보드 계열 집계 (SCR-02·13·14·16)
|
||||
* 정본: 백엔드 record — com.zioinfo.kintex.{dashboard,analytics,admin,ops}.dto.*
|
||||
* 봉투 내부 data. 원천 부재 지표는 백엔드가 0/null/빈배열로 정직 반환한다.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
// ── SCR-02 주최자 대시보드 (dashboard.DashboardDto) ──
|
||||
export interface DashboardMilestone {
|
||||
label: string;
|
||||
date: string; // "YYYY-MM-DD"
|
||||
state: 'done' | 'current' | 'upcoming' | string;
|
||||
}
|
||||
export interface DashboardKpi {
|
||||
label: string;
|
||||
value: string;
|
||||
delta: string | null;
|
||||
trend: 'up' | 'down' | 'flat' | string;
|
||||
}
|
||||
export interface DashboardExhibitor {
|
||||
companyName: string;
|
||||
boothNo: string;
|
||||
status: string | null;
|
||||
}
|
||||
export interface DashboardFeed {
|
||||
id: string;
|
||||
kind: string; // "render" 등
|
||||
message: string;
|
||||
at: string; // ISO
|
||||
}
|
||||
export interface DashboardData {
|
||||
milestones: DashboardMilestone[];
|
||||
kpis: DashboardKpi[];
|
||||
exhibitors: DashboardExhibitor[];
|
||||
feed: DashboardFeed[];
|
||||
}
|
||||
|
||||
// ── SCR-13 경영분석 BI (analytics.AnalyticsDto) ──
|
||||
export type AnalyticsPerspective = 'operator' | 'exhibitor';
|
||||
export type AnalyticsPeriod = 'event' | 'd90' | 'annual';
|
||||
export interface AnalyticsKpi {
|
||||
label: string;
|
||||
value: string;
|
||||
delta: string | null;
|
||||
trend: 'up' | 'down' | 'flat' | string;
|
||||
}
|
||||
export interface AnalyticsTrendPoint {
|
||||
period: string; // "YYYY-MM"
|
||||
revenue: number;
|
||||
booths: number;
|
||||
}
|
||||
export interface AnalyticsSector {
|
||||
name: string;
|
||||
sharePercent: number;
|
||||
revenue: number;
|
||||
}
|
||||
export interface AnalyticsPnlRow {
|
||||
item: string;
|
||||
amount: number;
|
||||
grade: 'A' | 'B' | 'C' | string;
|
||||
}
|
||||
export interface AnalyticsExhibitorPerf {
|
||||
companyName: string;
|
||||
leads: number;
|
||||
conversionPercent: number;
|
||||
}
|
||||
export interface AnalyticsData {
|
||||
kpis: AnalyticsKpi[];
|
||||
trend: AnalyticsTrendPoint[];
|
||||
sectors: AnalyticsSector[];
|
||||
pnl: AnalyticsPnlRow[];
|
||||
exhibitorPerf: AnalyticsExhibitorPerf[];
|
||||
}
|
||||
|
||||
// ── SCR-14 관리자 백오피스 (admin.AdminDashboardDto) ──
|
||||
export interface AdminKpiDto {
|
||||
label: string;
|
||||
value: string;
|
||||
sub: string | null;
|
||||
}
|
||||
export interface AdminVisitorPoint {
|
||||
hour: string;
|
||||
count: number;
|
||||
}
|
||||
export interface AdminLiveEvent {
|
||||
eventId: string;
|
||||
name: string;
|
||||
hall: string | null;
|
||||
occupancy: number;
|
||||
status: string;
|
||||
}
|
||||
export interface AdminDashboardData {
|
||||
kpis: AdminKpiDto[];
|
||||
visitorTrend: AdminVisitorPoint[];
|
||||
liveEvents: AdminLiveEvent[];
|
||||
tenants: string[];
|
||||
}
|
||||
|
||||
// ── SCR-16 홀 현장 운영 (ops.OpsDto) ──
|
||||
export interface OpsHall {
|
||||
hallId: string;
|
||||
label: string;
|
||||
estPeople: number;
|
||||
heat: 'smooth' | 'moderate' | 'busy' | string;
|
||||
}
|
||||
export interface OpsHvac {
|
||||
tempC: number | null;
|
||||
humidityPercent: number | null;
|
||||
co2Ppm: number | null;
|
||||
}
|
||||
export interface OpsLightingZone {
|
||||
zone: string;
|
||||
onPercent: number;
|
||||
}
|
||||
export interface OpsParkingZone {
|
||||
zone: string;
|
||||
occupancy: number;
|
||||
capacity: number;
|
||||
}
|
||||
export interface OpsData {
|
||||
halls: OpsHall[];
|
||||
hvac: OpsHvac;
|
||||
lighting: OpsLightingZone[];
|
||||
parking: OpsParkingZone[];
|
||||
}
|
||||
|
||||
@ -1,18 +1,21 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { DdayChip } from '../ui/Badge';
|
||||
import {
|
||||
IconAnalytics,
|
||||
IconBell,
|
||||
IconCalendar,
|
||||
IconCheckCircle,
|
||||
IconDashboard,
|
||||
IconDocument,
|
||||
IconExhibitors,
|
||||
IconFloorplan,
|
||||
IconOperations,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconSettlement,
|
||||
IconUsers,
|
||||
type IconProps,
|
||||
} from '../ui/icons';
|
||||
import './app-shell.css';
|
||||
@ -34,6 +37,18 @@ const NAV_ITEMS: { key: string; label: string; Icon: ComponentType<IconProps>; t
|
||||
{ key: 'admin', label: '관리자', Icon: IconSettings, to: '/admin' },
|
||||
];
|
||||
|
||||
/** §5B 공통 업무 기능(SCR-39~48) — 전 역할 공통 영역. */
|
||||
const WORK_NAV: { key: string; label: string; Icon: ComponentType<IconProps>; to: string }[] = [
|
||||
{ key: 'worklog', label: '업무일지', Icon: IconDocument, to: '/work/worklog' },
|
||||
{ key: 'work-schedule', label: '일정', Icon: IconCalendar, to: '/work/schedule' },
|
||||
{ key: 'message', label: '쪽지', Icon: IconUsers, to: '/work/message' },
|
||||
{ key: 'notice', label: '공지', Icon: IconBell, to: '/work/notice' },
|
||||
{ key: 'meeting', label: '회의록', Icon: IconOperations, to: '/work/meeting' },
|
||||
{ key: 'report', label: '업무보고', Icon: IconAnalytics, to: '/work/report' },
|
||||
{ key: 'opinion', label: '의견접수', Icon: IconCheckCircle, to: '/work/opinion' },
|
||||
{ key: 'search', label: '통합검색', Icon: IconSearch, to: '/work/search' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 웹 인증 셸 (design.md §2-2) — 좌측 240px 사이드바 + 상단 바(브레드크럼·D-데이·알림).
|
||||
* 캔버스 화면은 이 셸 안에서 전폭을 사용한다.
|
||||
@ -42,6 +57,7 @@ export function AppShell() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const workspace = useAuthStore((s) => s.currentWorkspace());
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="kx-shell">
|
||||
@ -84,11 +100,37 @@ export function AppShell() {
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="kx-shell__nav-label">업무 공통</div>
|
||||
<ul className="kx-shell__nav">
|
||||
{WORK_NAV.map((item) => (
|
||||
<li key={item.key}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={({ isActive }) => `kx-shell__nav-link ${isActive ? 'is-active' : ''}`}
|
||||
>
|
||||
<span className="kx-shell__nav-icon"><item.Icon size={20} /></span>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="kx-shell__user">
|
||||
<span className="kx-shell__avatar" aria-hidden="true">
|
||||
<button
|
||||
className="kx-shell__avatar kx-shell__avatar--btn"
|
||||
type="button"
|
||||
aria-label="마이페이지"
|
||||
onClick={() => navigate('/me')}
|
||||
>
|
||||
{user?.displayName?.[0] ?? '·'}
|
||||
</span>
|
||||
<span className="kx-shell__user-name">{user?.displayName ?? '사용자'}</span>
|
||||
</button>
|
||||
<button
|
||||
className="kx-shell__user-name kx-shell__user-name--btn"
|
||||
type="button"
|
||||
onClick={() => navigate('/me')}
|
||||
>
|
||||
{user?.displayName ?? '사용자'}
|
||||
</button>
|
||||
<button className="kx-shell__logout" type="button" onClick={logout}>
|
||||
로그아웃
|
||||
</button>
|
||||
@ -104,7 +146,12 @@ export function AppShell() {
|
||||
</div>
|
||||
<div className="kx-shell__topbar-right">
|
||||
{workspace && <DdayChip dday={workspace.dday} />}
|
||||
<button className="kx-shell__icon-btn" type="button" aria-label="알림">
|
||||
<button
|
||||
className="kx-shell__icon-btn"
|
||||
type="button"
|
||||
aria-label="알림"
|
||||
onClick={() => navigate('/notifications')}
|
||||
>
|
||||
<IconBell size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -138,6 +138,39 @@
|
||||
color: var(--color-primary-600);
|
||||
}
|
||||
|
||||
/* §5B 업무 공통 2번째 내비 그룹 — 콘텐츠 높이만 차지, 사이드바 스크롤. */
|
||||
.kx-shell__sidebar {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.kx-shell__nav {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.kx-shell__nav-label {
|
||||
padding: var(--space-4) var(--space-5) var(--space-2);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-neutral-500);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.kx-shell__user {
|
||||
margin-top: auto;
|
||||
}
|
||||
.kx-shell__avatar--btn {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.kx-shell__user-name--btn {
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.kx-shell__user-name--btn:hover {
|
||||
color: var(--color-primary-600);
|
||||
}
|
||||
|
||||
.kx-shell__main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
14
src/frontend/src/hooks/useResolvedEventId.ts
Normal file
14
src/frontend/src/hooks/useResolvedEventId.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
/**
|
||||
* 라우트에 :eventId 가 없는 화면(SCR-13 /analytics · SCR-16 /ops)에서 대상 행사를 해소한다.
|
||||
* 우선순위: 라우트 param → 로그인 시 선택한 currentEventId → 첫 워크스페이스.
|
||||
* (LoginPage 의 워크스페이스 선택 패턴 — selectEvent(ws.eventId) 로 currentEventId 설정.)
|
||||
*/
|
||||
export function useResolvedEventId(): string | null {
|
||||
const { eventId: routeEventId } = useParams();
|
||||
const currentEventId = useAuthStore((s) => s.currentEventId);
|
||||
const firstWorkspace = useAuthStore((s) => s.workspaces[0]?.eventId ?? null);
|
||||
return routeEventId ?? currentEventId ?? firstWorkspace;
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
@ -10,26 +11,33 @@ import {
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { CHART } from '../chartColors';
|
||||
import {
|
||||
ADMIN_KPIS,
|
||||
LIVE_EVENTS,
|
||||
TENANTS,
|
||||
VISITOR_TREND,
|
||||
} from './sampleAdmin';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import { adminApi } from '../../api/endpoints';
|
||||
import { ApiRequestError } from '../../api/client';
|
||||
import { StatusPill } from '../work/workShared';
|
||||
import type { AdminDashboardData } from '../../api/types';
|
||||
import { ADMIN_KPIS, LIVE_EVENTS, TENANTS, VISITOR_TREND } from './sampleAdmin';
|
||||
import './admin.css';
|
||||
|
||||
const MOCK = `${import.meta.env.BASE_URL}mock/organizer_dashboard`;
|
||||
const THUMBS = [`${MOCK}/img1.jpg`, `${MOCK}/img2.jpg`, `${MOCK}/img3.jpg`];
|
||||
|
||||
/*
|
||||
* SCR-16 관리자 대시보드 (M18 랜딩). Stitch admin_dashboard 이식.
|
||||
* - 시스템 개요: KPI 3 + 방문객 추이 막대(오늘/주간) + 진행중 이벤트 리스트.
|
||||
* ★ 경계면(혼합): 지표 대부분 샘플(집계/센서 미구축). 플로팅 AI 봇은 P2 → 미구현(비노출).
|
||||
* M18 본체(사용자·역할·감사로그·설정)는 후속 화면 세트(/admin/*).
|
||||
* SCR-14 관리자 백오피스 대시보드 (M18 랜딩). Stitch admin_dashboard 이식.
|
||||
* 정상 경로: GET /api/admin/dashboard?tenant (실집계 — 진행/예정 행사·사용자·라이브 점유).
|
||||
* 폴백: NETWORK/NOT_FOUND 시에만 sampleAdmin 로 강등. 관람객 추이는 센서 부재로 백엔드 빈배열 → 빈 상태.
|
||||
*/
|
||||
export function AdminDashboardPage() {
|
||||
const [range, setRange] = useState<'today' | 'weekly'>('today');
|
||||
const [tenant, setTenant] = useState(TENANTS[0]);
|
||||
const [tenant, setTenant] = useState<string | undefined>(undefined);
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['admin-dashboard', tenant],
|
||||
queryFn: () => adminApi.dashboard(tenant),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const degraded = isDegradable(q.error);
|
||||
const data: AdminDashboardData | null = q.data ?? (degraded ? FALLBACK : null);
|
||||
const hardError = q.isError && !degraded;
|
||||
const tenants = data?.tenants?.length ? data.tenants : TENANTS;
|
||||
const activeTenant = tenant ?? tenants[0];
|
||||
|
||||
return (
|
||||
<div className="kx-admin">
|
||||
@ -39,18 +47,16 @@ export function AdminDashboardPage() {
|
||||
<p className="kx-admin__subtitle">실시간 지표 및 운영 상태</p>
|
||||
</div>
|
||||
<div className="kx-admin__head-actions">
|
||||
{/* 관리자 하위 화면 — 로그인 슬라이드 관리 */}
|
||||
<Link to="/admin/login-slides" className="kx-btn kx-btn--secondary">
|
||||
로그인 슬라이드 관리
|
||||
</Link>
|
||||
{/* 플랫폼 슈퍼관리자 전용 테넌트 스위처(샘플) */}
|
||||
<select
|
||||
className="kx-select"
|
||||
aria-label="테넌트 전환"
|
||||
value={tenant}
|
||||
value={activeTenant}
|
||||
onChange={(e) => setTenant(e.target.value)}
|
||||
>
|
||||
{TENANTS.map((t) => (
|
||||
{tenants.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
@ -60,79 +66,92 @@ export function AdminDashboardPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="kx-admin__degraded-bar">
|
||||
<span className="kx-bi__degraded">집계 API 대기 (샘플)</span>
|
||||
실시간 방문객·주차·추이 위젯은 시연용 샘플입니다. 시스템관리(M18) 지표는 후속 연동됩니다.
|
||||
</div>
|
||||
{degraded && (
|
||||
<div className="kx-admin__degraded-bar">
|
||||
<span className="kx-bi__degraded">오프라인 — 샘플</span>
|
||||
집계 API에 연결하지 못해 시연용 샘플을 표시합니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="kx-admin__kpis" aria-label="핵심 지표">
|
||||
{ADMIN_KPIS.map((k) => (
|
||||
<div key={k.key} className={`kx-kpi kx-kpi--${k.tone ?? 'normal'}`}>
|
||||
<span className="kx-kpi__label">{k.label}</span>
|
||||
<strong className="kx-kpi__value tnum">{k.value}</strong>
|
||||
{k.delta && (
|
||||
<span className={`kx-delta kx-delta--${k.deltaDir ?? 'up'}`}>
|
||||
{k.tone === 'warn' ? '⚠' : k.deltaDir === 'down' ? '▼' : '▲'} {k.delta}
|
||||
</span>
|
||||
)}
|
||||
{k.bar != null && (
|
||||
<div className="kx-barlist__track" style={{ marginTop: 8 }}>
|
||||
<span className="kx-barlist__fill is-error" style={{ width: `${k.bar}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
{q.isLoading && <AdminSkeleton />}
|
||||
|
||||
<div className="kx-admin__split">
|
||||
<section className="kx-card kx-admin__chart-card" aria-label="방문객 추이">
|
||||
<div className="kx-card__head">
|
||||
<h2>방문객 추이 ({range === 'today' ? '오늘' : '주간 평균'})</h2>
|
||||
<div className="kx-seg" role="tablist" aria-label="추이 기간">
|
||||
<button role="tab" aria-selected={range === 'today'} className={`kx-seg__btn ${range === 'today' ? 'is-active' : ''}`} onClick={() => setRange('today')}>오늘</button>
|
||||
<button role="tab" aria-selected={range === 'weekly'} className={`kx-seg__btn ${range === 'weekly' ? 'is-active' : ''}`} onClick={() => setRange('weekly')}>주간</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kx-admin__chart">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={VISITOR_TREND} margin={{ top: 8, right: 8, bottom: 0, left: -8 }}>
|
||||
<CartesianGrid stroke={CHART.neutral200} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="hour" tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={{ stroke: CHART.neutral200 }} />
|
||||
<YAxis tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={false} width={44} />
|
||||
<Tooltip cursor={{ fill: CHART.primary050 }} contentStyle={TOOLTIP_STYLE} />
|
||||
<Bar dataKey={range} name="방문객" fill={CHART.primary600} radius={[4, 4, 0, 0]} maxBarSize={34} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</section>
|
||||
{hardError && (
|
||||
<ErrorState message="관리자 대시보드를 불러오지 못했습니다." onRetry={() => q.refetch()} />
|
||||
)}
|
||||
|
||||
<aside className="kx-admin__live" aria-label="진행 중 이벤트">
|
||||
<div className="kx-card__head">
|
||||
<h2>진행 중 이벤트</h2>
|
||||
</div>
|
||||
<div className="kx-admin__live-list">
|
||||
{LIVE_EVENTS.map((e) => (
|
||||
<article key={e.id} className="kx-livecard">
|
||||
<div className="kx-livecard__thumb" style={{ backgroundImage: `url(${THUMBS[e.thumbIdx]})` }} aria-hidden="true" />
|
||||
<div className="kx-livecard__body">
|
||||
<div className="kx-livecard__top">
|
||||
<span className={`kx-livecard__dot ${e.live ? 'is-live' : 'is-prep'}`} aria-hidden="true" />
|
||||
<span className="kx-livecard__state">{e.live ? 'LIVE NOW' : '준비중'}</span>
|
||||
</div>
|
||||
<h3 className="kx-livecard__title">{e.title}</h3>
|
||||
<p className="kx-livecard__meta">{e.hallTime}</p>
|
||||
{e.progress != null && (
|
||||
<div className="kx-barlist__track">
|
||||
<span className="kx-barlist__fill is-warning" style={{ width: `${e.progress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{data && (
|
||||
<>
|
||||
<section className="kx-admin__kpis" aria-label="핵심 지표">
|
||||
{data.kpis.length === 0 ? (
|
||||
<EmptyState title="집계된 지표가 없습니다" />
|
||||
) : (
|
||||
data.kpis.map((k, i) => (
|
||||
<div key={`${k.label}-${i}`} className="kx-kpi kx-kpi--normal">
|
||||
<span className="kx-kpi__label">{k.label}</span>
|
||||
<strong className="kx-kpi__value tnum">{k.value}</strong>
|
||||
{k.sub && <span className="kx-kpi__sub">{k.sub}</span>}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="kx-admin__split">
|
||||
<section className="kx-card kx-admin__chart-card" aria-label="방문객 추이">
|
||||
<div className="kx-card__head">
|
||||
<h2>시간대별 관람객</h2>
|
||||
</div>
|
||||
<div className="kx-admin__chart">
|
||||
{data.visitorTrend.length === 0 ? (
|
||||
<EmptyState
|
||||
title="관람객 추이 미집계"
|
||||
description="관람객 계측 센서(M14)가 연동되면 시간대별 추이가 표시됩니다."
|
||||
/>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data.visitorTrend} margin={{ top: 8, right: 8, bottom: 0, left: -8 }}>
|
||||
<CartesianGrid stroke={CHART.neutral200} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="hour" tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={{ stroke: CHART.neutral200 }} />
|
||||
<YAxis tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={false} width={44} />
|
||||
<Tooltip cursor={{ fill: CHART.primary050 }} contentStyle={TOOLTIP_STYLE} />
|
||||
<Bar dataKey="count" name="관람객" fill={CHART.primary600} radius={[4, 4, 0, 0]} maxBarSize={34} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="kx-admin__live" aria-label="진행 중 이벤트">
|
||||
<div className="kx-card__head">
|
||||
<h2>진행 중 이벤트</h2>
|
||||
</div>
|
||||
{data.liveEvents.length === 0 ? (
|
||||
<EmptyState title="진행 중인 행사가 없습니다" />
|
||||
) : (
|
||||
<div className="kx-admin__live-list">
|
||||
{data.liveEvents.map((e) => (
|
||||
<article key={e.eventId} className="kx-livecard">
|
||||
<div className="kx-livecard__body">
|
||||
<div className="kx-livecard__top">
|
||||
<span className="kx-livecard__dot is-live" aria-hidden="true" />
|
||||
<StatusPill value={e.status} />
|
||||
</div>
|
||||
<h3 className="kx-livecard__title">{e.name}</h3>
|
||||
<p className="kx-livecard__meta">{e.hall ?? '홀 미배정'}</p>
|
||||
<div className="kx-livecard__occ">
|
||||
<div className="kx-barlist__track">
|
||||
<span className="kx-barlist__fill is-warning" style={{ width: `${clamp(e.occupancy)}%` }} />
|
||||
</div>
|
||||
<span className="kx-livecard__occ-val tnum">점유 {clamp(e.occupancy)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{/* 플로팅 AI 컨시어지 버튼: PLANNING M18 미포함 → P2/후속. 현재 비노출. */}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -143,3 +162,44 @@ const TOOLTIP_STYLE = {
|
||||
border: '1px solid #E4E7EC',
|
||||
boxShadow: '0 4px 12px rgba(16,24,40,0.1)',
|
||||
} as const;
|
||||
|
||||
function AdminSkeleton() {
|
||||
return (
|
||||
<div aria-hidden="true" style={{ display: 'grid', gap: 16 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} height={92} radius={12} />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton height={300} radius={12} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function clamp(n: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round(n)));
|
||||
}
|
||||
|
||||
function isDegradable(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiRequestError &&
|
||||
(error.code === 'NETWORK' || error.code === 'NOT_FOUND' || error.code === 'NOT_IMPLEMENTED')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 샘플 폴백(백엔드 shape 매핑) — 정상 경로는 실 API. NETWORK/NOT_FOUND 시에만 사용.
|
||||
* 근거: 기존 sampleAdmin.ts(집계·센서 미구축 시연 데이터)를 AdminDashboardData 로 강등 변환.
|
||||
*/
|
||||
const FALLBACK: AdminDashboardData = {
|
||||
kpis: ADMIN_KPIS.map((k) => ({ label: k.label, value: k.value, sub: k.delta ?? null })),
|
||||
visitorTrend: VISITOR_TREND.map((p) => ({ hour: p.hour, count: p.today })),
|
||||
liveEvents: LIVE_EVENTS.filter((e) => e.live).map((e) => ({
|
||||
eventId: e.id,
|
||||
name: e.title,
|
||||
hall: e.hallTime,
|
||||
occupancy: e.progress ?? 60,
|
||||
status: 'ongoing',
|
||||
})),
|
||||
tenants: TENANTS,
|
||||
};
|
||||
|
||||
@ -147,6 +147,20 @@
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
.kx-livecard__occ {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.kx-livecard__occ .kx-barlist__track {
|
||||
flex: 1;
|
||||
}
|
||||
.kx-livecard__occ-val {
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--color-neutral-700);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.kx-admin__kpis {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
@ -8,40 +9,53 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { AiLabel, StatusBadge } from '../../components/ui/Badge';
|
||||
import { AiLabel } from '../../components/ui/Badge';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import { CHART } from '../chartColors';
|
||||
import { analyticsApi } from '../../api/endpoints';
|
||||
import { ApiRequestError } from '../../api/client';
|
||||
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
|
||||
import type {
|
||||
AnalyticsData,
|
||||
AnalyticsKpi,
|
||||
AnalyticsPeriod,
|
||||
AnalyticsPerspective,
|
||||
} from '../../api/types';
|
||||
import {
|
||||
EXHIBITOR_KPIS,
|
||||
EXHIBITOR_PERF,
|
||||
EXHIBITOR_TREND,
|
||||
GRADE_LABEL,
|
||||
OPERATOR_KPIS,
|
||||
OPERATOR_PNL,
|
||||
OPERATOR_TREND,
|
||||
SECTORS,
|
||||
type Kpi,
|
||||
type Perspective,
|
||||
type Period,
|
||||
} from './sampleAnalytics';
|
||||
import './analytics.css';
|
||||
|
||||
/*
|
||||
* SCR-13 경영분석 대시보드 (M16 BI). Stitch business_intelligence 이식.
|
||||
* - 관점 토글(운영사/참가업체)로 KPI·추세·성과 테이블 격리 전환.
|
||||
* ★ 경계면: BI 집계·예측 파이프라인 미구축(PLANNING §5A M16-1) → 전 지표 샘플 시연.
|
||||
* (라우트 /analytics 는 테넌트 전역. 행사 P&L 드릴다운은 후속.)
|
||||
* 정상 경로: GET /api/events/{eventId}/analytics?perspective&period (실집계 — 매출·부스 추이·손익).
|
||||
* 관점(운영사/참가업체)·기간 토글이 쿼리 파라미터를 구동 → 실 refetch.
|
||||
* 폴백: NETWORK/NOT_FOUND 시에만 sampleAnalytics 로 강등. sectors·exhibitorPerf 원천 부재 시 백엔드 빈배열 → 빈 상태.
|
||||
*/
|
||||
export function AnalyticsDashboardPage() {
|
||||
const [perspective, setPerspective] = useState<Perspective>('operator');
|
||||
const [period, setPeriod] = useState<Period>('event');
|
||||
|
||||
const [perspective, setPerspective] = useState<AnalyticsPerspective>('operator');
|
||||
const [period, setPeriod] = useState<AnalyticsPeriod>('event');
|
||||
const eventId = useResolvedEventId();
|
||||
const isOperator = perspective === 'operator';
|
||||
const kpis = isOperator ? OPERATOR_KPIS : EXHIBITOR_KPIS;
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['analytics', eventId, perspective, period],
|
||||
queryFn: () => analyticsApi.get(eventId as string, perspective, period),
|
||||
enabled: !!eventId,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const degraded = isDegradable(q.error);
|
||||
const data: AnalyticsData | null = q.data ?? (degraded ? fallback(perspective) : null);
|
||||
const hardError = q.isError && !degraded;
|
||||
|
||||
return (
|
||||
<div className="kx-bi">
|
||||
{/* 헤더 */}
|
||||
<header className="kx-bi__head">
|
||||
<div>
|
||||
<h1 className="kx-bi__title">경영분석</h1>
|
||||
@ -70,7 +84,7 @@ export function AnalyticsDashboardPage() {
|
||||
className="kx-select"
|
||||
aria-label="분석 기간"
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value as Period)}
|
||||
onChange={(e) => setPeriod(e.target.value as AnalyticsPeriod)}
|
||||
>
|
||||
<option value="event">이번 행사</option>
|
||||
<option value="d90">최근 90일</option>
|
||||
@ -81,138 +95,155 @@ export function AnalyticsDashboardPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="kx-bi__degraded-bar">
|
||||
<span className="kx-bi__degraded">집계 API 대기 (샘플)</span>
|
||||
BI 데이터마트·예측 파이프라인 미구축 — 아래 지표는 시연용 샘플입니다.
|
||||
</div>
|
||||
{!eventId && (
|
||||
<EmptyState title="행사를 선택해 주세요" description="분석할 행사가 지정되지 않았습니다." />
|
||||
)}
|
||||
|
||||
{/* KPI 밴드 */}
|
||||
<section className="kx-bi__kpis" aria-label="핵심 지표">
|
||||
{kpis.map((k) => (
|
||||
<KpiCard key={k.key} kpi={k} />
|
||||
))}
|
||||
</section>
|
||||
{degraded && (
|
||||
<div className="kx-bi__degraded-bar">
|
||||
<span className="kx-bi__degraded">오프라인 — 샘플</span>
|
||||
집계 API에 연결하지 못해 시연용 샘플을 표시합니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 히어로 추세(2/3) + 업종 구성(1/3) */}
|
||||
<div className="kx-bi__split">
|
||||
<section className="kx-card kx-bi__trend" aria-label="추세 차트">
|
||||
<div className="kx-card__head">
|
||||
<h2>{isOperator ? '매출 구성 추이' : '참가업체 vs 바이어 매칭 추이'}</h2>
|
||||
<AiLabel>AI 수요예측</AiLabel>
|
||||
</div>
|
||||
<p className="kx-card__hint">우측 점선 구간은 AI 예측치입니다 (단위: 백만원 / 건).</p>
|
||||
<div className="kx-bi__chart">
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
{isOperator ? (
|
||||
<AreaChart data={OPERATOR_TREND} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||
<defs>
|
||||
<linearGradient id="gRental" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={CHART.primary600} stopOpacity={0.35} />
|
||||
<stop offset="100%" stopColor={CHART.primary600} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid stroke={CHART.neutral200} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={{ stroke: CHART.neutral200 }} />
|
||||
<YAxis tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={false} width={44} />
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
||||
<Area type="monotone" dataKey="auction" name="옥션 수수료" stackId="1" stroke={CHART.aiAccent} fill={CHART.aiAccent} fillOpacity={0.12} strokeWidth={2} />
|
||||
<Area type="monotone" dataKey="utility" name="유틸리티" stackId="1" stroke={CHART.slate} fill={CHART.slate} fillOpacity={0.12} strokeWidth={2} />
|
||||
<Area type="monotone" dataKey="rental" name="임대" stackId="1" stroke={CHART.primary600} fill="url(#gRental)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
{q.isLoading && <BiSkeleton />}
|
||||
|
||||
{hardError && (
|
||||
<ErrorState message="경영분석 집계를 불러오지 못했습니다." onRetry={() => q.refetch()} />
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{/* KPI 밴드 */}
|
||||
<section className="kx-bi__kpis" aria-label="핵심 지표">
|
||||
{data.kpis.length === 0 ? (
|
||||
<EmptyState title="집계된 지표가 없습니다" />
|
||||
) : (
|
||||
data.kpis.map((k, i) => <KpiCard key={`${k.label}-${i}`} kpi={k} />)
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 히어로 추세(2/3) + 업종 구성(1/3) */}
|
||||
<div className="kx-bi__split">
|
||||
<section className="kx-card kx-bi__trend" aria-label="추세 차트">
|
||||
<div className="kx-card__head">
|
||||
<h2>매출 · 부스 추이</h2>
|
||||
<AiLabel>월별 집계</AiLabel>
|
||||
</div>
|
||||
<p className="kx-card__hint">월별 임대·유틸리티 매출과 부스 수 추이입니다 (매출 단위: 원).</p>
|
||||
<div className="kx-bi__chart">
|
||||
{data.trend.length === 0 ? (
|
||||
<EmptyState title="추이 데이터가 없습니다" description="집계 가능한 기간의 매출·부스 레코드가 없습니다." />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<AreaChart data={data.trend} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||
<defs>
|
||||
<linearGradient id="gRevenue" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={CHART.primary600} stopOpacity={0.35} />
|
||||
<stop offset="100%" stopColor={CHART.primary600} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid stroke={CHART.neutral200} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="period" tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={{ stroke: CHART.neutral200 }} />
|
||||
<YAxis yAxisId="rev" tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={false} width={54} tickFormatter={(v) => shortWon(v as number)} />
|
||||
<YAxis yAxisId="booth" orientation="right" tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={false} width={36} />
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
formatter={(value, name) =>
|
||||
name === '매출' ? formatWon(value as number) : `${(value as number).toLocaleString()} 부스`
|
||||
}
|
||||
/>
|
||||
<Area yAxisId="rev" type="monotone" dataKey="revenue" name="매출" stroke={CHART.primary600} fill="url(#gRevenue)" strokeWidth={2} />
|
||||
<Area yAxisId="booth" type="monotone" dataKey="booths" name="부스" stroke={CHART.aiAccent} fill={CHART.aiAccent} fillOpacity={0.08} strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="kx-card kx-bi__sectors" aria-label="업종별 구성">
|
||||
<div className="kx-card__head">
|
||||
<h2>업종별 구성</h2>
|
||||
</div>
|
||||
{data.sectors.length === 0 ? (
|
||||
<EmptyState
|
||||
title="업종 분류 미집계"
|
||||
description="업종 분류 원천(M13)이 연동되면 표시됩니다."
|
||||
/>
|
||||
) : (
|
||||
<AreaChart data={EXHIBITOR_TREND} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||
<CartesianGrid stroke={CHART.neutral200} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={{ stroke: CHART.neutral200 }} />
|
||||
<YAxis tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={false} width={44} />
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
||||
<Area type="monotone" dataKey="exhibitors" name="참가업체" stroke={CHART.primary600} fill={CHART.primary600} fillOpacity={0.12} strokeWidth={2} />
|
||||
<Area type="monotone" dataKey="buyers" name="바이어" stroke={CHART.aiAccent} fill={CHART.aiAccent} fillOpacity={0.1} strokeWidth={2} />
|
||||
</AreaChart>
|
||||
<ul className="kx-barlist">
|
||||
{data.sectors.map((s) => (
|
||||
<li key={s.name} className="kx-barlist__item">
|
||||
<div className="kx-barlist__top">
|
||||
<span>{s.name}</span>
|
||||
<span className="tnum">{s.sharePercent.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="kx-barlist__track">
|
||||
<span className="kx-barlist__fill" style={{ width: `${s.sharePercent}%` }} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="kx-card kx-bi__sectors" aria-label="업종별 구성">
|
||||
<div className="kx-card__head">
|
||||
<h2>업종별 구성</h2>
|
||||
</div>
|
||||
<ul className="kx-barlist">
|
||||
{SECTORS.map((s) => (
|
||||
<li key={s.label} className="kx-barlist__item">
|
||||
<div className="kx-barlist__top">
|
||||
<span>{s.label}</span>
|
||||
<span className="tnum">{s.pct}%</span>
|
||||
</div>
|
||||
<div className="kx-barlist__track">
|
||||
<span className="kx-barlist__fill" style={{ width: `${s.pct}%` }} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 성과 테이블(전폭) */}
|
||||
<section className="kx-card kx-bi__table-card" aria-label="성과 테이블">
|
||||
<div className="kx-card__head">
|
||||
<h2>{isOperator ? '홀·행사별 손익(P&L)' : '상위 참가사 성과'}</h2>
|
||||
<span className="kx-bi__degraded">집계 API 대기 (샘플)</span>
|
||||
</div>
|
||||
<div className="kx-table-scroll">
|
||||
{isOperator ? (
|
||||
<table className="kx-table kx-table--zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>행사</th>
|
||||
<th>홀</th>
|
||||
<th className="kx-num">매출</th>
|
||||
<th className="kx-num">공헌이익</th>
|
||||
<th className="kx-num">마진율</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{OPERATOR_PNL.map((r) => (
|
||||
<tr key={r.event}>
|
||||
<td>{r.event}</td>
|
||||
<td>{r.hall}</td>
|
||||
<td className="kx-num tnum">{r.revenue}</td>
|
||||
<td className="kx-num tnum">{r.contribution}</td>
|
||||
<td className="kx-num tnum">{r.margin}</td>
|
||||
<td><GradePill grade={r.grade} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<table className="kx-table kx-table--zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>업체명</th>
|
||||
<th>부스</th>
|
||||
<th className="kx-num">방문 수</th>
|
||||
<th className="kx-num">매칭 수</th>
|
||||
<th>설계 상태</th>
|
||||
<th>성과</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{EXHIBITOR_PERF.map((r) => (
|
||||
<tr key={r.company}>
|
||||
<td>{r.company}</td>
|
||||
<td className="tnum">{r.booth}</td>
|
||||
<td className="kx-num tnum">{r.visits.toLocaleString()}</td>
|
||||
<td className="kx-num tnum">{r.matches}</td>
|
||||
<td><StatusBadge status={r.status} /></td>
|
||||
<td><GradePill grade={r.grade} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{/* 성과 테이블(전폭) */}
|
||||
<section className="kx-card kx-bi__table-card" aria-label="성과 테이블">
|
||||
<div className="kx-card__head">
|
||||
<h2>{isOperator ? '손익(P&L) 구성' : '참가사 성과'}</h2>
|
||||
{degraded && <span className="kx-bi__degraded">샘플</span>}
|
||||
</div>
|
||||
<div className="kx-table-scroll">
|
||||
{isOperator ? (
|
||||
data.pnl.length === 0 ? (
|
||||
<EmptyState title="손익 항목이 없습니다" description="부스 임대·유틸리티 매출 레코드가 집계되면 표시됩니다." />
|
||||
) : (
|
||||
<table className="kx-table kx-table--zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>항목</th>
|
||||
<th className="kx-num">금액</th>
|
||||
<th>등급</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.pnl.map((r, i) => (
|
||||
<tr key={`${r.item}-${i}`}>
|
||||
<td>{r.item}</td>
|
||||
<td className="kx-num tnum">{formatWon(r.amount)}</td>
|
||||
<td><GradePill grade={r.grade} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
) : data.exhibitorPerf.length === 0 ? (
|
||||
<EmptyState title="참가사 성과 미집계" description="리드·전환율 원천(M13 방문자)이 연동되면 표시됩니다." />
|
||||
) : (
|
||||
<table className="kx-table kx-table--zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>업체명</th>
|
||||
<th className="kx-num">리드</th>
|
||||
<th className="kx-num">전환율</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.exhibitorPerf.map((r, i) => (
|
||||
<tr key={`${r.companyName}-${i}`}>
|
||||
<td>{r.companyName}</td>
|
||||
<td className="kx-num tnum">{r.leads.toLocaleString()}</td>
|
||||
<td className="kx-num tnum">{r.conversionPercent.toFixed(1)}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -224,23 +255,102 @@ const TOOLTIP_STYLE = {
|
||||
boxShadow: '0 4px 12px rgba(16,24,40,0.1)',
|
||||
} as const;
|
||||
|
||||
function KpiCard({ kpi }: { kpi: Kpi }) {
|
||||
function BiSkeleton() {
|
||||
return (
|
||||
<div className={`kx-kpi kx-kpi--${kpi.tone ?? 'normal'}`}>
|
||||
<span className="kx-kpi__label">
|
||||
{kpi.label}
|
||||
{kpi.ai && <span className="kx-kpi__ai">✦ AI</span>}
|
||||
</span>
|
||||
<div aria-hidden="true" style={{ display: 'grid', gap: 16 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12 }}>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} height={92} radius={12} />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton height={320} radius={12} />
|
||||
<Skeleton height={220} radius={12} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCard({ kpi }: { kpi: AnalyticsKpi }) {
|
||||
const dir = kpi.trend === 'down' ? 'down' : 'up';
|
||||
return (
|
||||
<div className={`kx-kpi kx-kpi--${kpi.trend === 'down' ? 'warn' : 'normal'}`}>
|
||||
<span className="kx-kpi__label">{kpi.label}</span>
|
||||
<strong className="kx-kpi__value tnum">{kpi.value}</strong>
|
||||
{kpi.delta && (
|
||||
<span className={`kx-delta kx-delta--${kpi.deltaDir ?? 'up'}`}>
|
||||
{kpi.deltaDir === 'down' ? '▼' : '▲'} {kpi.delta}
|
||||
<span className={`kx-delta kx-delta--${dir}`}>
|
||||
{kpi.trend === 'down' ? '▼' : kpi.trend === 'up' ? '▲' : ''} {kpi.delta}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GradePill({ grade }: { grade: 'excellent' | 'good' | 'fair' | 'review' }) {
|
||||
return <span className={`kx-grade kx-grade--${grade}`}>{GRADE_LABEL[grade]}</span>;
|
||||
type GradeKey = 'excellent' | 'good' | 'fair' | 'review';
|
||||
const GRADE_MAP: Record<string, { key: GradeKey; label: string }> = {
|
||||
A: { key: 'excellent', label: 'A' },
|
||||
B: { key: 'good', label: 'B' },
|
||||
C: { key: 'fair', label: 'C' },
|
||||
};
|
||||
function GradePill({ grade }: { grade: string }) {
|
||||
const g = GRADE_MAP[grade] ?? { key: 'review' as GradeKey, label: grade };
|
||||
return <span className={`kx-grade kx-grade--${g.key}`}>{g.label}</span>;
|
||||
}
|
||||
|
||||
/** 원 금액 → 억/만 단위 축약. */
|
||||
function formatWon(v: number): string {
|
||||
if (v >= 1e8) return `₩${(v / 1e8).toFixed(2)}억`;
|
||||
if (v >= 1e4) return `₩${Math.round(v / 1e4).toLocaleString()}만`;
|
||||
return `₩${v.toLocaleString()}`;
|
||||
}
|
||||
function shortWon(v: number): string {
|
||||
if (v >= 1e8) return `${(v / 1e8).toFixed(0)}억`;
|
||||
if (v >= 1e4) return `${Math.round(v / 1e4)}만`;
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function isDegradable(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiRequestError &&
|
||||
(error.code === 'NETWORK' || error.code === 'NOT_FOUND' || error.code === 'NOT_IMPLEMENTED')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 샘플 폴백(백엔드 shape 매핑) — 정상 경로는 실 API. NETWORK/NOT_FOUND 시에만 사용.
|
||||
* 근거: 기존 sampleAnalytics.ts(BI 데이터마트 미구축 시연 데이터)를 AnalyticsData 로 강등 변환.
|
||||
*/
|
||||
function fallback(perspective: AnalyticsPerspective): AnalyticsData {
|
||||
const isOperator = perspective === 'operator';
|
||||
const kpis = (isOperator ? OPERATOR_KPIS : EXHIBITOR_KPIS).map((k) => ({
|
||||
label: k.label,
|
||||
value: k.value,
|
||||
delta: k.delta ?? null,
|
||||
trend: k.deltaDir === 'down' ? 'down' : k.deltaDir === 'up' ? 'up' : 'flat',
|
||||
}));
|
||||
return {
|
||||
kpis,
|
||||
trend: [],
|
||||
sectors: SECTORS.map((s) => ({ name: s.label, sharePercent: s.pct, revenue: 0 })),
|
||||
pnl: isOperator
|
||||
? OPERATOR_PNL.map((r) => ({
|
||||
item: `${r.event} (${r.hall})`,
|
||||
amount: parseWon(r.revenue),
|
||||
grade: r.grade === 'excellent' ? 'A' : r.grade === 'good' ? 'B' : r.grade === 'fair' ? 'C' : 'C',
|
||||
}))
|
||||
: [],
|
||||
exhibitorPerf: isOperator
|
||||
? []
|
||||
: EXHIBITOR_PERF.map((r) => ({
|
||||
companyName: r.company,
|
||||
leads: r.matches,
|
||||
conversionPercent: r.visits > 0 ? (r.matches / r.visits) * 100 : 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
/** "₩412M" 류 표시 문자열 → 원 단위 근사(폴백 전용). */
|
||||
function parseWon(s: string): number {
|
||||
const n = parseFloat(s.replace(/[^0-9.]/g, ''));
|
||||
if (Number.isNaN(n)) return 0;
|
||||
if (/B/i.test(s)) return Math.round(n * 1e9);
|
||||
if (/M/i.test(s)) return Math.round(n * 1e6);
|
||||
return Math.round(n);
|
||||
}
|
||||
|
||||
@ -1,29 +1,33 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { StatusBadge, AiLabel } from '../../components/ui/Badge';
|
||||
import { AiLabel } from '../../components/ui/Badge';
|
||||
import { AiImage } from '../../components/ui/AiImage';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import { dashboardApi } from '../../api/endpoints';
|
||||
import { ApiRequestError } from '../../api/client';
|
||||
import type { DashboardData } from '../../api/types';
|
||||
import { StatusPill, timeAgo } from '../work/workShared';
|
||||
import {
|
||||
SAMPLE_EXHIBITORS,
|
||||
SAMPLE_FEED,
|
||||
SAMPLE_KPIS,
|
||||
SAMPLE_MILESTONES,
|
||||
type Milestone,
|
||||
} from './sampleDashboard';
|
||||
import './dashboard.css';
|
||||
|
||||
// Stitch organizer_dashboard 번들 이미지(실 KINTEX 촬영본).
|
||||
// Stitch organizer_dashboard 번들 이미지(실 KINTEX 촬영본 — 히어로 시각화 데코, AI 샘플 라벨).
|
||||
const MOCK = `${import.meta.env.BASE_URL}mock/organizer_dashboard`;
|
||||
const S7_AERIAL = `${MOCK}/img3.jpg`; // 홀 전경 조감(아이소메트릭 항공뷰)
|
||||
const BOOTH_THUMBS = [`${MOCK}/img1.jpg`, `${MOCK}/img2.jpg`, `${MOCK}/img3.jpg`];
|
||||
const S7_AERIAL = `${MOCK}/img3.jpg`;
|
||||
|
||||
/*
|
||||
* SCR-02 주최자 대시보드. Stitch organizer_dashboard 이식.
|
||||
* - 히어로: D-데이 마일스톤 타임라인 + KPI 4개 + 참가업체 테이블 + 알림 피드 + S7 미니 카드.
|
||||
* ★ 경계면 이슈: 계약에 대시보드 집계 엔드포인트 부재 → 마일스톤/KPI/테이블/피드는 샘플 시연.
|
||||
* 행사 헤더(행사명·기간·홀·D-데이)는 인증 워크스페이스 실데이터.
|
||||
* 정상 경로: GET /api/events/{eventId}/dashboard (실집계 — milestones·kpis·exhibitors·feed).
|
||||
* 폴백: NETWORK/NOT_FOUND 시에만 sampleDashboard 로 강등(degraded 배너). 그 외 오류는 재시도 UI.
|
||||
* 백엔드가 빈배열을 주는 섹션은 빈 상태로 표기(가짜 수치로 덮지 않음).
|
||||
*/
|
||||
export function OrganizerDashboardPage() {
|
||||
useParams(); // eventId 스코프(라우팅 확인용)
|
||||
const { eventId } = useParams();
|
||||
const workspace = useAuthStore((s) => s.currentWorkspace());
|
||||
|
||||
const eventName = workspace?.eventName ?? '2026 스마트팩토리 코리아';
|
||||
@ -34,9 +38,20 @@ export function OrganizerDashboardPage() {
|
||||
: '2026.08.11 ~ 08.14';
|
||||
const dday = workspace?.dday ?? 31;
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['dashboard', eventId],
|
||||
queryFn: () => dashboardApi.get(eventId as string),
|
||||
enabled: !!eventId,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const degraded = isDegradable(q.error);
|
||||
const data: DashboardData | null = q.data ?? (degraded ? FALLBACK : null);
|
||||
const hardError = q.isError && !degraded;
|
||||
|
||||
return (
|
||||
<div className="kx-dash">
|
||||
{/* 행사 헤더 */}
|
||||
{/* 행사 헤더 — 인증 워크스페이스 실데이터 */}
|
||||
<header className="kx-dash__event">
|
||||
<div>
|
||||
<h1 className="kx-dash__event-name">{eventName}</h1>
|
||||
@ -47,132 +62,225 @@ export function OrganizerDashboardPage() {
|
||||
<span className="kx-dash__countdown tnum">개장 D-{dday}</span>
|
||||
</header>
|
||||
|
||||
{/* 히어로: 마일스톤 타임라인 */}
|
||||
<section className="kx-dash__hero" aria-label="마일스톤 타임라인">
|
||||
<ol className="kx-timeline">
|
||||
{SAMPLE_MILESTONES.map((m, i) => (
|
||||
<li key={m.key} className={`kx-timeline__node is-${m.state}`}>
|
||||
{i < SAMPLE_MILESTONES.length - 1 && (
|
||||
<span className="kx-timeline__bar" aria-hidden="true" />
|
||||
{degraded && (
|
||||
<div className="kx-dash__degraded-bar">
|
||||
<span className="kx-dash__degraded">오프라인 — 샘플</span>
|
||||
집계 API에 연결하지 못해 시연용 샘플을 표시합니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.isLoading && <DashboardSkeleton />}
|
||||
|
||||
{hardError && (
|
||||
<ErrorState
|
||||
message="대시보드 집계를 불러오지 못했습니다."
|
||||
onRetry={() => q.refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{/* 히어로: 마일스톤 타임라인 */}
|
||||
<section className="kx-dash__hero" aria-label="마일스톤 타임라인">
|
||||
{data.milestones.length === 0 ? (
|
||||
<EmptyState title="파생 일정이 없습니다" description="행사 시작일이 확정되면 마일스톤이 표시됩니다." />
|
||||
) : (
|
||||
<ol className="kx-timeline">
|
||||
{data.milestones.map((m, i) => (
|
||||
<li key={`${m.label}-${i}`} className={`kx-timeline__node is-${milestoneClass(m.state)}`}>
|
||||
{i < data.milestones.length - 1 && (
|
||||
<span className="kx-timeline__bar" aria-hidden="true" />
|
||||
)}
|
||||
<span className="kx-timeline__dot" aria-hidden="true">
|
||||
{m.state === 'done' ? '✓' : ''}
|
||||
</span>
|
||||
<div className="kx-timeline__label">
|
||||
<span className="kx-timeline__d tnum">{ddayLabel(m.date)}</span>
|
||||
<span className="kx-timeline__title">{m.label}</span>
|
||||
</div>
|
||||
<div className="kx-timeline__card">
|
||||
<span className="kx-timeline__muted">{stateLabel(m.state)}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* KPI 카드 */}
|
||||
<section className="kx-dash__kpis" aria-label="핵심 지표">
|
||||
{data.kpis.length === 0 ? (
|
||||
<EmptyState title="집계된 지표가 없습니다" />
|
||||
) : (
|
||||
data.kpis.map((k, i) => (
|
||||
<div key={`${k.label}-${i}`} className={`kx-kpi kx-kpi--${kpiTone(k.trend)}`}>
|
||||
<span className="kx-kpi__label">{k.label}</span>
|
||||
<strong className="kx-kpi__value tnum">{k.value}</strong>
|
||||
{k.delta && (
|
||||
<span className={`kx-delta kx-delta--${k.trend === 'down' ? 'down' : 'up'}`}>
|
||||
{k.trend === 'down' ? '▼' : k.trend === 'up' ? '▲' : ''} {k.delta}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 하단: 참가업체 테이블(2/3) + 알림·S7(1/3) */}
|
||||
<div className="kx-dash__split">
|
||||
<section className="kx-dash__table-wrap" aria-label="참가업체 현황">
|
||||
<div className="kx-dash__section-head">
|
||||
<h2>참가업체 현황</h2>
|
||||
{degraded && <span className="kx-dash__degraded">샘플</span>}
|
||||
</div>
|
||||
{data.exhibitors.length === 0 ? (
|
||||
<EmptyState
|
||||
title="참가업체 부스가 없습니다"
|
||||
description="부스 배치·설계안이 등록되면 여기에 표시됩니다."
|
||||
/>
|
||||
) : (
|
||||
<table className="kx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>부스번호</th>
|
||||
<th>업체명</th>
|
||||
<th>설계 상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.exhibitors.map((r, i) => (
|
||||
<tr key={`${r.boothNo}-${i}`}>
|
||||
<td className="tnum">{r.boothNo}</td>
|
||||
<td>{r.companyName}</td>
|
||||
<td>
|
||||
<StatusPill value={r.status} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<span className="kx-timeline__dot" aria-hidden="true">
|
||||
{m.state === 'done' ? '✓' : ''}
|
||||
</span>
|
||||
<div className="kx-timeline__label">
|
||||
<span className="kx-timeline__d tnum">{m.dLabel}</span>
|
||||
<span className="kx-timeline__title">{m.title}</span>
|
||||
</div>
|
||||
<div className="kx-timeline__card">
|
||||
{m.progress ? (
|
||||
<span className={m.state === 'imminent' ? 'kx-timeline__imminent' : ''}>
|
||||
{m.progress}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<aside className="kx-dash__aside">
|
||||
<section className="kx-feed" aria-label="알림 피드">
|
||||
<h2 className="kx-feed__title">알림</h2>
|
||||
{data.feed.length === 0 ? (
|
||||
<EmptyState title="새 알림이 없습니다" />
|
||||
) : (
|
||||
<span className="kx-timeline__muted">{stateLabel(m)}</span>
|
||||
<ul className="kx-feed__list">
|
||||
{data.feed.map((f) => (
|
||||
<li key={f.id} className={`kx-feed__item is-${feedKind(f.kind)}`}>
|
||||
<span className="kx-feed__dot" aria-hidden="true" />
|
||||
<span className="kx-feed__text">{f.message}</span>
|
||||
<span className="kx-feed__time">{timeAgo(f.at)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{/* KPI 카드 4개 */}
|
||||
<section className="kx-dash__kpis" aria-label="핵심 지표">
|
||||
{SAMPLE_KPIS.map((k) => (
|
||||
<div key={k.key} className={`kx-kpi kx-kpi--${k.tone ?? 'normal'}`}>
|
||||
<span className="kx-kpi__label">{k.label}</span>
|
||||
<strong className="kx-kpi__value tnum">{k.value}</strong>
|
||||
{k.sub && <span className="kx-kpi__sub">{k.sub}</span>}
|
||||
<section className="kx-dash__s7" aria-label="홀 전경 조감">
|
||||
<div className="kx-dash__s7-head">
|
||||
<h2>홀 전경 조감</h2>
|
||||
<AiLabel>S7</AiLabel>
|
||||
</div>
|
||||
<AiImage
|
||||
imageUrl={S7_AERIAL}
|
||||
status="DONE"
|
||||
sample
|
||||
watermarkText="AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있습니다"
|
||||
notice="AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 — 시공 기준은 도면입니다"
|
||||
shotLabel="S7 홀 전경"
|
||||
alt="홀 전경 조감 예상 이미지"
|
||||
/>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* 하단: 참가업체 테이블(2/3) + 알림·S7(1/3) */}
|
||||
<div className="kx-dash__split">
|
||||
<section className="kx-dash__table-wrap" aria-label="참가업체 현황">
|
||||
<div className="kx-dash__section-head">
|
||||
<h2>참가업체 현황</h2>
|
||||
<span className="kx-dash__degraded">집계 API 대기 (샘플)</span>
|
||||
</div>
|
||||
<table className="kx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>부스번호</th>
|
||||
<th>업체명</th>
|
||||
<th>설계 상태</th>
|
||||
<th>유틸리티</th>
|
||||
<th>예상 사진</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{SAMPLE_EXHIBITORS.map((r, i) => (
|
||||
<tr key={r.boothNo}>
|
||||
<td className="tnum">{r.boothNo}</td>
|
||||
<td>{r.company}</td>
|
||||
<td>
|
||||
<StatusBadge status={r.designStatus} />
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge status={r.utilityStatus} />
|
||||
</td>
|
||||
<td>
|
||||
<img
|
||||
className="kx-table__thumb"
|
||||
src={BOOTH_THUMBS[i % BOOTH_THUMBS.length]}
|
||||
alt={`${r.company} 예상 사진 썸네일`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<aside className="kx-dash__aside">
|
||||
<section className="kx-feed" aria-label="알림 피드">
|
||||
<h2 className="kx-feed__title">알림</h2>
|
||||
<ul className="kx-feed__list">
|
||||
{SAMPLE_FEED.map((f) => (
|
||||
<li key={f.key} className={`kx-feed__item is-${f.kind}`}>
|
||||
<span className="kx-feed__dot" aria-hidden="true" />
|
||||
<span className="kx-feed__text">{f.text}</span>
|
||||
<span className="kx-feed__time">{f.time}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="kx-dash__s7" aria-label="홀 전경 조감">
|
||||
<div className="kx-dash__s7-head">
|
||||
<h2>홀 전경 조감</h2>
|
||||
<AiLabel>S7</AiLabel>
|
||||
</div>
|
||||
<AiImage
|
||||
imageUrl={S7_AERIAL}
|
||||
status="DONE"
|
||||
sample
|
||||
watermarkText="AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있습니다"
|
||||
notice="AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 — 시공 기준은 도면입니다"
|
||||
shotLabel="S7 홀 전경"
|
||||
alt="홀 전경 조감 예상 이미지"
|
||||
/>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function stateLabel(m: Milestone): string {
|
||||
switch (m.state) {
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<div aria-hidden="true" style={{ display: 'grid', gap: 16 }}>
|
||||
<Skeleton height={72} radius={12} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12 }}>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} height={92} radius={12} />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton height={260} radius={12} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** NETWORK/NOT_FOUND(백엔드 미배포·미접속) 만 샘플 폴백으로 강등. */
|
||||
function isDegradable(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiRequestError &&
|
||||
(error.code === 'NETWORK' || error.code === 'NOT_FOUND' || error.code === 'NOT_IMPLEMENTED')
|
||||
);
|
||||
}
|
||||
|
||||
function milestoneClass(state: string): string {
|
||||
if (state === 'done') return 'done';
|
||||
if (state === 'current') return 'imminent';
|
||||
return 'upcoming';
|
||||
}
|
||||
function stateLabel(state: string): string {
|
||||
switch (state) {
|
||||
case 'done':
|
||||
return '완료';
|
||||
case 'current':
|
||||
return '진행 중';
|
||||
case 'imminent':
|
||||
return '임박';
|
||||
default:
|
||||
return '예정';
|
||||
}
|
||||
}
|
||||
function kpiTone(trend: string): 'normal' | 'warn' | 'error' {
|
||||
return trend === 'down' ? 'warn' : 'normal';
|
||||
}
|
||||
function feedKind(kind: string): string {
|
||||
if (kind === 'approval' || kind === 'approved') return 'approved';
|
||||
if (kind === 'violation' || kind === 'rejected') return 'rejected';
|
||||
if (kind === 'deadline') return 'deadline';
|
||||
return 'submitted';
|
||||
}
|
||||
/** "YYYY-MM-DD" → D-데이 라벨(오늘 기준). */
|
||||
function ddayLabel(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
if (Number.isNaN(d.getTime())) return dateStr;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
const diff = Math.round((d.getTime() - today.getTime()) / 86400000);
|
||||
if (diff === 0) return 'D-DAY';
|
||||
return diff > 0 ? `D-${diff}` : `D+${-diff}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 샘플 폴백(백엔드 shape 매핑) — 정상 경로는 실 API. NETWORK/NOT_FOUND 시에만 사용.
|
||||
* 근거: 기존 sampleDashboard.ts(경계면 시연 데이터)를 DashboardData 형태로 강등 변환.
|
||||
*/
|
||||
const FALLBACK: DashboardData = {
|
||||
milestones: SAMPLE_MILESTONES.map((m) => ({
|
||||
label: m.title,
|
||||
date: '',
|
||||
state: m.state === 'imminent' ? 'current' : m.state === 'current' ? 'current' : m.state,
|
||||
})),
|
||||
kpis: SAMPLE_KPIS.map((k) => ({
|
||||
label: k.label,
|
||||
value: k.value,
|
||||
delta: k.sub ?? null,
|
||||
trend: k.tone === 'error' ? 'down' : k.tone === 'warn' ? 'down' : 'flat',
|
||||
})),
|
||||
exhibitors: SAMPLE_EXHIBITORS.map((e) => ({
|
||||
companyName: e.company,
|
||||
boothNo: e.boothNo,
|
||||
status: e.designStatus,
|
||||
})),
|
||||
feed: SAMPLE_FEED.map((f) => ({ id: f.key, kind: f.kind, message: f.text, at: '' })),
|
||||
};
|
||||
|
||||
@ -199,6 +199,17 @@
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
.kx-dash__degraded-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--color-neutral-700);
|
||||
background: #fff8ef;
|
||||
border: 1px solid #ffe4bf;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
/* 테이블 */
|
||||
.kx-table {
|
||||
|
||||
@ -10,10 +10,12 @@ import { InviteDialog } from './InviteDialog';
|
||||
import { SignupDialog } from './SignupDialog';
|
||||
import { ForgotPasswordDialog } from './ForgotPasswordDialog';
|
||||
import { LoginSlides } from './LoginSlides';
|
||||
import type { WorkspaceDto } from '../../api/types';
|
||||
import { OtpChallengePanel } from './OtpChallengePanel';
|
||||
import type { LoginResponse, WorkspaceDto } from '../../api/types';
|
||||
import './login.css';
|
||||
|
||||
type Phase = 'login' | 'workspace';
|
||||
type Phase = 'login' | 'otp' | 'workspace';
|
||||
type OtpMode = 'verify' | 'enroll';
|
||||
|
||||
// 아이디 기억 — 이메일만 저장(비번 저장 금지).
|
||||
const REMEMBER_KEY = 'kintex_remember_email';
|
||||
@ -33,6 +35,8 @@ export function LoginPage() {
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [signupOpen, setSignupOpen] = useState(false);
|
||||
const [forgotOpen, setForgotOpen] = useState(false);
|
||||
// 2FA 챌린지 — 1단계 성공 후 OTP 단계로 전환.
|
||||
const [otpChallenge, setOtpChallenge] = useState<{ token: string; mode: OtpMode } | null>(null);
|
||||
|
||||
// 아이디 기억 프리필 — 저장된 이메일이 있으면 채우고 체크 유지.
|
||||
useEffect(() => {
|
||||
@ -43,24 +47,44 @@ export function LoginPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 최종 로그인 확정(공통) — 토큰 적용 + 워크스페이스 단계 이동.
|
||||
function completeLogin(res: LoginResponse) {
|
||||
if (remember) localStorage.setItem(REMEMBER_KEY, email.trim());
|
||||
else localStorage.removeItem(REMEMBER_KEY);
|
||||
applyLogin(res);
|
||||
setOtpChallenge(null);
|
||||
setPhase('workspace');
|
||||
}
|
||||
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await authApi.login({ email, password });
|
||||
if (remember) localStorage.setItem(REMEMBER_KEY, email.trim());
|
||||
else localStorage.removeItem(REMEMBER_KEY);
|
||||
applyLogin(res);
|
||||
setPhase('workspace');
|
||||
// 2FA/잠금 적용 1단계 — status 로 분기.
|
||||
const res = await authApi.secureLogin({ email, password });
|
||||
if (res.status === 'OK' && res.login) {
|
||||
completeLogin(res.login);
|
||||
} else if (res.challengeToken) {
|
||||
// OTP_REQUIRED(등록자 6자리) | OTP_ENROLL(미등록 등록 강제).
|
||||
setOtpChallenge({
|
||||
token: res.challengeToken,
|
||||
mode: res.status === 'OTP_ENROLL' ? 'enroll' : 'verify',
|
||||
});
|
||||
setPhase('otp');
|
||||
} else {
|
||||
setError('로그인 응답을 처리하지 못했습니다. 다시 시도해 주세요.');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) {
|
||||
// 계약: login은 매퍼 대기 시 501(NOT_IMPLEMENTED) — degraded 안내
|
||||
setError(
|
||||
err.code === 'NOT_IMPLEMENTED'
|
||||
? '로그인 서비스가 준비 중입니다. 잠시 후 다시 시도해 주세요.'
|
||||
: err.message,
|
||||
);
|
||||
// 잠금·미구현·자격오류 등 코드별 안내(계정 존재 여부 미노출).
|
||||
if (err.code === 'ACCOUNT_LOCKED') {
|
||||
setError('로그인 시도가 일시적으로 제한되었습니다. 잠시 후 다시 시도해 주세요.');
|
||||
} else if (err.code === 'NOT_IMPLEMENTED') {
|
||||
setError('로그인 서비스가 준비 중입니다. 잠시 후 다시 시도해 주세요.');
|
||||
} else {
|
||||
setError(err.message);
|
||||
}
|
||||
} else {
|
||||
setError('로그인 중 오류가 발생했습니다.');
|
||||
}
|
||||
@ -75,6 +99,28 @@ export function LoginPage() {
|
||||
navigate(`/events/${ws.eventId}/halls/H7/layout`);
|
||||
}
|
||||
|
||||
if (phase === 'otp' && otpChallenge) {
|
||||
return (
|
||||
<div className="kx-login">
|
||||
<LoginSlides />
|
||||
<section className="kx-login__panel">
|
||||
<OtpChallengePanel
|
||||
challengeToken={otpChallenge.token}
|
||||
mode={otpChallenge.mode}
|
||||
onSuccess={completeLogin}
|
||||
onCancel={() => {
|
||||
setOtpChallenge(null);
|
||||
setPassword('');
|
||||
setError(null);
|
||||
setPhase('login');
|
||||
}}
|
||||
/>
|
||||
<p className="kx-login__copy">© 2026 KINTEX. All rights reserved.</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === 'workspace') {
|
||||
return (
|
||||
<div className="kx-ws">
|
||||
|
||||
136
src/frontend/src/screens/login/OtpChallengePanel.tsx
Normal file
136
src/frontend/src/screens/login/OtpChallengePanel.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { authApi } from '../../api/endpoints';
|
||||
import { ApiRequestError } from '../../api/client';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import type { LoginResponse, OtpSetupResponse } from '../../api/types';
|
||||
|
||||
/**
|
||||
* 로그인 2단계 OTP 패널 — LoginPage 1단계 성공 후 표시.
|
||||
* - mode="verify" : 이미 OTP 등록된 사용자 → 6자리 코드만 입력.
|
||||
* - mode="enroll" : 필수 역할 미등록 → setup(QR·시크릿) 발급 후 6자리 확인·활성화.
|
||||
* challengeToken 은 짧은 수명(5분)·1회성 프리오스 토큰(정식 JWT 아님).
|
||||
*/
|
||||
interface Props {
|
||||
challengeToken: string;
|
||||
mode: 'verify' | 'enroll';
|
||||
onSuccess: (res: LoginResponse) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function OtpChallengePanel({ challengeToken, mode, onSuccess, onCancel }: Props) {
|
||||
const [setup, setSetup] = useState<OtpSetupResponse | null>(null);
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loadingSetup, setLoadingSetup] = useState(mode === 'enroll');
|
||||
|
||||
// 등록 강제 모드 — 진입 시 시크릿·QR 발급.
|
||||
useEffect(() => {
|
||||
if (mode !== 'enroll') return;
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await authApi.otpEnrollSetup(challengeToken);
|
||||
if (alive) setSetup(res);
|
||||
} catch (err) {
|
||||
if (alive) setError(messageOf(err));
|
||||
} finally {
|
||||
if (alive) setLoadingSetup(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [mode, challengeToken]);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (code.length !== 6) return;
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res =
|
||||
mode === 'enroll'
|
||||
? await authApi.otpEnrollVerify(challengeToken, code)
|
||||
: await authApi.otpVerify(challengeToken, code);
|
||||
onSuccess(res);
|
||||
} catch (err) {
|
||||
setError(messageOf(err));
|
||||
setCode('');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="kx-login__card">
|
||||
<h2 className="kx-login__title">2차 인증</h2>
|
||||
<p className="kx-login__title-sub">
|
||||
{mode === 'enroll'
|
||||
? '보안 정책에 따라 OTP(Authenticator) 등록이 필요합니다.'
|
||||
: 'Authenticator 앱에 표시된 6자리 코드를 입력하세요.'}
|
||||
</p>
|
||||
|
||||
{mode === 'enroll' && (
|
||||
<div className="kx-otp__enroll">
|
||||
{loadingSetup && <p className="kx-login__title-sub">QR 발급 중…</p>}
|
||||
{setup && (
|
||||
<>
|
||||
<ol className="kx-otp__steps">
|
||||
<li>Google/Microsoft Authenticator 앱에서 아래 QR을 스캔하세요.</li>
|
||||
<li>스캔이 안 되면 수동 키를 직접 입력하세요.</li>
|
||||
<li>앱에 표시된 6자리 코드로 등록을 완료하세요.</li>
|
||||
</ol>
|
||||
<div className="kx-otp__qr">
|
||||
<img src={setup.qrImageDataUri} alt="OTP QR" width={180} height={180} />
|
||||
</div>
|
||||
<div className="kx-otp__secret">
|
||||
<span className="kx-otp__secret-label">수동 입력 키</span>
|
||||
<code>{setup.secret}</code>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="kx-login__form" onSubmit={submit}>
|
||||
<div className="kx-field">
|
||||
<label htmlFor="otp-code">인증 코드 (6자리)</label>
|
||||
<input
|
||||
id="otp-code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
className="kx-otp__code-input"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="kx-login__error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" block disabled={busy || code.length !== 6}>
|
||||
{busy ? '확인 중…' : mode === 'enroll' ? '등록 완료 · 로그인' : '인증 · 로그인'}
|
||||
</Button>
|
||||
<button type="button" className="kx-login__text-link kx-otp__cancel" onClick={onCancel}>
|
||||
← 다시 로그인
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function messageOf(err: unknown): string {
|
||||
if (err instanceof ApiRequestError) {
|
||||
if (err.code === 'OTP_INVALID') return '코드가 올바르지 않습니다. 다시 시도해 주세요.';
|
||||
if (err.code === 'OTP_REQUIRED') return '인증 세션이 만료되었습니다. 다시 로그인해 주세요.';
|
||||
return err.message;
|
||||
}
|
||||
return '인증 중 오류가 발생했습니다.';
|
||||
}
|
||||
168
src/frontend/src/screens/login/OtpSetupPage.tsx
Normal file
168
src/frontend/src/screens/login/OtpSetupPage.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { authApi } from '../../api/endpoints';
|
||||
import { ApiRequestError } from '../../api/client';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import type { OtpSetupResponse } from '../../api/types';
|
||||
import './login.css';
|
||||
|
||||
/**
|
||||
* 2차 인증(Authenticator/TOTP) 관리 — 로그인 상태에서 접근(/otp-setup).
|
||||
* 상태 조회 → 미등록이면 setup(QR)·6자리 confirm → 활성화. 등록 상태면 해제 가능.
|
||||
*/
|
||||
type Phase = 'loading' | 'idle' | 'setup' | 'enabled';
|
||||
|
||||
export function OtpSetupPage() {
|
||||
const navigate = useNavigate();
|
||||
const [phase, setPhase] = useState<Phase>('loading');
|
||||
const [data, setData] = useState<OtpSetupResponse | null>(null);
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const st = await authApi.otpStatus();
|
||||
if (alive) setPhase(st.otpEnabled ? 'enabled' : 'idle');
|
||||
} catch (err) {
|
||||
if (alive) {
|
||||
setError(messageOf(err));
|
||||
setPhase('idle');
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function startSetup() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await authApi.otpSetup();
|
||||
setData(res);
|
||||
setPhase('setup');
|
||||
} catch (err) {
|
||||
setError(messageOf(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await authApi.otpConfirm(code.trim());
|
||||
setData(null);
|
||||
setCode('');
|
||||
setPhase('enabled');
|
||||
} catch (err) {
|
||||
setError(messageOf(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await authApi.otpResetSelf();
|
||||
setPhase('idle');
|
||||
} catch (err) {
|
||||
setError(messageOf(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="kx-otp-page">
|
||||
<div className="kx-login__card">
|
||||
<h2 className="kx-login__title">2차 인증 — Authenticator(OTP)</h2>
|
||||
<p className="kx-login__title-sub">
|
||||
Google·Microsoft Authenticator 등 OTP 앱으로 6자리 코드를 사용하는 2차 인증을 관리합니다.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="kx-login__error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{phase === 'loading' && <p className="kx-login__title-sub">상태 확인 중…</p>}
|
||||
|
||||
{phase === 'idle' && (
|
||||
<div className="kx-otp__enroll">
|
||||
<p className="kx-login__title-sub">등록을 시작하면 QR 코드가 발급됩니다.</p>
|
||||
<Button block disabled={busy} onClick={startSetup}>
|
||||
{busy ? '발급 중…' : 'Authenticator 등록 시작'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'setup' && data && (
|
||||
<div className="kx-otp__enroll">
|
||||
<ol className="kx-otp__steps">
|
||||
<li>Authenticator 앱에서 아래 QR을 스캔하세요.</li>
|
||||
<li>스캔이 안 되면 수동 키를 직접 입력하세요.</li>
|
||||
<li>앱에 표시된 6자리 코드를 입력해 확인하세요.</li>
|
||||
</ol>
|
||||
<div className="kx-otp__qr">
|
||||
<img src={data.qrImageDataUri} alt="OTP QR" width={180} height={180} />
|
||||
</div>
|
||||
<div className="kx-otp__secret">
|
||||
<span className="kx-otp__secret-label">수동 입력 키</span>
|
||||
<code>{data.secret}</code>
|
||||
</div>
|
||||
<div className="kx-field">
|
||||
<label htmlFor="otp-confirm">앱에 표시된 6자리 코드</label>
|
||||
<input
|
||||
id="otp-confirm"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
className="kx-otp__code-input"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
/>
|
||||
</div>
|
||||
<Button block disabled={busy || code.length !== 6} onClick={confirm}>
|
||||
{busy ? '확인 중…' : '코드 확인 · 활성화'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'enabled' && (
|
||||
<div className="kx-otp__enroll">
|
||||
<p className="kx-otp__ok">2차 인증(Authenticator)이 활성화되어 있습니다.</p>
|
||||
<Button variant="secondary" block disabled={busy} onClick={disable}>
|
||||
{busy ? '처리 중…' : 'Authenticator 해제(이메일 인증으로 복귀)'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="kx-login__text-link kx-otp__cancel"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
← 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function messageOf(err: unknown): string {
|
||||
if (err instanceof ApiRequestError) {
|
||||
if (err.code === 'OTP_INVALID') return '코드가 올바르지 않습니다. 다시 시도해 주세요.';
|
||||
return err.message;
|
||||
}
|
||||
return 'OTP 처리 중 오류가 발생했습니다.';
|
||||
}
|
||||
@ -494,3 +494,67 @@
|
||||
font-size: var(--fs-body);
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* ── 2차 인증(OTP) 패널 ─────────────────────────────────────────── */
|
||||
.kx-otp-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4);
|
||||
background: var(--color-neutral-050);
|
||||
}
|
||||
.kx-otp__enroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.kx-otp__steps {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
font-size: var(--fs-caption);
|
||||
line-height: 1.8;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
.kx-otp__qr {
|
||||
text-align: center;
|
||||
}
|
||||
.kx-otp__qr img {
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 8px;
|
||||
background: var(--color-white);
|
||||
padding: 8px;
|
||||
}
|
||||
.kx-otp__secret {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.kx-otp__secret-label {
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
.kx-otp__secret code {
|
||||
display: block;
|
||||
word-break: break-all;
|
||||
background: var(--color-neutral-050);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
color: var(--color-neutral-900);
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
.kx-otp__code-input {
|
||||
letter-spacing: 6px;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
.kx-otp__ok {
|
||||
font-size: var(--fs-body);
|
||||
color: var(--color-primary-700);
|
||||
}
|
||||
.kx-otp__cancel {
|
||||
align-self: center;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
@ -1,31 +1,45 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
PolarAngleAxis,
|
||||
RadialBar,
|
||||
RadialBarChart,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { CHART, occupancyTone } from '../chartColors';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { occupancyTone } from '../chartColors';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import { opsApi } from '../../api/endpoints';
|
||||
import { ApiRequestError } from '../../api/client';
|
||||
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
|
||||
import type { OpsData } from '../../api/types';
|
||||
import {
|
||||
HALL_CELLS,
|
||||
HALL_GROUPS,
|
||||
HVAC,
|
||||
HVAC as SAMPLE_HVAC,
|
||||
LIGHTING_ZONES,
|
||||
PARKING_ZONES,
|
||||
TOTAL_EST_PEOPLE,
|
||||
} from './sampleOps';
|
||||
import './ops.css';
|
||||
|
||||
/*
|
||||
* SCR-14 홀 현장운영 대시보드 (M14). Stitch hall_operations 이식.
|
||||
* - 히어로: 혼잡도 히트맵(홀 셀) + 우측 HVAC·조명존 + 하단 주차 점유.
|
||||
* ★ 경계면: 센서·IoT 전부 미연동 → 위젯 전부 샘플. 하드웨어 연동 코드 없음(§2.6).
|
||||
* SCR-16 홀 현장운영 대시보드 (M14). Stitch hall_operations 이식.
|
||||
* 정상 경로: GET /api/events/{eventId}/ops (배정 홀 실목록 + HVAC/조명/주차).
|
||||
* ★ 센서·IoT 미연동(PLANNING M14) → 백엔드가 estPeople=0·hvac=null·조명/주차 빈배열을 정직 반환.
|
||||
* 따라서 실 경로에서도 대부분 빈 상태 UI로 표기(가짜 센서값으로 덮지 않음).
|
||||
* 폴백: NETWORK/NOT_FOUND 시에만 sampleOps 로 강등.
|
||||
*/
|
||||
export function HallOperationsPage() {
|
||||
const [mode, setMode] = useState<'live' | 'history'>('live');
|
||||
const [group, setGroup] = useState(HALL_GROUPS[0].id);
|
||||
const eventId = useResolvedEventId();
|
||||
|
||||
const powerPct = Math.round((HVAC.powerLoadMw / HVAC.powerCapacityMw) * 100);
|
||||
const q = useQuery({
|
||||
queryKey: ['ops', eventId],
|
||||
queryFn: () => opsApi.get(eventId as string),
|
||||
enabled: !!eventId,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const degraded = isDegradable(q.error);
|
||||
const data: OpsData | null = q.data ?? (degraded ? FALLBACK : null);
|
||||
const hardError = q.isError && !degraded;
|
||||
|
||||
const hvac = data?.hvac;
|
||||
const hasHvac = !!hvac && (hvac.tempC != null || hvac.humidityPercent != null || hvac.co2Ppm != null);
|
||||
const totalPeople = data?.halls.reduce((s, h) => s + h.estPeople, 0) ?? 0;
|
||||
const hasPeople = totalPeople > 0;
|
||||
|
||||
return (
|
||||
<div className="kx-ops">
|
||||
@ -35,159 +49,190 @@ export function HallOperationsPage() {
|
||||
<p className="kx-ops__subtitle">실시간 시설 모니터링 및 자원 최적화</p>
|
||||
</div>
|
||||
<div className="kx-seg" role="tablist" aria-label="조회 모드">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === 'live'}
|
||||
className={`kx-seg__btn ${mode === 'live' ? 'is-active' : ''}`}
|
||||
onClick={() => setMode('live')}
|
||||
>
|
||||
<button role="tab" aria-selected={mode === 'live'} className={`kx-seg__btn ${mode === 'live' ? 'is-active' : ''}`} onClick={() => setMode('live')}>
|
||||
실시간
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === 'history'}
|
||||
className={`kx-seg__btn ${mode === 'history' ? 'is-active' : ''}`}
|
||||
onClick={() => setMode('history')}
|
||||
>
|
||||
<button role="tab" aria-selected={mode === 'history'} className={`kx-seg__btn ${mode === 'history' ? 'is-active' : ''}`} onClick={() => setMode('history')}>
|
||||
이력 조회
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="kx-ops__grid">
|
||||
{/* 히어로: 혼잡도 히트맵 (span 8) */}
|
||||
<section className="kx-card kx-ops__heat" aria-label="실시간 혼잡도 히트맵">
|
||||
<div className="kx-card__head">
|
||||
<h2>실시간 혼잡도 히트맵</h2>
|
||||
<select
|
||||
className="kx-select"
|
||||
aria-label="홀 그룹 선택"
|
||||
value={group}
|
||||
onChange={(e) => setGroup(e.target.value)}
|
||||
>
|
||||
{HALL_GROUPS.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<span className="kx-ops__sensor-badge">센서 미연동 · 체크인(M10) 파생/샘플</span>
|
||||
{!eventId && (
|
||||
<EmptyState title="행사를 선택해 주세요" description="현장 운영을 조회할 행사가 지정되지 않았습니다." />
|
||||
)}
|
||||
|
||||
<div className="kx-ops__halls">
|
||||
{HALL_CELLS.map((c) => (
|
||||
<div key={c.id} className={`kx-ops__hall is-${c.level}`}>
|
||||
<span className="kx-ops__hall-label">{c.label}</span>
|
||||
{c.level === 'busy' && <span className="kx-ops__hall-tag">혼잡</span>}
|
||||
<span className="kx-ops__hall-people tnum">{c.estPeople.toLocaleString()}명</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{degraded && (
|
||||
<div className="kx-ops__degraded-bar">
|
||||
<span className="kx-ops__degraded-badge">오프라인 — 샘플</span>
|
||||
집계 API에 연결하지 못해 시연용 샘플을 표시합니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="kx-ops__heat-foot">
|
||||
<ul className="kx-ops__legend">
|
||||
<li><span className="kx-dot is-smooth" /> 원활</li>
|
||||
<li><span className="kx-dot is-moderate" /> 보통</li>
|
||||
<li><span className="kx-dot is-busy" /> 혼잡</li>
|
||||
</ul>
|
||||
<span className="kx-ops__total">
|
||||
총 추정 인원 <strong className="tnum">{TOTAL_EST_PEOPLE.toLocaleString()}</strong>명
|
||||
{q.isLoading && <OpsSkeleton />}
|
||||
|
||||
{hardError && (
|
||||
<ErrorState message="현장 운영 현황을 불러오지 못했습니다." onRetry={() => q.refetch()} />
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<div className="kx-ops__grid">
|
||||
{/* 히어로: 홀 혼잡도 */}
|
||||
<section className="kx-card kx-ops__heat" aria-label="홀별 현황">
|
||||
<div className="kx-card__head">
|
||||
<h2>홀별 현황</h2>
|
||||
</div>
|
||||
<span className="kx-ops__sensor-badge">
|
||||
{hasPeople ? '체크인(M10) 파생' : '센서 미연동 · 배정 홀 목록'}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 우측: HVAC + 조명존 (span 4) */}
|
||||
<aside className="kx-ops__aside">
|
||||
<section className="kx-card" aria-label="HVAC 최적화">
|
||||
<div className="kx-card__head">
|
||||
<h2>HVAC 최적화</h2>
|
||||
<span className="kx-ops__pill is-ok">{HVAC.status}</span>
|
||||
</div>
|
||||
<div className="kx-ops__hvac">
|
||||
<div className="kx-ops__gauge">
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<RadialBarChart
|
||||
innerRadius="70%"
|
||||
outerRadius="100%"
|
||||
data={[{ name: '전력 부하', value: powerPct }]}
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
>
|
||||
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
|
||||
<RadialBar dataKey="value" cornerRadius={8} fill={CHART.primary600} background={{ fill: CHART.neutral100 }} />
|
||||
</RadialBarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="kx-ops__gauge-label">
|
||||
<strong className="tnum">{HVAC.powerLoadMw}MW</strong>
|
||||
<span>전력 부하</span>
|
||||
{data.halls.length === 0 ? (
|
||||
<EmptyState title="배정된 홀이 없습니다" description="행사에 홀이 배정되면 표시됩니다." />
|
||||
) : (
|
||||
<>
|
||||
<div className="kx-ops__halls">
|
||||
{data.halls.map((c) => (
|
||||
<div key={c.hallId} className={`kx-ops__hall is-${heatClass(c.heat)}`}>
|
||||
<span className="kx-ops__hall-label">{c.label}</span>
|
||||
{c.heat === 'busy' && <span className="kx-ops__hall-tag">혼잡</span>}
|
||||
<span className="kx-ops__hall-people tnum">
|
||||
{c.estPeople > 0 ? `${c.estPeople.toLocaleString()}명` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="kx-ops__hvac-meta">
|
||||
<div>
|
||||
<span className="kx-ops__meta-k">평균 온도</span>
|
||||
<strong className="tnum">{HVAC.avgTempC}°C</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="kx-ops__meta-k">부하율</span>
|
||||
<strong className="tnum">{powerPct}%</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="kx-card" aria-label="조명 존">
|
||||
<div className="kx-card__head">
|
||||
<h2>조명 존</h2>
|
||||
<label className="kx-switch">
|
||||
<input type="checkbox" defaultChecked aria-label="자동 디밍" />
|
||||
<span className="kx-switch__track" aria-hidden="true" />
|
||||
<span className="kx-switch__text">자동 디밍</span>
|
||||
</label>
|
||||
</div>
|
||||
<ul className="kx-barlist">
|
||||
{LIGHTING_ZONES.map((z) => (
|
||||
<li key={z.label} className="kx-barlist__item">
|
||||
<div className="kx-barlist__top">
|
||||
<span>{z.label}</span>
|
||||
<span className="tnum">{z.pct}%</span>
|
||||
</div>
|
||||
<div className="kx-barlist__track">
|
||||
<span className="kx-barlist__fill" style={{ width: `${z.pct}%` }} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
{/* 하단: 주차 점유 (span 12) */}
|
||||
<section className="kx-card kx-ops__parking" aria-label="주차 점유 현황">
|
||||
<div className="kx-card__head">
|
||||
<h2>주차 점유 현황</h2>
|
||||
<span className="kx-ops__note">iparking 연계 (샘플)</span>
|
||||
</div>
|
||||
<div className="kx-ops__zones">
|
||||
{PARKING_ZONES.map((z) => {
|
||||
const pct = Math.round((z.occupied / z.total) * 100);
|
||||
const tone = occupancyTone(pct);
|
||||
return (
|
||||
<div key={z.id} className="kx-ops__zone">
|
||||
<div className="kx-ops__zone-top">
|
||||
<span className="kx-ops__zone-label">{z.label}</span>
|
||||
<strong className={`kx-ops__zone-pct tnum is-${tone}`}>{pct}%</strong>
|
||||
</div>
|
||||
<div className="kx-barlist__track">
|
||||
<span className={`kx-barlist__fill is-${tone}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="kx-ops__zone-count tnum">
|
||||
{z.occupied.toLocaleString()} / {z.total.toLocaleString()}면
|
||||
<div className="kx-ops__heat-foot">
|
||||
<ul className="kx-ops__legend">
|
||||
<li><span className="kx-dot is-smooth" /> 원활</li>
|
||||
<li><span className="kx-dot is-moderate" /> 보통</li>
|
||||
<li><span className="kx-dot is-busy" /> 혼잡</li>
|
||||
</ul>
|
||||
<span className="kx-ops__total">
|
||||
{hasPeople ? (
|
||||
<>총 추정 인원 <strong className="tnum">{totalPeople.toLocaleString()}</strong>명</>
|
||||
) : (
|
||||
<>인원 계측 미연동</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 우측: HVAC + 조명존 */}
|
||||
<aside className="kx-ops__aside">
|
||||
<section className="kx-card" aria-label="공조(HVAC)">
|
||||
<div className="kx-card__head">
|
||||
<h2>공조 (HVAC)</h2>
|
||||
</div>
|
||||
{!hasHvac ? (
|
||||
<EmptyState title="공조 계측 미연동" description="HVAC 센서가 연동되면 온·습도·CO₂가 표시됩니다." />
|
||||
) : (
|
||||
<div className="kx-ops__hvac-meta">
|
||||
<div>
|
||||
<span className="kx-ops__meta-k">온도</span>
|
||||
<strong className="tnum">{hvac!.tempC != null ? `${hvac!.tempC}°C` : '—'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="kx-ops__meta-k">습도</span>
|
||||
<strong className="tnum">{hvac!.humidityPercent != null ? `${hvac!.humidityPercent}%` : '—'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="kx-ops__meta-k">CO₂</span>
|
||||
<strong className="tnum">{hvac!.co2Ppm != null ? `${hvac!.co2Ppm}ppm` : '—'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="kx-card" aria-label="조명 존">
|
||||
<div className="kx-card__head">
|
||||
<h2>조명 존</h2>
|
||||
</div>
|
||||
{data.lighting.length === 0 ? (
|
||||
<EmptyState title="조명 IoT 미연동" description="조명 제어 존이 연동되면 점등률이 표시됩니다." />
|
||||
) : (
|
||||
<ul className="kx-barlist">
|
||||
{data.lighting.map((z) => (
|
||||
<li key={z.zone} className="kx-barlist__item">
|
||||
<div className="kx-barlist__top">
|
||||
<span>{z.zone}</span>
|
||||
<span className="tnum">{z.onPercent}%</span>
|
||||
</div>
|
||||
<div className="kx-barlist__track">
|
||||
<span className="kx-barlist__fill" style={{ width: `${z.onPercent}%` }} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
{/* 하단: 주차 점유 */}
|
||||
<section className="kx-card kx-ops__parking" aria-label="주차 점유 현황">
|
||||
<div className="kx-card__head">
|
||||
<h2>주차 점유 현황</h2>
|
||||
</div>
|
||||
{data.parking.length === 0 ? (
|
||||
<EmptyState title="주차 연계 미연동" description="iparking 연계가 활성화되면 존별 점유가 표시됩니다." />
|
||||
) : (
|
||||
<div className="kx-ops__zones">
|
||||
{data.parking.map((z) => {
|
||||
const pct = z.capacity > 0 ? Math.round((z.occupancy / z.capacity) * 100) : 0;
|
||||
const tone = occupancyTone(pct);
|
||||
return (
|
||||
<div key={z.zone} className="kx-ops__zone">
|
||||
<div className="kx-ops__zone-top">
|
||||
<span className="kx-ops__zone-label">{z.zone}</span>
|
||||
<strong className={`kx-ops__zone-pct tnum is-${tone}`}>{pct}%</strong>
|
||||
</div>
|
||||
<div className="kx-barlist__track">
|
||||
<span className={`kx-barlist__fill is-${tone}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="kx-ops__zone-count tnum">
|
||||
{z.occupancy.toLocaleString()} / {z.capacity.toLocaleString()}면
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OpsSkeleton() {
|
||||
return (
|
||||
<div aria-hidden="true" style={{ display: 'grid', gap: 16 }}>
|
||||
<Skeleton height={220} radius={12} />
|
||||
<Skeleton height={160} radius={12} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function heatClass(heat: string): string {
|
||||
if (heat === 'busy' || heat === 'moderate' || heat === 'smooth') return heat;
|
||||
return 'smooth';
|
||||
}
|
||||
|
||||
function isDegradable(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiRequestError &&
|
||||
(error.code === 'NETWORK' || error.code === 'NOT_FOUND' || error.code === 'NOT_IMPLEMENTED')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 샘플 폴백(백엔드 shape 매핑) — 정상 경로는 실 API. NETWORK/NOT_FOUND 시에만 사용.
|
||||
* 근거: 기존 sampleOps.ts(센서·IoT 미연동 시연 데이터)를 OpsData 로 강등 변환.
|
||||
*/
|
||||
const FALLBACK: OpsData = {
|
||||
halls: HALL_CELLS.map((c) => ({ hallId: c.id, label: c.label, estPeople: c.estPeople, heat: c.level })),
|
||||
hvac: { tempC: SAMPLE_HVAC.avgTempC, humidityPercent: null, co2Ppm: null },
|
||||
lighting: LIGHTING_ZONES.map((z) => ({ zone: z.label, onPercent: z.pct })),
|
||||
parking: PARKING_ZONES.map((z) => ({ zone: z.label, occupancy: z.occupied, capacity: z.total })),
|
||||
};
|
||||
|
||||
@ -64,6 +64,25 @@
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.kx-ops__degraded-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--color-neutral-700);
|
||||
background: #fff8ef;
|
||||
border: 1px solid #ffe4bf;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.kx-ops__degraded-badge {
|
||||
font-size: 11px;
|
||||
color: var(--color-warning);
|
||||
background: #fff4e5;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
/* 히트맵 홀 셀 */
|
||||
.kx-ops__halls {
|
||||
flex: 1;
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ApiRequestError } from '../../api/client';
|
||||
import { exhibitionApi } from '../../api/endpoints';
|
||||
import type { ExhibitionDto } from '../../api/types';
|
||||
import { StatusBadge } from '../../components/ui/Badge';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { EmptyState } from '../../components/ui/States';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import {
|
||||
IconCalendar,
|
||||
IconChevronLeft,
|
||||
@ -18,6 +21,7 @@ import {
|
||||
eventDaysFor,
|
||||
eventsByDateFor,
|
||||
hallUtilizationFor,
|
||||
type EventCategory,
|
||||
type EventStatus,
|
||||
type ScheduleEvent,
|
||||
} from './sampleSchedule';
|
||||
@ -26,6 +30,42 @@ import './schedule.css';
|
||||
const MOCK = `${import.meta.env.BASE_URL}mock/organizer_dashboard`;
|
||||
const THUMBS = [`${MOCK}/img1.jpg`, `${MOCK}/img2.jpg`, `${MOCK}/img3.jpg`];
|
||||
const TODAY = new Date(2026, 6, 11); // 2026-07-11 (currentDate)
|
||||
const TODAY_YMD = '2026-07-11';
|
||||
|
||||
const KNOWN_CATEGORIES = new Set<EventCategory>(['exhibition', 'culture', 'trade', 'tech']);
|
||||
|
||||
/** 날짜 기준 상태 재계산 — DB status 원문 대신 TODAY 기준 진행/예정/종료로 정규화. */
|
||||
function statusFromDates(start: string, end: string): EventStatus {
|
||||
if (end < TODAY_YMD) return 'ended';
|
||||
if (start > TODAY_YMD) return 'upcoming';
|
||||
return 'ongoing';
|
||||
}
|
||||
|
||||
/** "2026-08-11","2026-08-14" → "2026.08.11–08.14" */
|
||||
function periodLabel(start: string, end: string): string {
|
||||
const s = start.split('-').join('.');
|
||||
const e = end.split('-').join('.');
|
||||
return e.startsWith(s.slice(0, 5)) ? `${s}–${e.slice(5)}` : `${s}–${e}`;
|
||||
}
|
||||
|
||||
/** 실 API ExhibitionDto → 화면 ScheduleEvent 매핑. DB 미포함 필드(카테고리·홀·인원)는 기본값. */
|
||||
function toScheduleEvent(e: ExhibitionDto, i: number): ScheduleEvent {
|
||||
const category = (KNOWN_CATEGORIES.has(e.category as EventCategory)
|
||||
? (e.category as EventCategory)
|
||||
: 'exhibition') as EventCategory;
|
||||
return {
|
||||
id: e.id,
|
||||
title: e.name,
|
||||
category,
|
||||
status: statusFromDates(e.startDate, e.endDate),
|
||||
period: periodLabel(e.startDate, e.endDate),
|
||||
start: e.startDate,
|
||||
end: e.endDate,
|
||||
halls: e.hallLabel ?? '홀 미배정',
|
||||
estVisitors: e.estVisitors != null ? `${e.estVisitors.toLocaleString()}명` : '집계 대기',
|
||||
thumbIdx: i % THUMBS.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** 홀 필터 매칭 — halls 문자열("홀 4, 5A"·"제2전시장 홀7")에서 홀 번호만 추출해 전시장 범위("1-5"/"6-10")와 대조. "제N전시장"의 N·"7A-1"의 -1 오매칭 방지. */
|
||||
function matchesHall(halls: string, range: string): boolean {
|
||||
@ -47,7 +87,6 @@ function matchesHall(halls: string, range: string): boolean {
|
||||
* ★ 경계면: 행사 목록은 인증 workspaces(실 원천) 우선, 없으면 크롤링 데이터 폴백.
|
||||
*/
|
||||
export function ExhibitionSchedulePage() {
|
||||
const workspaces = useAuthStore((s) => s.workspaces);
|
||||
const [view, setView] = useState<'list' | 'calendar'>('list');
|
||||
const [year, setYear] = useState('2026');
|
||||
const [hall, setHall] = useState('all');
|
||||
@ -56,24 +95,31 @@ export function ExhibitionSchedulePage() {
|
||||
// 우측 미니 캘린더·홀 가동률 공유 월 커서
|
||||
const [asideCursor, setAsideCursor] = useState(new Date(2026, 6, 1)); // 2026년 7월
|
||||
|
||||
// 실 원천: 인증 workspaces → ScheduleEvent 매핑. 없으면 크롤링 10년치 폴백.
|
||||
const usingReal = workspaces.length > 0;
|
||||
const events: ScheduleEvent[] = useMemo(() => {
|
||||
if (!usingReal) return ALL_EVENTS;
|
||||
return workspaces.map((w, i) => ({
|
||||
id: w.eventId,
|
||||
title: w.eventName,
|
||||
// ★ category/estVisitors 는 WorkspaceDto 미포함 → 샘플/기본값
|
||||
category: 'exhibition' as const,
|
||||
status: (w.dday > 0 ? 'upcoming' : 'ongoing') as EventStatus,
|
||||
period: `${w.startDate} – ${w.endDate}`,
|
||||
start: w.startDate,
|
||||
end: w.endDate,
|
||||
halls: w.hallLabel,
|
||||
estVisitors: '집계 대기',
|
||||
thumbIdx: i % THUMBS.length,
|
||||
}));
|
||||
}, [usingReal, workspaces]);
|
||||
/*
|
||||
* 실 원천: `GET /api/events` 전 홀 10년치 카탈로그(ExhibitionDto[]) — event 테이블(V10 시드).
|
||||
* 백엔드 미구현(NOT_IMPLEMENTED/NOT_FOUND)·네트워크 오류 시에만 크롤링 샘플(ALL_EVENTS)로 폴백한다.
|
||||
* ALL_EVENTS는 백엔드 V10 seed_exhibitions_10yr 와 동일 원천(kintex.com 크롤링)이라 shape·건수 정합.
|
||||
*/
|
||||
const query = useQuery<{ events: ScheduleEvent[]; degraded: boolean }>({
|
||||
queryKey: ['exhibitions'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const rows = await exhibitionApi.list();
|
||||
return { events: rows.map(toScheduleEvent), degraded: false };
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof ApiRequestError &&
|
||||
(err.code === 'NOT_IMPLEMENTED' || err.code === 'NOT_FOUND' || err.code === 'NETWORK')
|
||||
) {
|
||||
return { events: ALL_EVENTS, degraded: true };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const events = query.data?.events ?? [];
|
||||
const degraded = query.data?.degraded ?? false;
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
@ -96,7 +142,8 @@ export function ExhibitionSchedulePage() {
|
||||
<div>
|
||||
<h1 className="kx-sched__title">전시 일정</h1>
|
||||
<p className="kx-sched__subtitle">
|
||||
킨텍스 전 홀 행사 일정 관리·모니터링 — 10년치 {ALL_EVENTS.length.toLocaleString()}건 (kintex.com 수집)
|
||||
킨텍스 전 홀 행사 일정 관리·모니터링 — {events.length.toLocaleString()}건
|
||||
{degraded && ' (kintex.com 크롤링 폴백)'}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
@ -126,7 +173,7 @@ export function ExhibitionSchedulePage() {
|
||||
))}
|
||||
</select>
|
||||
<div className="kx-sched__filter-spacer" />
|
||||
{!usingReal && <span className="kx-bi__degraded">행사 API 대기 (크롤링 데이터)</span>}
|
||||
{degraded && <span className="kx-bi__degraded">행사 API 대기 (크롤링 데이터)</span>}
|
||||
<div className="kx-seg" role="tablist" aria-label="보기 전환">
|
||||
<button role="tab" aria-selected={view === 'list'} className={`kx-seg__btn ${view === 'list' ? 'is-active' : ''}`} onClick={() => setView('list')}>목록</button>
|
||||
<button role="tab" aria-selected={view === 'calendar'} className={`kx-seg__btn ${view === 'calendar' ? 'is-active' : ''}`} onClick={() => setView('calendar')}>캘린더</button>
|
||||
@ -134,7 +181,28 @@ export function ExhibitionSchedulePage() {
|
||||
</div>
|
||||
|
||||
<div className="kx-sched__split">
|
||||
{view === 'list' ? (
|
||||
{query.isLoading ? (
|
||||
<section className="kx-sched__list" aria-label="행사 목록 로딩 중" aria-busy="true">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<article key={i} className="kx-evcard" aria-hidden="true">
|
||||
<Skeleton height={112} width={160} radius={8} />
|
||||
<div className="kx-evcard__body" style={{ flex: 1 }}>
|
||||
<Skeleton height={20} width="60%" />
|
||||
<Skeleton height={14} width="35%" />
|
||||
<Skeleton height={14} width="80%" />
|
||||
<Skeleton height={14} width="50%" />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
) : query.isError ? (
|
||||
<section className="kx-sched__list" aria-label="행사 목록">
|
||||
<ErrorState
|
||||
message="전시 일정을 불러오지 못했습니다."
|
||||
onRetry={() => query.refetch()}
|
||||
/>
|
||||
</section>
|
||||
) : view === 'list' ? (
|
||||
<section className="kx-sched__list" aria-label="행사 목록">
|
||||
<p className="kx-sched__count tnum">{listed.length.toLocaleString()}건</p>
|
||||
{listed.length === 0 ? (
|
||||
|
||||
@ -101,6 +101,10 @@
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-900);
|
||||
}
|
||||
.kx-kpi__sub {
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
.kx-kpi--warn {
|
||||
border-color: var(--color-violation-warn);
|
||||
}
|
||||
|
||||
324
src/frontend/src/screens/work/MeetingPage.tsx
Normal file
324
src/frontend/src/screens/work/MeetingPage.tsx
Normal file
@ -0,0 +1,324 @@
|
||||
/*
|
||||
* SCR-45 회의록 (meeting). 참조: UIWS meeting/MeetingDetailPage.
|
||||
* 좌: 회의 목록 / 우: 상세(본문·회의록 편집·액션아이템).
|
||||
* 백엔드: /api/work/meetings (get·create·minutes·actions·action status).
|
||||
* ★ 갭: 녹음 재생·STT 전사·AI 요약·Jasper PDF 엔드포인트 부재 → 해당 UI는 disabled+툴팁. 07_work_api_gaps.md 기록.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { meetingApi } from '../../api/endpoints';
|
||||
import type { MeetingSaveRequest } from '../../api/types';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import { IconDownload, IconPlus } from '../../components/ui/icons';
|
||||
import { StatusPill, errMessage, fmtDateTime, useToast } from './workShared';
|
||||
import './work.css';
|
||||
|
||||
const NOT_SUPPORTED = '백엔드 미지원 — STT/PDF 파이프라인 연동 후 활성화';
|
||||
|
||||
export function MeetingPage() {
|
||||
const qc = useQueryClient();
|
||||
const { show, node: toast } = useToast();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [composing, setComposing] = useState(false);
|
||||
const [minutesDraft, setMinutesDraft] = useState('');
|
||||
const [actionText, setActionText] = useState('');
|
||||
|
||||
const listQ = useQuery({
|
||||
queryKey: ['meetings'],
|
||||
queryFn: () => meetingApi.list({ page: 0, size: 50 }),
|
||||
});
|
||||
const detailQ = useQuery({
|
||||
queryKey: ['meeting', selectedId],
|
||||
queryFn: () => meetingApi.get(selectedId as string),
|
||||
enabled: !!selectedId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (detailQ.data) setMinutesDraft(detailQ.data.meeting.minutes ?? '');
|
||||
}, [detailQ.data]);
|
||||
|
||||
const createM = useMutation({
|
||||
mutationFn: (b: MeetingSaveRequest) => meetingApi.create(b),
|
||||
onSuccess: (m) => {
|
||||
show('회의가 등록되었습니다.');
|
||||
setComposing(false);
|
||||
setSelectedId(m.id);
|
||||
qc.invalidateQueries({ queryKey: ['meetings'] });
|
||||
},
|
||||
onError: (e) => show(errMessage(e)),
|
||||
});
|
||||
const minutesM = useMutation({
|
||||
mutationFn: ({ id, minutes }: { id: string; minutes: string }) =>
|
||||
meetingApi.saveMinutes(id, minutes),
|
||||
onSuccess: () => {
|
||||
show('회의록이 저장되었습니다.');
|
||||
qc.invalidateQueries({ queryKey: ['meeting', selectedId] });
|
||||
},
|
||||
onError: (e) => show(errMessage(e)),
|
||||
});
|
||||
const actionM = useMutation({
|
||||
mutationFn: ({ id, item }: { id: string; item: string }) =>
|
||||
meetingApi.addAction(id, { actionItem: item }),
|
||||
onSuccess: () => {
|
||||
setActionText('');
|
||||
qc.invalidateQueries({ queryKey: ['meeting', selectedId] });
|
||||
},
|
||||
onError: (e) => show(errMessage(e)),
|
||||
});
|
||||
const actionStatusM = useMutation({
|
||||
mutationFn: ({ actionId, value }: { actionId: string; value: string }) =>
|
||||
meetingApi.actionStatus(actionId, value),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['meeting', selectedId] }),
|
||||
onError: (e) => show(errMessage(e)),
|
||||
});
|
||||
|
||||
const rows = listQ.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="kx-page">
|
||||
<header className="kx-work__head">
|
||||
<div>
|
||||
<h1 className="kx-work__title">회의록</h1>
|
||||
<p className="kx-work__subtitle">사전업무협의(D-30) 회의 기록 · 액션아이템 관리</p>
|
||||
</div>
|
||||
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setComposing(true)}>
|
||||
회의 등록
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="kx-meet">
|
||||
{/* 목록 */}
|
||||
<section className="kx-card" aria-label="회의 목록" style={{ padding: 0 }}>
|
||||
<div className="kx-scroll-70">
|
||||
{listQ.isLoading &&
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<div className="kx-meet__list-item" key={i}>
|
||||
<Skeleton height={16} width="70%" />
|
||||
<Skeleton height={13} width="40%" />
|
||||
</div>
|
||||
))}
|
||||
{listQ.isError && (
|
||||
<ErrorState message={errMessage(listQ.error)} onRetry={() => listQ.refetch()} />
|
||||
)}
|
||||
{!listQ.isLoading && !listQ.isError && rows.length === 0 && (
|
||||
<EmptyState title="등록된 회의가 없습니다" />
|
||||
)}
|
||||
{rows.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`kx-meet__list-item ${selectedId === m.id ? 'is-selected' : ''}`}
|
||||
onClick={() => {
|
||||
setComposing(false);
|
||||
setSelectedId(m.id);
|
||||
}}
|
||||
>
|
||||
<span className="kx-list-table__title">{m.title}</span>
|
||||
<span className="kx-detail__meta">
|
||||
{fmtDateTime(m.meetingAt)}
|
||||
{m.location && ` · ${m.location}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 상세 */}
|
||||
<section className="kx-detail" aria-label="회의 상세" style={{ position: 'static' }}>
|
||||
{composing ? (
|
||||
<MeetingCompose
|
||||
saving={createM.isPending}
|
||||
onCancel={() => setComposing(false)}
|
||||
onSave={(b) => createM.mutate(b)}
|
||||
/>
|
||||
) : !selectedId ? (
|
||||
<EmptyState title="회의를 선택하세요" description="목록에서 회의를 선택하면 상세가 표시됩니다." />
|
||||
) : detailQ.isLoading ? (
|
||||
<>
|
||||
<Skeleton height={24} width="60%" />
|
||||
<Skeleton height={120} />
|
||||
</>
|
||||
) : detailQ.isError ? (
|
||||
<ErrorState message={errMessage(detailQ.error)} onRetry={() => detailQ.refetch()} />
|
||||
) : detailQ.data ? (
|
||||
<>
|
||||
<div className="kx-detail__head">
|
||||
<h2 className="kx-detail__title">{detailQ.data.meeting.title}</h2>
|
||||
<span title={NOT_SUPPORTED} style={{ display: 'inline-flex' }}>
|
||||
<Button variant="secondary" disabled leadingIcon={<IconDownload size={16} />}>
|
||||
회의록 PDF
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="kx-detail__meta">
|
||||
<span>{detailQ.data.meeting.organizerName}</span>
|
||||
<span>· {fmtDateTime(detailQ.data.meeting.meetingAt)}</span>
|
||||
{detailQ.data.meeting.location && <span>· {detailQ.data.meeting.location}</span>}
|
||||
</div>
|
||||
|
||||
{/* 녹음/STT (미지원) */}
|
||||
<div className="kx-meet__player" title={NOT_SUPPORTED}>
|
||||
▶ 녹음 재생 · STT 전사 (준비 중)
|
||||
</div>
|
||||
|
||||
{detailQ.data.meeting.content && (
|
||||
<div className="kx-field">
|
||||
<span className="kx-label">회의 안건</span>
|
||||
<div className="kx-detail__body">{detailQ.data.meeting.content}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 회의록 편집 */}
|
||||
<div className="kx-field">
|
||||
<span className="kx-label">회의록</span>
|
||||
<textarea
|
||||
className="kx-textarea"
|
||||
style={{ minHeight: 140 }}
|
||||
value={minutesDraft}
|
||||
onChange={(e) => setMinutesDraft(e.target.value)}
|
||||
placeholder="회의 내용을 기록하세요."
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Button
|
||||
disabled={minutesM.isPending}
|
||||
onClick={() => minutesM.mutate({ id: detailQ.data!.meeting.id, minutes: minutesDraft })}
|
||||
>
|
||||
{minutesM.isPending ? '저장 중…' : '회의록 저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션아이템 */}
|
||||
<div className="kx-field">
|
||||
<span className="kx-label">액션아이템</span>
|
||||
<table className="kx-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 40 }}>#</th>
|
||||
<th>내용</th>
|
||||
<th style={{ width: 96 }}>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detailQ.data.actions.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="kx-list-table__muted">
|
||||
등록된 액션아이템이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{detailQ.data.actions.map((a) => (
|
||||
<tr key={a.id} style={{ cursor: 'default' }}>
|
||||
<td className="kx-num">{a.seq}</td>
|
||||
<td>{a.actionItem}</td>
|
||||
<td>
|
||||
<button
|
||||
className="kx-pill kx-pill--neutral"
|
||||
style={{ cursor: 'pointer', border: 'none' }}
|
||||
onClick={() =>
|
||||
actionStatusM.mutate({
|
||||
actionId: a.id,
|
||||
value: a.status === 'DONE' ? 'OPEN' : 'DONE',
|
||||
})
|
||||
}
|
||||
title="상태 토글"
|
||||
>
|
||||
<StatusPill value={a.status} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="kx-thread__compose" style={{ marginTop: 8 }}>
|
||||
<input
|
||||
className="kx-input"
|
||||
placeholder="새 액션아이템…"
|
||||
value={actionText}
|
||||
onChange={(e) => setActionText(e.target.value)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === 'Enter' &&
|
||||
actionText.trim() &&
|
||||
actionM.mutate({ id: detailQ.data!.meeting.id, item: actionText })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
disabled={!actionText.trim() || actionM.isPending}
|
||||
onClick={() => actionM.mutate({ id: detailQ.data!.meeting.id, item: actionText })}
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
{toast}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MeetingCompose({
|
||||
onCancel,
|
||||
onSave,
|
||||
saving,
|
||||
}: {
|
||||
onCancel: () => void;
|
||||
onSave: (b: MeetingSaveRequest) => void;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const now = new Date();
|
||||
const [form, setForm] = useState<MeetingSaveRequest>({
|
||||
title: '',
|
||||
location: '',
|
||||
meetingAt: `${now.toISOString().slice(0, 16)}`,
|
||||
content: '',
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<div className="kx-detail__head">
|
||||
<h2 className="kx-detail__title">회의 등록</h2>
|
||||
</div>
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">제목 *</span>
|
||||
<input className="kx-input" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
|
||||
</label>
|
||||
<div className="kx-formgrid">
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">일시 *</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="kx-input"
|
||||
value={form.meetingAt}
|
||||
onChange={(e) => setForm({ ...form, meetingAt: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">장소</span>
|
||||
<input
|
||||
className="kx-input"
|
||||
value={form.location ?? ''}
|
||||
onChange={(e) => setForm({ ...form, location: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">안건</span>
|
||||
<textarea
|
||||
className="kx-textarea"
|
||||
value={form.content ?? ''}
|
||||
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<div className="kx-detail__actions">
|
||||
<Button disabled={!form.title.trim() || saving} onClick={() => onSave(form)}>
|
||||
{saving ? '등록 중…' : '등록'}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
258
src/frontend/src/screens/work/MessagePage.tsx
Normal file
258
src/frontend/src/screens/work/MessagePage.tsx
Normal file
@ -0,0 +1,258 @@
|
||||
/*
|
||||
* SCR-41 쪽지 (message). 참조: UIWS message/*.
|
||||
* 3-패널: 폴더 레일(받은/보낸) · 목록 · 읽기/작성. 읽음 처리·수신자 태그 입력.
|
||||
* 백엔드: /api/work/messages (inbox·sent·send·read·delete).
|
||||
* ★ 갭: 부서 트리 사용자 디렉터리 엔드포인트 부재 → 수신자는 사용자 ID 직접 입력. 07_work_api_gaps.md 기록.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { messageApi } from '../../api/endpoints';
|
||||
import type { MessageDto, MessageSendRequest } from '../../api/types';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import { IconClose, IconPlus } from '../../components/ui/icons';
|
||||
import { errMessage, fmtDateTime, timeAgo, useToast } from './workShared';
|
||||
import './work.css';
|
||||
|
||||
type Folder = 'inbox' | 'sent';
|
||||
|
||||
export function MessagePage() {
|
||||
const qc = useQueryClient();
|
||||
const { show, node: toast } = useToast();
|
||||
const [folder, setFolder] = useState<Folder>('inbox');
|
||||
const [selected, setSelected] = useState<MessageDto | null>(null);
|
||||
const [composing, setComposing] = useState(false);
|
||||
|
||||
const unreadQ = useQuery({ queryKey: ['msg-unread'], queryFn: () => messageApi.unreadCount() });
|
||||
const listQ = useQuery({
|
||||
queryKey: ['messages', folder],
|
||||
queryFn: () => (folder === 'inbox' ? messageApi.inbox(0, 50) : messageApi.sent(0, 50)),
|
||||
});
|
||||
|
||||
const readM = useMutation({
|
||||
mutationFn: (id: string) => messageApi.read(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['messages', 'inbox'] });
|
||||
qc.invalidateQueries({ queryKey: ['msg-unread'] });
|
||||
},
|
||||
});
|
||||
const delM = useMutation({
|
||||
mutationFn: (id: string) => messageApi.remove(id),
|
||||
onSuccess: () => {
|
||||
show('삭제되었습니다.');
|
||||
setSelected(null);
|
||||
qc.invalidateQueries({ queryKey: ['messages'] });
|
||||
qc.invalidateQueries({ queryKey: ['msg-unread'] });
|
||||
},
|
||||
onError: (e) => show(errMessage(e)),
|
||||
});
|
||||
const sendM = useMutation({
|
||||
mutationFn: (body: MessageSendRequest) => messageApi.send(body),
|
||||
onSuccess: () => {
|
||||
show('쪽지를 보냈습니다.');
|
||||
setComposing(false);
|
||||
qc.invalidateQueries({ queryKey: ['messages', 'sent'] });
|
||||
},
|
||||
onError: (e) => show(errMessage(e)),
|
||||
});
|
||||
|
||||
function openMessage(m: MessageDto) {
|
||||
setSelected(m);
|
||||
if (folder === 'inbox' && !m.readAt) readM.mutate(m.id);
|
||||
}
|
||||
|
||||
const rows = listQ.data?.items ?? [];
|
||||
const unread = unreadQ.data?.unread ?? 0;
|
||||
|
||||
return (
|
||||
<div className="kx-page">
|
||||
<header className="kx-work__head">
|
||||
<div>
|
||||
<h1 className="kx-work__title">쪽지</h1>
|
||||
<p className="kx-work__subtitle">행사 내 커뮤니케이션 · 주최자↔참가↔업체↔홀매니저</p>
|
||||
</div>
|
||||
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setComposing(true)}>
|
||||
쪽지 쓰기
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="kx-msg">
|
||||
{/* 폴더 레일 */}
|
||||
<nav className="kx-msg__rail" aria-label="쪽지 폴더">
|
||||
<button
|
||||
className={`kx-msg__rail-btn ${folder === 'inbox' ? 'is-active' : ''}`}
|
||||
onClick={() => {
|
||||
setFolder('inbox');
|
||||
setSelected(null);
|
||||
}}
|
||||
>
|
||||
받은 쪽지
|
||||
{unread > 0 && <span className="kx-msg__rail-count tnum">{unread}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={`kx-msg__rail-btn ${folder === 'sent' ? 'is-active' : ''}`}
|
||||
onClick={() => {
|
||||
setFolder('sent');
|
||||
setSelected(null);
|
||||
}}
|
||||
>
|
||||
보낸 쪽지
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* 목록 */}
|
||||
<section className="kx-msg__list" aria-label={folder === 'inbox' ? '받은 쪽지 목록' : '보낸 쪽지 목록'}>
|
||||
{listQ.isLoading &&
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<div className="kx-msg__item" key={i}>
|
||||
<Skeleton height={16} width="60%" />
|
||||
<Skeleton height={14} width="90%" />
|
||||
</div>
|
||||
))}
|
||||
{listQ.isError && (
|
||||
<ErrorState message={errMessage(listQ.error)} onRetry={() => listQ.refetch()} />
|
||||
)}
|
||||
{!listQ.isLoading && !listQ.isError && rows.length === 0 && (
|
||||
<EmptyState
|
||||
title={folder === 'inbox' ? '받은 쪽지가 없습니다' : '보낸 쪽지가 없습니다'}
|
||||
/>
|
||||
)}
|
||||
{rows.map((m) => {
|
||||
const unreadItem = folder === 'inbox' && !m.readAt;
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`kx-msg__item ${selected?.id === m.id ? 'is-selected' : ''} ${
|
||||
unreadItem ? 'is-unread' : ''
|
||||
}`}
|
||||
onClick={() => openMessage(m)}
|
||||
>
|
||||
<div className="kx-msg__item-top">
|
||||
<span className="kx-msg__item-sender">
|
||||
{unreadItem && <span className="kx-msg__unread-dot" aria-label="안읽음" />}{' '}
|
||||
{folder === 'inbox' ? m.senderName : m.title || '(제목 없음)'}
|
||||
</span>
|
||||
<span className="kx-msg__item-time">{timeAgo(m.createdAt)}</span>
|
||||
</div>
|
||||
<span className="kx-msg__item-title">{m.title || '(제목 없음)'}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
{/* 읽기/작성 */}
|
||||
<section className="kx-msg__read" aria-label="쪽지 내용">
|
||||
{composing ? (
|
||||
<ComposeForm
|
||||
onCancel={() => setComposing(false)}
|
||||
onSend={(b) => sendM.mutate(b)}
|
||||
sending={sendM.isPending}
|
||||
/>
|
||||
) : selected ? (
|
||||
<>
|
||||
<div className="kx-detail__head">
|
||||
<h2 className="kx-detail__title">{selected.title || '(제목 없음)'}</h2>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
if (window.confirm('이 쪽지를 삭제할까요?')) delM.mutate(selected.id);
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
<div className="kx-detail__meta">
|
||||
<span>보낸이: {selected.senderName}</span>
|
||||
<span>· {fmtDateTime(selected.createdAt)}</span>
|
||||
{selected.recvType && <span>· {selected.recvType === 'REF' ? '참조' : '수신'}</span>}
|
||||
</div>
|
||||
<div className="kx-detail__body">{selected.content || '(내용 없음)'}</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState title="쪽지를 선택하세요" description="목록에서 쪽지를 선택하면 내용이 표시됩니다." />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{toast}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComposeForm({
|
||||
onCancel,
|
||||
onSend,
|
||||
sending,
|
||||
}: {
|
||||
onCancel: () => void;
|
||||
onSend: (b: MessageSendRequest) => void;
|
||||
sending: boolean;
|
||||
}) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [recipients, setRecipients] = useState<string[]>([]);
|
||||
const [entry, setEntry] = useState('');
|
||||
|
||||
function addRecipient() {
|
||||
const v = entry.trim();
|
||||
if (v && !recipients.includes(v)) setRecipients([...recipients, v]);
|
||||
setEntry('');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="kx-detail__head">
|
||||
<h2 className="kx-detail__title">쪽지 쓰기</h2>
|
||||
</div>
|
||||
<div className="kx-field">
|
||||
<span className="kx-label">받는 사람 (사용자 ID)</span>
|
||||
<div className="kx-tags">
|
||||
{recipients.map((r) => (
|
||||
<span className="kx-tag" key={r}>
|
||||
{r}
|
||||
<button
|
||||
onClick={() => setRecipients(recipients.filter((x) => x !== r))}
|
||||
aria-label={`${r} 제거`}
|
||||
>
|
||||
<IconClose size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
value={entry}
|
||||
placeholder="ID 입력 후 Enter"
|
||||
onChange={(e) => setEntry(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
addRecipient();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">제목</span>
|
||||
<input className="kx-input" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</label>
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">내용</span>
|
||||
<textarea
|
||||
className="kx-textarea"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="kx-detail__actions">
|
||||
<Button
|
||||
disabled={recipients.length === 0 || sending}
|
||||
onClick={() => onSend({ title, content, recipientIds: recipients })}
|
||||
>
|
||||
{sending ? '보내는 중…' : '보내기'}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
243
src/frontend/src/screens/work/MyPage.tsx
Normal file
243
src/frontend/src/screens/work/MyPage.tsx
Normal file
@ -0,0 +1,243 @@
|
||||
/*
|
||||
* SCR-48 마이페이지·환경설정. 참조: UIWS auth/MyProfilePage.
|
||||
* 좌: 탭 레일(프로필·보안·알림설정·테마/언어) / 우: 설정 폼.
|
||||
* 백엔드: /api/auth/me·/api/auth/otp/status. 2FA는 기존 /otp-setup 화면으로 연결(중복 구현 금지).
|
||||
* ★ 갭: 프로필 수정·알림 규칙·환경설정 저장 엔드포인트 부재 → 테마/언어는 클라이언트 로컬 저장. 07_work_api_gaps.md 기록.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { authApi } from '../../api/endpoints';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { RoleBadge } from '../../components/ui/Badge';
|
||||
import { ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import { IconCheckCircle } from '../../components/ui/icons';
|
||||
import { useToast } from './workShared';
|
||||
import './work.css';
|
||||
|
||||
type Tab = 'profile' | 'security' | 'notify' | 'theme';
|
||||
const TABS: { v: Tab; l: string }[] = [
|
||||
{ v: 'profile', l: '프로필' },
|
||||
{ v: 'security', l: '보안(2차 인증)' },
|
||||
{ v: 'notify', l: '알림 설정' },
|
||||
{ v: 'theme', l: '테마·언어' },
|
||||
];
|
||||
|
||||
const PREF_KEY = 'kintex.prefs';
|
||||
interface Prefs {
|
||||
theme: 'light' | 'dark';
|
||||
lang: 'ko' | 'en' | 'zh' | 'ja';
|
||||
notifyDeadline: boolean;
|
||||
notifyApproval: boolean;
|
||||
notifyPayment: boolean;
|
||||
}
|
||||
const DEFAULT_PREFS: Prefs = {
|
||||
theme: 'light',
|
||||
lang: 'ko',
|
||||
notifyDeadline: true,
|
||||
notifyApproval: true,
|
||||
notifyPayment: true,
|
||||
};
|
||||
|
||||
function loadPrefs(): Prefs {
|
||||
try {
|
||||
return { ...DEFAULT_PREFS, ...JSON.parse(localStorage.getItem(PREF_KEY) ?? '{}') };
|
||||
} catch {
|
||||
return DEFAULT_PREFS;
|
||||
}
|
||||
}
|
||||
|
||||
export function MyPage() {
|
||||
const { show, node: toast } = useToast();
|
||||
const storeUser = useAuthStore((s) => s.user);
|
||||
const workspaces = useAuthStore((s) => s.workspaces);
|
||||
const [tab, setTab] = useState<Tab>('profile');
|
||||
const [prefs, setPrefs] = useState<Prefs>(loadPrefs);
|
||||
|
||||
const meQ = useQuery({ queryKey: ['me'], queryFn: () => authApi.me() });
|
||||
const otpQ = useQuery({ queryKey: ['otp-status'], queryFn: () => authApi.otpStatus() });
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', prefs.theme);
|
||||
document.documentElement.setAttribute('lang', prefs.lang);
|
||||
}, [prefs.theme, prefs.lang]);
|
||||
|
||||
function savePrefs(next: Prefs) {
|
||||
setPrefs(next);
|
||||
localStorage.setItem(PREF_KEY, JSON.stringify(next));
|
||||
show('설정이 저장되었습니다.');
|
||||
}
|
||||
|
||||
const displayName = meQ.data?.displayName ?? storeUser?.displayName ?? '사용자';
|
||||
const roles = meQ.data?.eventRoles ? Object.values(meQ.data.eventRoles) : [];
|
||||
|
||||
return (
|
||||
<div className="kx-page">
|
||||
<header className="kx-work__head">
|
||||
<div>
|
||||
<h1 className="kx-work__title">마이페이지</h1>
|
||||
<p className="kx-work__subtitle">프로필 · 보안 · 알림 · 환경설정</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="kx-my">
|
||||
<nav className="kx-my__rail" aria-label="설정 탭">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.v}
|
||||
className={`kx-my__rail-btn ${tab === t.v ? 'is-active' : ''}`}
|
||||
onClick={() => setTab(t.v)}
|
||||
>
|
||||
{t.l}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<section className="kx-card" aria-label="설정 내용">
|
||||
{/* 프로필 */}
|
||||
{tab === 'profile' && (
|
||||
<>
|
||||
<div className="kx-card__head">
|
||||
<h2>프로필</h2>
|
||||
</div>
|
||||
{meQ.isLoading ? (
|
||||
<Skeleton height={80} />
|
||||
) : meQ.isError ? (
|
||||
<ErrorState onRetry={() => meQ.refetch()} />
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: 20, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span className="kx-my__avatar">{displayName[0] ?? '·'}</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<strong style={{ fontSize: 18 }}>{displayName}</strong>
|
||||
<span className="kx-list-table__muted">사용자 ID: {meQ.data?.userId}</span>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{meQ.data?.hallManager && <span className="kx-pill kx-pill--info">홀매니저</span>}
|
||||
{roles.map((r, i) => (
|
||||
<RoleBadge key={i} role={r} />
|
||||
))}
|
||||
</div>
|
||||
<span className="kx-list-table__muted">
|
||||
참여 행사 {workspaces.length}건
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="kx-list-table__muted" style={{ marginTop: 12, fontSize: 12 }}>
|
||||
프로필 정보 수정은 준비 중입니다.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 보안 (2FA) */}
|
||||
{tab === 'security' && (
|
||||
<>
|
||||
<div className="kx-card__head">
|
||||
<h2>2차 인증(OTP)</h2>
|
||||
</div>
|
||||
{otpQ.isLoading ? (
|
||||
<Skeleton height={60} />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{otpQ.data?.otpEnabled ? (
|
||||
<>
|
||||
<span style={{ color: 'var(--color-success)', display: 'inline-flex' }}>
|
||||
<IconCheckCircle size={20} />
|
||||
</span>
|
||||
<span>2차 인증이 활성화되어 있습니다.</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="kx-pill kx-pill--warn">2차 인증 미설정</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="kx-list-table__muted" style={{ fontSize: 13 }}>
|
||||
Google Authenticator 등 TOTP 앱으로 로그인 2단계 인증을 사용합니다.
|
||||
등록·재설정은 전용 화면에서 진행합니다.
|
||||
</p>
|
||||
<div>
|
||||
<Link to="/otp-setup">
|
||||
<Button>{otpQ.data?.otpEnabled ? '2차 인증 재설정' : '2차 인증 등록'}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 알림 설정 */}
|
||||
{tab === 'notify' && (
|
||||
<>
|
||||
<div className="kx-card__head">
|
||||
<h2>알림 설정</h2>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{(
|
||||
[
|
||||
['notifyDeadline', '마감 D-데이 알림'],
|
||||
['notifyApproval', '승인·검수 알림'],
|
||||
['notifyPayment', '결제·정산 알림'],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<label className="kx-switch" key={key}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={prefs[key]}
|
||||
onChange={(e) => savePrefs({ ...prefs, [key]: e.target.checked })}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
<p className="kx-list-table__muted" style={{ fontSize: 12 }}>
|
||||
현재 알림 설정은 이 기기에 저장됩니다. 서버 동기화는 준비 중입니다.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 테마·언어 */}
|
||||
{tab === 'theme' && (
|
||||
<>
|
||||
<div className="kx-card__head">
|
||||
<h2>테마·언어</h2>
|
||||
</div>
|
||||
<div className="kx-formgrid">
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">테마</span>
|
||||
<div className="kx-seg" role="tablist">
|
||||
{(['light', 'dark'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`kx-seg__btn ${prefs.theme === t ? 'is-active' : ''}`}
|
||||
onClick={() => savePrefs({ ...prefs, theme: t })}
|
||||
>
|
||||
{t === 'light' ? '라이트' : '다크'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</label>
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">언어</span>
|
||||
<select
|
||||
className="kx-select"
|
||||
value={prefs.lang}
|
||||
onChange={(e) => savePrefs({ ...prefs, lang: e.target.value as Prefs['lang'] })}
|
||||
>
|
||||
<option value="ko">한국어</option>
|
||||
<option value="en">English</option>
|
||||
<option value="zh">中文</option>
|
||||
<option value="ja">日本語</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="kx-list-table__muted" style={{ marginTop: 12, fontSize: 12 }}>
|
||||
테마·언어는 이 기기에 저장됩니다. 다국어 리소스는 순차 적용 예정입니다.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{toast}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
255
src/frontend/src/screens/work/NoticePage.tsx
Normal file
255
src/frontend/src/screens/work/NoticePage.tsx
Normal file
@ -0,0 +1,255 @@
|
||||
/*
|
||||
* SCR-42 공지 (notice). 참조: UIWS notice/*.
|
||||
* 고정 공지(상단) + 검색/목록 테이블 + 상세. 쓰기=매니저(백엔드 requireManager) → 홀매니저만 작성 UI 노출.
|
||||
* 백엔드: /api/work/notices.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { noticeApi } from '../../api/endpoints';
|
||||
import type { NoticeDto, NoticeSaveRequest } from '../../api/types';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import { IconPlus, IconSearch } from '../../components/ui/icons';
|
||||
import { errMessage, fmtDateTime, useToast } from './workShared';
|
||||
import './work.css';
|
||||
|
||||
export function NoticePage() {
|
||||
const qc = useQueryClient();
|
||||
const { show, node: toast } = useToast();
|
||||
const isManager = useAuthStore((s) => s.user?.hallManager ?? false);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [term, setTerm] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [composing, setComposing] = useState(false);
|
||||
|
||||
const listQ = useQuery({
|
||||
queryKey: ['notices', term],
|
||||
queryFn: () => noticeApi.list({ keyword: term || undefined, page: 0, size: 50 }),
|
||||
});
|
||||
const detailQ = useQuery({
|
||||
queryKey: ['notice', selectedId],
|
||||
queryFn: () => noticeApi.get(selectedId as string),
|
||||
enabled: !!selectedId && !composing,
|
||||
});
|
||||
|
||||
const saveM = useMutation({
|
||||
mutationFn: (body: NoticeSaveRequest) => noticeApi.create(body),
|
||||
onSuccess: (n: NoticeDto) => {
|
||||
show('공지가 등록되었습니다.');
|
||||
setComposing(false);
|
||||
setSelectedId(n.id);
|
||||
qc.invalidateQueries({ queryKey: ['notices'] });
|
||||
},
|
||||
onError: (e) => show(errMessage(e)),
|
||||
});
|
||||
const delM = useMutation({
|
||||
mutationFn: (id: string) => noticeApi.remove(id),
|
||||
onSuccess: () => {
|
||||
show('삭제되었습니다.');
|
||||
setSelectedId(null);
|
||||
qc.invalidateQueries({ queryKey: ['notices'] });
|
||||
},
|
||||
onError: (e) => show(errMessage(e)),
|
||||
});
|
||||
|
||||
const rows = listQ.data?.items ?? [];
|
||||
const pinned = rows.filter((r) => r.pinned);
|
||||
const normal = rows.filter((r) => !r.pinned);
|
||||
|
||||
const renderRow = (r: NoticeDto) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className={selectedId === r.id ? 'is-selected' : ''}
|
||||
onClick={() => {
|
||||
setComposing(false);
|
||||
setSelectedId(r.id);
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
{r.pinned && <span className="kx-pill kx-pill--danger" style={{ marginRight: 6 }}>중요</span>}
|
||||
<span className="kx-list-table__title">{r.title}</span>
|
||||
{r.category && <span className="kx-list-table__muted"> · {r.category}</span>}
|
||||
</td>
|
||||
<td className="kx-list-table__muted">{r.authorName}</td>
|
||||
<td className="kx-list-table__muted kx-num">{fmtDateTime(r.publishedAt ?? r.createdAt)}</td>
|
||||
<td className="kx-num">{r.viewCount ?? 0}</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="kx-page">
|
||||
<header className="kx-work__head">
|
||||
<div>
|
||||
<h1 className="kx-work__title">공지</h1>
|
||||
<p className="kx-work__subtitle">킨텍스·행사 공지 · 중요 공지 상단 고정</p>
|
||||
</div>
|
||||
{isManager && (
|
||||
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setComposing(true)}>
|
||||
공지 작성
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="kx-split">
|
||||
<section className="kx-card" aria-label="공지 목록">
|
||||
<div className="kx-filterbar">
|
||||
<div className="kx-search__box" style={{ height: 40, flex: 1 }}>
|
||||
<IconSearch size={18} />
|
||||
<input
|
||||
placeholder="공지 검색…"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && setTerm(keyword)}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => setTerm(keyword)}>
|
||||
검색
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="kx-table-scroll kx-scroll-70">
|
||||
<table className="kx-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>제목</th>
|
||||
<th style={{ width: 110 }}>작성자</th>
|
||||
<th style={{ width: 140 }}>게시일</th>
|
||||
<th style={{ width: 70 }} className="kx-num">
|
||||
조회
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{listQ.isLoading &&
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td colSpan={4}>
|
||||
<Skeleton height={20} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!listQ.isLoading && pinned.map(renderRow)}
|
||||
{!listQ.isLoading && normal.map(renderRow)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{listQ.isError && (
|
||||
<ErrorState message={errMessage(listQ.error)} onRetry={() => listQ.refetch()} />
|
||||
)}
|
||||
{!listQ.isLoading && !listQ.isError && rows.length === 0 && (
|
||||
<EmptyState title="등록된 공지가 없습니다" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="kx-detail" aria-label="공지 상세">
|
||||
{composing ? (
|
||||
<NoticeCompose
|
||||
saving={saveM.isPending}
|
||||
onCancel={() => setComposing(false)}
|
||||
onSave={(b) => saveM.mutate(b)}
|
||||
/>
|
||||
) : selectedId ? (
|
||||
detailQ.isLoading ? (
|
||||
<>
|
||||
<Skeleton height={24} width="70%" />
|
||||
<Skeleton height={16} />
|
||||
<Skeleton height={120} />
|
||||
</>
|
||||
) : detailQ.isError ? (
|
||||
<ErrorState message={errMessage(detailQ.error)} onRetry={() => detailQ.refetch()} />
|
||||
) : detailQ.data ? (
|
||||
<>
|
||||
<div className="kx-detail__head">
|
||||
<h2 className="kx-detail__title">
|
||||
{detailQ.data.pinned && (
|
||||
<span className="kx-pill kx-pill--danger" style={{ marginRight: 6 }}>
|
||||
중요
|
||||
</span>
|
||||
)}
|
||||
{detailQ.data.title}
|
||||
</h2>
|
||||
{isManager && (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
if (window.confirm('이 공지를 삭제할까요?')) delM.mutate(detailQ.data!.id);
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="kx-detail__meta">
|
||||
<span>{detailQ.data.authorName}</span>
|
||||
<span>· {fmtDateTime(detailQ.data.publishedAt ?? detailQ.data.createdAt)}</span>
|
||||
<span>· 조회 {detailQ.data.viewCount ?? 0}</span>
|
||||
</div>
|
||||
<div className="kx-detail__body">{detailQ.data.content || '(내용 없음)'}</div>
|
||||
</>
|
||||
) : null
|
||||
) : (
|
||||
<EmptyState title="공지를 선택하세요" description="목록에서 공지를 선택하면 내용이 표시됩니다." />
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
{toast}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoticeCompose({
|
||||
onCancel,
|
||||
onSave,
|
||||
saving,
|
||||
}: {
|
||||
onCancel: () => void;
|
||||
onSave: (b: NoticeSaveRequest) => void;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [category, setCategory] = useState('일반');
|
||||
const [content, setContent] = useState('');
|
||||
const [pinned, setPinned] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<div className="kx-detail__head">
|
||||
<h2 className="kx-detail__title">공지 작성</h2>
|
||||
</div>
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">제목 *</span>
|
||||
<input className="kx-input" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</label>
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">분류</span>
|
||||
<select className="kx-select" value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||
{['일반', '행사', '규정', '시스템'].map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">내용</span>
|
||||
<textarea className="kx-textarea" value={content} onChange={(e) => setContent(e.target.value)} />
|
||||
</label>
|
||||
<label className="kx-switch">
|
||||
<input type="checkbox" checked={pinned} onChange={(e) => setPinned(e.target.checked)} />
|
||||
<span>중요 공지로 상단 고정</span>
|
||||
</label>
|
||||
<div className="kx-detail__actions">
|
||||
<Button
|
||||
disabled={!title.trim() || saving}
|
||||
onClick={() => onSave({ title, category, content, pinned })}
|
||||
>
|
||||
{saving ? '등록 중…' : '등록'}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
171
src/frontend/src/screens/work/NotificationCenterPage.tsx
Normal file
171
src/frontend/src/screens/work/NotificationCenterPage.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
/*
|
||||
* SCR-47 알림센터 (notification). 참조: UIWS NotificationsPage.
|
||||
* 카테고리 칩 + 시간순 목록 + 읽음/모두읽음 + 딥링크 + 삭제.
|
||||
* 백엔드: /api/work/notifications (list·unread-count·read·read-all·delete).
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { notificationApi } from '../../api/endpoints';
|
||||
import type { NotificationDto } from '../../api/types';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import { IconArrowRight, IconBell, IconCheck } from '../../components/ui/icons';
|
||||
import { errMessage, timeAgo, useToast } from './workShared';
|
||||
import './work.css';
|
||||
|
||||
// 알림 유형 라벨(백엔드 notiType 원문 매핑, 미지정은 원문 표기).
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
DEADLINE: '마감',
|
||||
AWARD: '낙찰',
|
||||
APPROVAL: '승인',
|
||||
PAYMENT: '결제',
|
||||
SYSTEM: '시스템',
|
||||
};
|
||||
|
||||
export function NotificationCenterPage() {
|
||||
const qc = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { show, node: toast } = useToast();
|
||||
const [category, setCategory] = useState<string>('ALL');
|
||||
|
||||
const listQ = useQuery({
|
||||
queryKey: ['notifications-center'],
|
||||
queryFn: () => notificationApi.list({ page: 0, size: 100 }),
|
||||
});
|
||||
|
||||
const readM = useMutation({
|
||||
mutationFn: (id: string) => notificationApi.read(id),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
const readAllM = useMutation({
|
||||
mutationFn: () => notificationApi.readAll(),
|
||||
onSuccess: () => {
|
||||
show('모두 읽음 처리했습니다.');
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => show(errMessage(e)),
|
||||
});
|
||||
const delM = useMutation({
|
||||
mutationFn: (id: string) => notificationApi.remove(id),
|
||||
onSuccess: () => invalidate(),
|
||||
onError: (e) => show(errMessage(e)),
|
||||
});
|
||||
|
||||
function invalidate() {
|
||||
qc.invalidateQueries({ queryKey: ['notifications-center'] });
|
||||
qc.invalidateQueries({ queryKey: ['notif-unread'] });
|
||||
}
|
||||
|
||||
const items = listQ.data?.items ?? [];
|
||||
const categories = useMemo(() => {
|
||||
const present = Array.from(new Set(items.map((n) => n.notiType)));
|
||||
return ['ALL', ...present];
|
||||
}, [items]);
|
||||
const filtered = category === 'ALL' ? items : items.filter((n) => n.notiType === category);
|
||||
const unreadCount = items.filter((n) => !n.readAt).length;
|
||||
|
||||
function openItem(n: NotificationDto) {
|
||||
if (!n.readAt) readM.mutate(n.id);
|
||||
if (n.link) {
|
||||
if (/^https?:\/\//.test(n.link)) window.open(n.link, '_blank', 'noopener');
|
||||
else navigate(n.link);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="kx-page">
|
||||
<header className="kx-work__head">
|
||||
<div>
|
||||
<h1 className="kx-work__title">알림</h1>
|
||||
<p className="kx-work__subtitle">
|
||||
마감·낙찰·승인·결제·시스템 알림 {unreadCount > 0 && `· 안읽음 ${unreadCount}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="kx-work__head-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
leadingIcon={<IconCheck size={16} />}
|
||||
disabled={unreadCount === 0 || readAllM.isPending}
|
||||
onClick={() => readAllM.mutate()}
|
||||
>
|
||||
모두 읽음
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="kx-noti__chips">
|
||||
{categories.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
className={`kx-chip ${category === c ? 'is-active' : ''}`}
|
||||
onClick={() => setCategory(c)}
|
||||
>
|
||||
{c === 'ALL' ? '전체' : (TYPE_LABEL[c] ?? c)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="kx-card" style={{ padding: 0 }} aria-label="알림 목록">
|
||||
{listQ.isLoading &&
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<div className="kx-noti__item" key={i}>
|
||||
<Skeleton height={32} width={32} radius={16} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Skeleton height={16} width="40%" />
|
||||
<Skeleton height={14} width="70%" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{listQ.isError && (
|
||||
<ErrorState message={errMessage(listQ.error)} onRetry={() => listQ.refetch()} />
|
||||
)}
|
||||
{!listQ.isLoading && !listQ.isError && filtered.length === 0 && (
|
||||
<EmptyState title="새 알림이 없습니다" icon={<IconBell size={28} />} />
|
||||
)}
|
||||
{filtered.map((n) => (
|
||||
<div key={n.id} className={`kx-noti__item ${!n.readAt ? 'is-unread' : ''}`}>
|
||||
<span className="kx-noti__icon">
|
||||
<IconBell size={16} />
|
||||
</span>
|
||||
<div
|
||||
className="kx-noti__body"
|
||||
style={{ cursor: n.link ? 'pointer' : 'default' }}
|
||||
onClick={() => openItem(n)}
|
||||
>
|
||||
<div className="kx-noti__title">
|
||||
<span className="kx-pill kx-pill--info" style={{ marginRight: 6 }}>
|
||||
{TYPE_LABEL[n.notiType] ?? n.notiType}
|
||||
</span>
|
||||
{n.title}
|
||||
</div>
|
||||
{n.message && <div className="kx-noti__desc">{n.message}</div>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4 }}>
|
||||
<span className="kx-noti__time">{timeAgo(n.createdAt)}</span>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{n.link && (
|
||||
<button
|
||||
className="kx-tag"
|
||||
onClick={() => openItem(n)}
|
||||
aria-label="바로가기"
|
||||
>
|
||||
바로가기 <IconArrowRight size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button className="kx-tag" onClick={() => delM.mutate(n.id)} aria-label="삭제">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!n.readAt && <span className="kx-noti__dot" aria-label="안읽음" />}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<p className="kx-list-table__muted" style={{ fontSize: 12 }}>
|
||||
알림 규칙 설정은 마이페이지 > 알림 설정에서 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
284
src/frontend/src/screens/work/OpinionPage.tsx
Normal file
284
src/frontend/src/screens/work/OpinionPage.tsx
Normal file
@ -0,0 +1,284 @@
|
||||
/*
|
||||
* SCR-43 의견접수 (opinion). 참조: UIWS opinion/*.
|
||||
* 좌: 접수 폼 + 내 접수 목록(상태 배지) / 우: 상세(답변 스레드).
|
||||
* 백엔드: /api/work/opinions. 상태변경·답변=매니저(requireManager).
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { opinionApi } from '../../api/endpoints';
|
||||
import type { OpinionDto, OpinionSaveRequest } from '../../api/types';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import { StatusPill, errMessage, fmtDateTime, useToast } from './workShared';
|
||||
import './work.css';
|
||||
|
||||
const CATEGORIES = ['규정 문의', '개선 제안', '불편 신고', '기타'];
|
||||
const STATUS_FLOW = ['접수', '검토중', '답변완료'];
|
||||
|
||||
export function OpinionPage() {
|
||||
const qc = useQueryClient();
|
||||
const { show, node: toast } = useToast();
|
||||
const isManager = useAuthStore((s) => s.user?.hallManager ?? false);
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const [form, setForm] = useState<OpinionSaveRequest>({
|
||||
category: CATEGORIES[0],
|
||||
title: '',
|
||||
content: '',
|
||||
secretYn: 'N',
|
||||
});
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
const listQ = useQuery({
|
||||
queryKey: ['opinions', statusFilter],
|
||||
queryFn: () => opinionApi.list({ status: statusFilter || undefined, page: 0, size: 50 }),
|
||||
});
|
||||
const detailQ = useQuery({
|
||||
queryKey: ['opinion', selectedId],
|
||||
queryFn: () => opinionApi.get(selectedId as string),
|
||||
enabled: !!selectedId,
|
||||
});
|
||||
|
||||
const createM = useMutation({
|
||||
mutationFn: (b: OpinionSaveRequest) => opinionApi.create(b),
|
||||
onSuccess: () => {
|
||||
show('의견이 접수되었습니다.');
|
||||
setForm({ category: CATEGORIES[0], title: '', content: '', secretYn: 'N' });
|
||||
qc.invalidateQueries({ queryKey: ['opinions'] });
|
||||
},
|
||||
onError: (e) => show(errMessage(e)),
|
||||
});
|
||||
const statusM = useMutation({
|
||||
mutationFn: ({ id, value }: { id: string; value: string }) => opinionApi.changeStatus(id, value),
|
||||
onSuccess: () => {
|
||||
show('상태가 변경되었습니다.');
|
||||
qc.invalidateQueries({ queryKey: ['opinions'] });
|
||||
qc.invalidateQueries({ queryKey: ['opinion', selectedId] });
|
||||
},
|
||||
onError: (e) => show(errMessage(e)),
|
||||
});
|
||||
const commentM = useMutation({
|
||||
mutationFn: ({ id, content }: { id: string; content: string }) => opinionApi.comment(id, content),
|
||||
onSuccess: () => {
|
||||
show('답변이 등록되었습니다.');
|
||||
setComment('');
|
||||
qc.invalidateQueries({ queryKey: ['opinion', selectedId] });
|
||||
},
|
||||
onError: (e) => show(errMessage(e)),
|
||||
});
|
||||
|
||||
const rows = listQ.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="kx-page">
|
||||
<header className="kx-work__head">
|
||||
<div>
|
||||
<h1 className="kx-work__title">의견접수</h1>
|
||||
<p className="kx-work__subtitle">고객의 소리 · 규정 문의 · 개선 제안</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="kx-split">
|
||||
{/* 좌: 접수 폼 + 목록 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<section className="kx-card" aria-label="의견 접수">
|
||||
<div className="kx-card__head">
|
||||
<h2>의견 접수</h2>
|
||||
</div>
|
||||
<div className="kx-formgrid">
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">분류</span>
|
||||
<select
|
||||
className="kx-select"
|
||||
value={form.category ?? ''}
|
||||
onChange={(e) => setForm({ ...form, category: e.target.value })}
|
||||
>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="kx-switch" style={{ alignSelf: 'end' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.secretYn === 'Y'}
|
||||
onChange={(e) => setForm({ ...form, secretYn: e.target.checked ? 'Y' : 'N' })}
|
||||
/>
|
||||
<span>비공개(작성자·매니저만)</span>
|
||||
</label>
|
||||
<label className="kx-field kx-field--full">
|
||||
<span className="kx-label">제목 *</span>
|
||||
<input
|
||||
className="kx-input"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="kx-field kx-field--full">
|
||||
<span className="kx-label">내용</span>
|
||||
<textarea
|
||||
className="kx-textarea"
|
||||
value={form.content ?? ''}
|
||||
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="kx-detail__actions" style={{ marginTop: 12 }}>
|
||||
<Button disabled={!form.title.trim() || createM.isPending} onClick={() => createM.mutate(form)}>
|
||||
{createM.isPending ? '제출 중…' : '제출'}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="kx-card" aria-label="접수 목록">
|
||||
<div className="kx-card__head">
|
||||
<h2>접수 내역</h2>
|
||||
<select
|
||||
className="kx-select"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="">전체 상태</option>
|
||||
{STATUS_FLOW.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="kx-table-scroll">
|
||||
<table className="kx-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>제목</th>
|
||||
<th style={{ width: 96 }}>분류</th>
|
||||
<th style={{ width: 96 }}>상태</th>
|
||||
<th style={{ width: 120 }}>접수일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{listQ.isLoading &&
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td colSpan={4}>
|
||||
<Skeleton height={20} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!listQ.isLoading &&
|
||||
rows.map((r: OpinionDto) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className={selectedId === r.id ? 'is-selected' : ''}
|
||||
onClick={() => setSelectedId(r.id)}
|
||||
>
|
||||
<td>
|
||||
<span className="kx-list-table__title">{r.title}</span>
|
||||
{r.secretYn === 'Y' && (
|
||||
<span className="kx-list-table__muted"> · 비공개</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="kx-list-table__muted">{r.category ?? '-'}</td>
|
||||
<td>
|
||||
<StatusPill value={r.status} />
|
||||
</td>
|
||||
<td className="kx-list-table__muted kx-num">{fmtDateTime(r.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{listQ.isError && (
|
||||
<ErrorState message={errMessage(listQ.error)} onRetry={() => listQ.refetch()} />
|
||||
)}
|
||||
{!listQ.isLoading && !listQ.isError && rows.length === 0 && (
|
||||
<EmptyState title="접수된 의견이 없습니다" />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 우: 상세 */}
|
||||
<aside className="kx-detail" aria-label="의견 상세">
|
||||
{!selectedId ? (
|
||||
<EmptyState title="의견을 선택하세요" description="목록에서 항목을 선택하면 상세·답변이 표시됩니다." />
|
||||
) : detailQ.isLoading ? (
|
||||
<>
|
||||
<Skeleton height={24} width="70%" />
|
||||
<Skeleton height={80} />
|
||||
</>
|
||||
) : detailQ.isError ? (
|
||||
<ErrorState message={errMessage(detailQ.error)} onRetry={() => detailQ.refetch()} />
|
||||
) : detailQ.data ? (
|
||||
<>
|
||||
<div className="kx-detail__head">
|
||||
<h2 className="kx-detail__title">{detailQ.data.opinion.title}</h2>
|
||||
<StatusPill value={detailQ.data.opinion.status} />
|
||||
</div>
|
||||
<div className="kx-detail__meta">
|
||||
<span>{detailQ.data.opinion.authorName}</span>
|
||||
<span>· {detailQ.data.opinion.category}</span>
|
||||
<span>· {fmtDateTime(detailQ.data.opinion.createdAt)}</span>
|
||||
</div>
|
||||
<div className="kx-detail__body">{detailQ.data.opinion.content || '(내용 없음)'}</div>
|
||||
|
||||
{isManager && (
|
||||
<div className="kx-field">
|
||||
<span className="kx-label">상태 변경 (매니저)</span>
|
||||
<div className="kx-noti__chips">
|
||||
{STATUS_FLOW.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
className={`kx-chip ${detailQ.data!.opinion.status === s ? 'is-active' : ''}`}
|
||||
onClick={() => statusM.mutate({ id: detailQ.data!.opinion.id, value: s })}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="kx-thread">
|
||||
{detailQ.data.comments.length === 0 && (
|
||||
<p className="kx-list-table__muted">아직 답변이 없습니다.</p>
|
||||
)}
|
||||
{detailQ.data.comments.map((c) => (
|
||||
<div className="kx-thread__item" key={c.id}>
|
||||
<div className="kx-thread__meta">
|
||||
<span className="kx-thread__author">{c.authorName}</span>
|
||||
<span>{fmtDateTime(c.createdAt)}</span>
|
||||
</div>
|
||||
<div className="kx-thread__text">{c.content}</div>
|
||||
</div>
|
||||
))}
|
||||
{isManager && (
|
||||
<div className="kx-thread__compose">
|
||||
<textarea
|
||||
className="kx-textarea"
|
||||
placeholder="답변 작성…"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
disabled={!comment.trim() || commentM.isPending}
|
||||
onClick={() =>
|
||||
commentM.mutate({ id: detailQ.data!.opinion.id, content: comment })
|
||||
}
|
||||
>
|
||||
답변
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</aside>
|
||||
</div>
|
||||
{toast}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
251
src/frontend/src/screens/work/ReportPage.tsx
Normal file
251
src/frontend/src/screens/work/ReportPage.tsx
Normal file
@ -0,0 +1,251 @@
|
||||
/*
|
||||
* SCR-46 업무보고·통계 (report + stats). 참조: UIWS report/*·stats/*.
|
||||
* 기간 탭(일/주/월/분기/연) + 요약 KPI + 유형별 차트 + 작성자별 표 + 최근 보고서 목록.
|
||||
* 백엔드: /api/work/stats/worklog(fromDate·toDate 집계), /api/work/reports(문서).
|
||||
* ★ 갭: 보고서 Jasper PDF 출력 엔드포인트 부재 → PDF 버튼 disabled. 07_work_api_gaps.md 기록.
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { reportApi, workStatsApi } from '../../api/endpoints';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { CHART } from '../chartColors';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import { IconDownload } from '../../components/ui/icons';
|
||||
import { errMessage, fmtDate } from './workShared';
|
||||
import './work.css';
|
||||
|
||||
type Period = 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'YEARLY';
|
||||
const PERIODS: { v: Period; l: string }[] = [
|
||||
{ v: 'DAILY', l: '일간' },
|
||||
{ v: 'WEEKLY', l: '주간' },
|
||||
{ v: 'MONTHLY', l: '월간' },
|
||||
{ v: 'QUARTERLY', l: '분기' },
|
||||
{ v: 'YEARLY', l: '연간' },
|
||||
];
|
||||
const BAR_COLORS = [CHART.primary600, CHART.primary700, CHART.aiAccent, CHART.success, CHART.warning, CHART.slate];
|
||||
|
||||
function iso(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
/** 기간 → [fromDate, toDate] (역년/ISO 주 기준, 화면 편의 산출). */
|
||||
function periodRange(p: Period): [string, string] {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
if (p === 'DAILY') return [iso(now), iso(now)];
|
||||
if (p === 'WEEKLY') {
|
||||
const day = now.getDay();
|
||||
const mon = new Date(now);
|
||||
mon.setDate(now.getDate() - ((day + 6) % 7));
|
||||
const sun = new Date(mon);
|
||||
sun.setDate(mon.getDate() + 6);
|
||||
return [iso(mon), iso(sun)];
|
||||
}
|
||||
if (p === 'MONTHLY') return [iso(new Date(y, now.getMonth(), 1)), iso(new Date(y, now.getMonth() + 1, 0))];
|
||||
if (p === 'QUARTERLY') {
|
||||
const q = Math.floor(now.getMonth() / 3);
|
||||
return [iso(new Date(y, q * 3, 1)), iso(new Date(y, q * 3 + 3, 0))];
|
||||
}
|
||||
return [iso(new Date(y, 0, 1)), iso(new Date(y, 11, 31))];
|
||||
}
|
||||
|
||||
export function ReportPage() {
|
||||
const [period, setPeriod] = useState<Period>('WEEKLY');
|
||||
const [from, to] = useMemo(() => periodRange(period), [period]);
|
||||
|
||||
const statsQ = useQuery({
|
||||
queryKey: ['work-stats', from, to],
|
||||
queryFn: () => workStatsApi.worklog({ fromDate: from, toDate: to }),
|
||||
});
|
||||
const reportsQ = useQuery({
|
||||
queryKey: ['reports'],
|
||||
queryFn: () => reportApi.list({ page: 0, size: 20 }),
|
||||
});
|
||||
|
||||
const stats = statsQ.data;
|
||||
|
||||
return (
|
||||
<div className="kx-page">
|
||||
<header className="kx-work__head">
|
||||
<div>
|
||||
<h1 className="kx-work__title">업무보고·통계</h1>
|
||||
<p className="kx-work__subtitle">
|
||||
일상 업무보고 집계 ({from.replace(/-/g, '.')} ~ {to.replace(/-/g, '.')})
|
||||
</p>
|
||||
</div>
|
||||
<div className="kx-work__head-actions">
|
||||
<div className="kx-seg" role="tablist" aria-label="기간">
|
||||
{PERIODS.map((p) => (
|
||||
<button
|
||||
key={p.v}
|
||||
role="tab"
|
||||
aria-selected={period === p.v}
|
||||
className={`kx-seg__btn ${period === p.v ? 'is-active' : ''}`}
|
||||
onClick={() => setPeriod(p.v)}
|
||||
>
|
||||
{p.l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span title="백엔드 미지원 — Jasper PDF 연동 후 활성화" style={{ display: 'inline-flex' }}>
|
||||
<Button variant="secondary" disabled leadingIcon={<IconDownload size={16} />}>
|
||||
보고서 PDF
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* KPI */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16 }}>
|
||||
<div className="kx-kpi">
|
||||
<span className="kx-kpi__label">총 업무 건수</span>
|
||||
<span className="kx-kpi__value tnum">
|
||||
{statsQ.isLoading ? <Skeleton height={28} width={80} /> : (stats?.totalCount ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="kx-kpi">
|
||||
<span className="kx-kpi__label">총 공수(시간)</span>
|
||||
<span className="kx-kpi__value tnum">
|
||||
{statsQ.isLoading ? <Skeleton height={28} width={80} /> : (stats?.totalHours ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="kx-kpi">
|
||||
<span className="kx-kpi__label">참여 작성자</span>
|
||||
<span className="kx-kpi__value tnum">
|
||||
{statsQ.isLoading ? <Skeleton height={28} width={80} /> : (stats?.byWriter.length ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{statsQ.isError && (
|
||||
<ErrorState message={errMessage(statsQ.error)} onRetry={() => statsQ.refetch()} />
|
||||
)}
|
||||
|
||||
{/* 유형별 차트 */}
|
||||
<section className="kx-card" aria-label="유형별 업무량">
|
||||
<div className="kx-card__head">
|
||||
<h2>유형별 업무량</h2>
|
||||
</div>
|
||||
{statsQ.isLoading ? (
|
||||
<Skeleton height={260} />
|
||||
) : !stats || stats.byType.length === 0 ? (
|
||||
<EmptyState title="집계할 데이터가 없습니다" description="선택 기간에 등록된 업무일지가 없습니다." />
|
||||
) : (
|
||||
<div style={{ width: '100%', height: 280 }}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={stats.byType} margin={{ top: 8, right: 8, bottom: 8, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART.neutral100} vertical={false} />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12, fill: CHART.slate }} />
|
||||
<YAxis tick={{ fontSize: 12, fill: CHART.slate }} allowDecimals={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ fontSize: 12, borderRadius: 8, border: `1px solid ${CHART.neutral200}` }}
|
||||
formatter={(v: number) => [`${v}건`, '건수']}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
|
||||
{stats.byType.map((_, i) => (
|
||||
<Cell key={i} fill={BAR_COLORS[i % BAR_COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 작성자별 표 + 최근 보고서 */}
|
||||
<div className="kx-split" style={{ gridTemplateColumns: '1fr 1fr' }}>
|
||||
<section className="kx-card" aria-label="작성자별 집계">
|
||||
<div className="kx-card__head">
|
||||
<h2>작성자별 집계</h2>
|
||||
</div>
|
||||
<div className="kx-table-scroll">
|
||||
<table className="kx-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작성자</th>
|
||||
<th className="kx-num" style={{ width: 80 }}>
|
||||
건수
|
||||
</th>
|
||||
<th className="kx-num" style={{ width: 90 }}>
|
||||
공수(h)
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{statsQ.isLoading &&
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td colSpan={3}>
|
||||
<Skeleton height={20} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!statsQ.isLoading &&
|
||||
(stats?.byWriter ?? []).map((w) => (
|
||||
<tr key={w.name} style={{ cursor: 'default' }}>
|
||||
<td className="kx-list-table__title">{w.name}</td>
|
||||
<td className="kx-num">{w.count}</td>
|
||||
<td className="kx-num">{w.hours}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!statsQ.isLoading && (stats?.byWriter.length ?? 0) === 0 && (
|
||||
<EmptyState title="집계 데이터 없음" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="kx-card" aria-label="최근 보고서">
|
||||
<div className="kx-card__head">
|
||||
<h2>최근 업무보고</h2>
|
||||
</div>
|
||||
<div className="kx-table-scroll">
|
||||
<table className="kx-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>제목</th>
|
||||
<th style={{ width: 80 }}>유형</th>
|
||||
<th style={{ width: 90 }}>작성일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reportsQ.isLoading &&
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td colSpan={3}>
|
||||
<Skeleton height={20} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!reportsQ.isLoading &&
|
||||
(reportsQ.data?.items ?? []).map((r) => (
|
||||
<tr key={r.id} style={{ cursor: 'default' }}>
|
||||
<td className="kx-list-table__title">{r.title}</td>
|
||||
<td className="kx-list-table__muted">{r.reportType}</td>
|
||||
<td className="kx-list-table__muted kx-num">{fmtDate(r.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{reportsQ.isError && (
|
||||
<ErrorState message={errMessage(reportsQ.error)} onRetry={() => reportsQ.refetch()} />
|
||||
)}
|
||||
{!reportsQ.isLoading && !reportsQ.isError && (reportsQ.data?.items.length ?? 0) === 0 && (
|
||||
<EmptyState title="작성된 보고서가 없습니다" />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
src/frontend/src/screens/work/SearchPage.tsx
Normal file
179
src/frontend/src/screens/work/SearchPage.tsx
Normal file
@ -0,0 +1,179 @@
|
||||
/*
|
||||
* SCR-44 통합검색 (search). 참조: UIWS SearchResultsPage.
|
||||
* 대형 검색창 + 결과 타입 탭 + 좌측 파셋. 백엔드: /api/work/search (WORKLOG|NOTICE|MEETING|REPORT|OPINION).
|
||||
* ★ 갭: AI 자연어 검색 엔드포인트·크로스모듈(행사/부스/업체) 검색 부재 → AI 토글은 UI 표기(비활성 안내). 07_work_api_gaps.md 기록.
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { searchApi } from '../../api/endpoints';
|
||||
import type { SearchResultItem } from '../../api/types';
|
||||
import { AiLabel } from '../../components/ui/Badge';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import {
|
||||
IconCalendar,
|
||||
IconDocument,
|
||||
IconSearch,
|
||||
IconSpark,
|
||||
} from '../../components/ui/icons';
|
||||
import { fmtDateTime, errMessage } from './workShared';
|
||||
import './work.css';
|
||||
|
||||
const TYPE_META: Record<string, { label: string; Icon: typeof IconDocument }> = {
|
||||
WORKLOG: { label: '업무일지', Icon: IconDocument },
|
||||
NOTICE: { label: '공지', Icon: IconDocument },
|
||||
MEETING: { label: '회의록', Icon: IconCalendar },
|
||||
REPORT: { label: '업무보고', Icon: IconDocument },
|
||||
OPINION: { label: '의견', Icon: IconDocument },
|
||||
};
|
||||
|
||||
export function SearchPage() {
|
||||
const [input, setInput] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [aiMode, setAiMode] = useState(false);
|
||||
const [tab, setTab] = useState<string>('ALL');
|
||||
|
||||
const searchQ = useQuery({
|
||||
queryKey: ['search', query],
|
||||
queryFn: () => searchApi.search(query, 50),
|
||||
enabled: query.trim().length > 0,
|
||||
});
|
||||
|
||||
const results = searchQ.data ?? [];
|
||||
const counts = useMemo(() => {
|
||||
const c: Record<string, number> = { ALL: results.length };
|
||||
for (const r of results) c[r.type] = (c[r.type] ?? 0) + 1;
|
||||
return c;
|
||||
}, [results]);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
const present = Array.from(new Set(results.map((r) => r.type)));
|
||||
return ['ALL', ...present];
|
||||
}, [results]);
|
||||
|
||||
const filtered = tab === 'ALL' ? results : results.filter((r) => r.type === tab);
|
||||
|
||||
function highlight(text: string): React.ReactNode {
|
||||
if (!query.trim()) return text;
|
||||
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
||||
if (idx < 0) return text;
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark style={{ background: '#FEF0C7', padding: '0 1px' }}>
|
||||
{text.slice(idx, idx + query.length)}
|
||||
</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="kx-page">
|
||||
<header className="kx-work__head">
|
||||
<div>
|
||||
<h1 className="kx-work__title">통합검색</h1>
|
||||
<p className="kx-work__subtitle">업무일지·공지·회의록·보고·의견 크로스 검색</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="kx-search__hero">
|
||||
<div className="kx-search__box">
|
||||
<IconSearch size={22} />
|
||||
<input
|
||||
placeholder="검색어를 입력하세요"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && setQuery(input)}
|
||||
aria-label="통합검색"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
className={`kx-search__ai-toggle ${aiMode ? '' : 'is-off'}`}
|
||||
onClick={() => setAiMode((v) => !v)}
|
||||
title="AI 자연어 검색(준비 중)"
|
||||
>
|
||||
<IconSpark size={14} /> AI 자연어 검색
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={() => setQuery(input)}>검색</Button>
|
||||
</div>
|
||||
|
||||
{aiMode && (
|
||||
<div className="kx-meet__ai-card" role="note">
|
||||
<AiLabel>AI 자연어 검색</AiLabel>{' '}
|
||||
<span className="kx-list-table__muted">
|
||||
자연어 검색은 준비 중입니다. 현재는 키워드 검색으로 동작합니다.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!query.trim() ? (
|
||||
<EmptyState
|
||||
title="검색어를 입력하세요"
|
||||
description="업무 데이터 전반을 한 번에 검색합니다."
|
||||
icon={<IconSearch size={28} />}
|
||||
/>
|
||||
) : (
|
||||
<div className="kx-cal" style={{ gridTemplateColumns: '200px minmax(0,1fr)' }}>
|
||||
{/* 파셋 */}
|
||||
<aside className="kx-card" aria-label="검색 필터">
|
||||
<div className="kx-card__head">
|
||||
<h2>유형</h2>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`kx-msg__rail-btn ${tab === t ? 'is-active' : ''}`}
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
{t === 'ALL' ? '전체' : (TYPE_META[t]?.label ?? t)}
|
||||
<span className="kx-list-table__muted">{counts[t] ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 결과 */}
|
||||
<section aria-label="검색 결과" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{searchQ.isLoading &&
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<div className="kx-search__result" key={i}>
|
||||
<Skeleton height={36} width={36} radius={6} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Skeleton height={16} width="50%" />
|
||||
<Skeleton height={14} width="80%" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{searchQ.isError && (
|
||||
<ErrorState message={errMessage(searchQ.error)} onRetry={() => searchQ.refetch()} />
|
||||
)}
|
||||
{!searchQ.isLoading && !searchQ.isError && filtered.length === 0 && (
|
||||
<EmptyState title="검색 결과가 없습니다" description={`'${query}'에 대한 결과를 찾지 못했습니다.`} />
|
||||
)}
|
||||
{filtered.map((r: SearchResultItem) => {
|
||||
const meta = TYPE_META[r.type] ?? { label: r.type, Icon: IconDocument };
|
||||
const Icon = meta.Icon;
|
||||
return (
|
||||
<div className="kx-search__result" key={`${r.type}-${r.id}`}>
|
||||
<span className="kx-search__result-icon">
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="kx-list-table__title">{highlight(r.title)}</div>
|
||||
<div className="kx-detail__meta">
|
||||
<span className="kx-pill kx-pill--info">{meta.label}</span>
|
||||
<span>{fmtDateTime(r.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
322
src/frontend/src/screens/work/WorkSchedulePage.tsx
Normal file
322
src/frontend/src/screens/work/WorkSchedulePage.tsx
Normal file
@ -0,0 +1,322 @@
|
||||
/*
|
||||
* SCR-40 통합 일정·캘린더 (schedule). 참조: UIWS schedule/PersonalSchedulePage.
|
||||
* 월 캘린더 + 우측 선택일 어젠다/작성. 개인·부서 업무 일정(운영 SCR-15와 구분).
|
||||
* 백엔드: /api/work/schedules (fromAt/toAt ISO 범위). 색상 범례.
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { scheduleWorkApi } from '../../api/endpoints';
|
||||
import type { ScheduleDto, ScheduleSaveRequest } from '../../api/types';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import { IconChevronLeft, IconChevronRight, IconPlus } from '../../components/ui/icons';
|
||||
import { errMessage, fmtDateTime, useToast } from './workShared';
|
||||
import './work.css';
|
||||
|
||||
const WD = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const TYPE_COLOR: Record<string, string> = {
|
||||
milestone: '#0066B3',
|
||||
auction: '#6D4AFF',
|
||||
movein: '#0E8A5F',
|
||||
meeting: '#B45309',
|
||||
personal: '#667085',
|
||||
};
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
milestone: '행사 마일스톤',
|
||||
auction: '옥션 마감',
|
||||
movein: '반입 슬롯',
|
||||
meeting: '회의',
|
||||
personal: '개인',
|
||||
};
|
||||
|
||||
function ymd(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
function colorOf(s: ScheduleDto): string {
|
||||
return s.color || TYPE_COLOR[(s.scheduleType ?? '').toLowerCase()] || '#0066B3';
|
||||
}
|
||||
|
||||
export function WorkSchedulePage() {
|
||||
const qc = useQueryClient();
|
||||
const { show, node: toast } = useToast();
|
||||
const today = new Date();
|
||||
const [cursor, setCursor] = useState({ y: today.getFullYear(), m: today.getMonth() });
|
||||
const [selDate, setSelDate] = useState(ymd(today));
|
||||
const [composing, setComposing] = useState(false);
|
||||
const [form, setForm] = useState<ScheduleSaveRequest>({
|
||||
title: '',
|
||||
scheduleType: 'personal',
|
||||
startAt: `${ymd(today)}T09:00`,
|
||||
endAt: `${ymd(today)}T10:00`,
|
||||
allDay: false,
|
||||
location: '',
|
||||
content: '',
|
||||
});
|
||||
|
||||
// 표시 그리드(월 시작 주 일요일 ~ 6주) 범위로 조회.
|
||||
const grid = useMemo(() => {
|
||||
const first = new Date(cursor.y, cursor.m, 1);
|
||||
const start = new Date(first);
|
||||
start.setDate(1 - first.getDay());
|
||||
const days: Date[] = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const d = new Date(start);
|
||||
d.setDate(start.getDate() + i);
|
||||
days.push(d);
|
||||
}
|
||||
return { start: days[0], end: days[41], days };
|
||||
}, [cursor]);
|
||||
|
||||
const listQ = useQuery({
|
||||
queryKey: ['schedules', ymd(grid.start), ymd(grid.end)],
|
||||
queryFn: () =>
|
||||
scheduleWorkApi.list({
|
||||
fromAt: `${ymd(grid.start)}T00:00:00`,
|
||||
toAt: `${ymd(grid.end)}T23:59:59`,
|
||||
}),
|
||||
});
|
||||
|
||||
const byDay = useMemo(() => {
|
||||
const map = new Map<string, ScheduleDto[]>();
|
||||
for (const s of listQ.data ?? []) {
|
||||
const key = (s.startAt ?? '').slice(0, 10);
|
||||
if (!key) continue;
|
||||
const arr = map.get(key) ?? [];
|
||||
arr.push(s);
|
||||
map.set(key, arr);
|
||||
}
|
||||
return map;
|
||||
}, [listQ.data]);
|
||||
|
||||
const createM = useMutation({
|
||||
mutationFn: (body: ScheduleSaveRequest) => scheduleWorkApi.create(body),
|
||||
onSuccess: () => {
|
||||
show('일정이 추가되었습니다.');
|
||||
setComposing(false);
|
||||
qc.invalidateQueries({ queryKey: ['schedules'] });
|
||||
},
|
||||
onError: (e) => show(errMessage(e)),
|
||||
});
|
||||
const delM = useMutation({
|
||||
mutationFn: (id: string) => scheduleWorkApi.remove(id),
|
||||
onSuccess: () => {
|
||||
show('삭제되었습니다.');
|
||||
qc.invalidateQueries({ queryKey: ['schedules'] });
|
||||
},
|
||||
onError: (e) => show(errMessage(e)),
|
||||
});
|
||||
|
||||
const agenda = byDay.get(selDate) ?? [];
|
||||
const todayKey = ymd(today);
|
||||
|
||||
function openCompose() {
|
||||
setForm({
|
||||
title: '',
|
||||
scheduleType: 'personal',
|
||||
startAt: `${selDate}T09:00`,
|
||||
endAt: `${selDate}T10:00`,
|
||||
allDay: false,
|
||||
location: '',
|
||||
content: '',
|
||||
});
|
||||
setComposing(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="kx-page">
|
||||
<header className="kx-work__head">
|
||||
<div>
|
||||
<h1 className="kx-work__title">일정</h1>
|
||||
<p className="kx-work__subtitle">개인·부서 업무 일정 · 마일스톤/옥션/반입 통합 뷰</p>
|
||||
</div>
|
||||
<div className="kx-work__head-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
setCursor((c) => (c.m === 0 ? { y: c.y - 1, m: 11 } : { y: c.y, m: c.m - 1 }))
|
||||
}
|
||||
aria-label="이전 달"
|
||||
leadingIcon={<IconChevronLeft size={16} />}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<strong style={{ minWidth: 120, textAlign: 'center' }}>
|
||||
{cursor.y}년 {cursor.m + 1}월
|
||||
</strong>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
setCursor((c) => (c.m === 11 ? { y: c.y + 1, m: 0 } : { y: c.y, m: c.m + 1 }))
|
||||
}
|
||||
aria-label="다음 달"
|
||||
>
|
||||
다음 <IconChevronRight size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="kx-cal">
|
||||
<div>
|
||||
<div className="kx-cal__grid" role="grid" aria-label="월 캘린더">
|
||||
<div className="kx-cal__weekhead" role="row">
|
||||
{WD.map((w, i) => (
|
||||
<div key={w} className={`kx-cal__wd ${i === 0 ? 'kx-cal__wd--sun' : ''}`} role="columnheader">
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: 6 }).map((_, wk) => (
|
||||
<div className="kx-cal__week" role="row" key={wk}>
|
||||
{grid.days.slice(wk * 7, wk * 7 + 7).map((d) => {
|
||||
const key = ymd(d);
|
||||
const evts = byDay.get(key) ?? [];
|
||||
const isOther = d.getMonth() !== cursor.m;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
role="gridcell"
|
||||
className={`kx-cal__cell ${isOther ? 'is-other' : ''} ${
|
||||
key === selDate ? 'is-selected' : ''
|
||||
} ${key === todayKey ? 'is-today' : ''}`}
|
||||
onClick={() => setSelDate(key)}
|
||||
>
|
||||
<span className="kx-cal__daynum">{d.getDate()}</span>
|
||||
{listQ.isLoading && isOther === false && wk === 1 && (
|
||||
<Skeleton height={14} />
|
||||
)}
|
||||
{evts.slice(0, 3).map((s) => (
|
||||
<span
|
||||
key={s.id}
|
||||
className="kx-cal__event"
|
||||
style={{ background: colorOf(s) }}
|
||||
title={s.title}
|
||||
>
|
||||
{s.title}
|
||||
</span>
|
||||
))}
|
||||
{evts.length > 3 && <span className="kx-cal__more">+{evts.length - 3}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="kx-cal__legend" style={{ marginTop: 12 }}>
|
||||
{Object.entries(TYPE_LABEL).map(([k, label]) => (
|
||||
<span key={k}>
|
||||
<span className="kx-cal__swatch" style={{ background: TYPE_COLOR[k] }} />
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{listQ.isError && (
|
||||
<ErrorState message={errMessage(listQ.error)} onRetry={() => listQ.refetch()} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 어젠다 */}
|
||||
<aside className="kx-cal__agenda" aria-label={`${selDate} 일정`}>
|
||||
<div className="kx-detail__head">
|
||||
<h2 className="kx-detail__title">{selDate.replace(/-/g, '.')}</h2>
|
||||
<Button leadingIcon={<IconPlus size={16} />} onClick={openCompose}>
|
||||
일정 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{composing ? (
|
||||
<div className="kx-formgrid" style={{ gridTemplateColumns: '1fr' }}>
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">제목 *</span>
|
||||
<input
|
||||
className="kx-input"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">유형</span>
|
||||
<select
|
||||
className="kx-select"
|
||||
value={form.scheduleType ?? 'personal'}
|
||||
onChange={(e) => setForm({ ...form, scheduleType: e.target.value })}
|
||||
>
|
||||
{Object.entries(TYPE_LABEL).map(([k, l]) => (
|
||||
<option key={k} value={k}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">시작</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="kx-input"
|
||||
value={form.startAt}
|
||||
onChange={(e) => setForm({ ...form, startAt: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">종료</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="kx-input"
|
||||
value={form.endAt ?? ''}
|
||||
onChange={(e) => setForm({ ...form, endAt: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">장소</span>
|
||||
<input
|
||||
className="kx-input"
|
||||
value={form.location ?? ''}
|
||||
onChange={(e) => setForm({ ...form, location: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<div className="kx-detail__actions">
|
||||
<Button
|
||||
disabled={!form.title.trim() || createM.isPending}
|
||||
onClick={() => createM.mutate(form)}
|
||||
>
|
||||
{createM.isPending ? '저장 중…' : '저장'}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setComposing(false)}>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : agenda.length === 0 ? (
|
||||
<EmptyState title="일정이 없습니다" description="이 날짜에 등록된 일정이 없습니다." />
|
||||
) : (
|
||||
agenda.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="kx-cal__agenda-item"
|
||||
style={{ borderLeftColor: colorOf(s) }}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="kx-list-table__title">{s.title}</div>
|
||||
<div className="kx-detail__meta">
|
||||
<span>{s.allDay ? '종일' : fmtDateTime(s.startAt)}</span>
|
||||
{s.location && <span>· {s.location}</span>}
|
||||
<span>· {TYPE_LABEL[(s.scheduleType ?? '').toLowerCase()] ?? s.scheduleType ?? ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="kx-tag"
|
||||
onClick={() => {
|
||||
if (window.confirm('이 일정을 삭제할까요?')) delM.mutate(s.id);
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
{toast}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
339
src/frontend/src/screens/work/WorklogPage.tsx
Normal file
339
src/frontend/src/screens/work/WorklogPage.tsx
Normal file
@ -0,0 +1,339 @@
|
||||
/*
|
||||
* SCR-39 업무일지 (worklog). 참조: UIWS worklog/*.
|
||||
* 좌: 필터 + 목록 테이블(날짜·제목·유형·진행·공수) / 우: 상세·작성 폼(공수·이슈).
|
||||
* 백엔드: /api/work/worklogs (PageResponse). 3상태(로딩/빈/에러) 필수.
|
||||
* ★ 갭: worklog 댓글 스레드 엔드포인트 부재 → 07_work_api_gaps.md 기록.
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { worklogApi } from '../../api/endpoints';
|
||||
import type { WorklogDto, WorklogSaveRequest } from '../../api/types';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import { IconPlus } from '../../components/ui/icons';
|
||||
import { StatusPill, errMessage, fmtDate, useToast } from './workShared';
|
||||
import './work.css';
|
||||
|
||||
const STATUS_OPTS = [
|
||||
{ v: '', l: '전체' },
|
||||
{ v: '진행중', l: '진행중' },
|
||||
{ v: '완료', l: '완료' },
|
||||
{ v: '보류', l: '보류' },
|
||||
];
|
||||
const TYPE_OPTS = ['현장점검', '시공관리', '설계검토', '행정', '회의', '기타'];
|
||||
|
||||
const EMPTY_FORM: WorklogSaveRequest = {
|
||||
workDate: new Date().toISOString().slice(0, 10),
|
||||
title: '',
|
||||
workType: '현장점검',
|
||||
progress: '',
|
||||
status: '진행중',
|
||||
content: '',
|
||||
hours: 0,
|
||||
};
|
||||
|
||||
export function WorklogPage() {
|
||||
const qc = useQueryClient();
|
||||
const { show, node: toast } = useToast();
|
||||
const [fromDate, setFromDate] = useState('');
|
||||
const [toDate, setToDate] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<WorklogSaveRequest>(EMPTY_FORM);
|
||||
|
||||
const listQ = useQuery({
|
||||
queryKey: ['worklogs', { fromDate, toDate, status, page }],
|
||||
queryFn: () =>
|
||||
worklogApi.list({
|
||||
fromDate: fromDate || undefined,
|
||||
toDate: toDate || undefined,
|
||||
status: status || undefined,
|
||||
page,
|
||||
size: 20,
|
||||
}),
|
||||
});
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['worklogs'] });
|
||||
|
||||
const saveM = useMutation({
|
||||
mutationFn: (body: WorklogSaveRequest) =>
|
||||
selectedId ? worklogApi.update(selectedId, body) : worklogApi.create(body),
|
||||
onSuccess: (saved: WorklogDto) => {
|
||||
show(selectedId ? '수정되었습니다.' : '등록되었습니다.');
|
||||
setSelectedId(saved.id);
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => show(errMessage(e)),
|
||||
});
|
||||
|
||||
const delM = useMutation({
|
||||
mutationFn: (id: string) => worklogApi.remove(id),
|
||||
onSuccess: () => {
|
||||
show('삭제되었습니다.');
|
||||
resetForm();
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => show(errMessage(e)),
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
setSelectedId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
}
|
||||
|
||||
function selectRow(row: WorklogDto) {
|
||||
setSelectedId(row.id);
|
||||
setForm({
|
||||
eventId: row.eventId ?? undefined,
|
||||
workDate: row.workDate,
|
||||
title: row.title,
|
||||
workType: row.workType ?? '',
|
||||
progress: row.progress ?? '',
|
||||
status: row.status ?? '',
|
||||
content: row.content ?? '',
|
||||
hours: row.hours,
|
||||
});
|
||||
}
|
||||
|
||||
const rows = listQ.data?.items ?? [];
|
||||
const total = listQ.data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / 20));
|
||||
const canSave = form.title.trim().length > 0 && !!form.workDate;
|
||||
|
||||
const editingTitle = useMemo(() => (selectedId ? '업무일지 수정' : '새 업무일지'), [selectedId]);
|
||||
|
||||
return (
|
||||
<div className="kx-page">
|
||||
<header className="kx-work__head">
|
||||
<div>
|
||||
<h1 className="kx-work__title">업무일지</h1>
|
||||
<p className="kx-work__subtitle">현장·시공·설계 일지 기록 · 공수/이슈 관리</p>
|
||||
</div>
|
||||
<Button leadingIcon={<IconPlus size={16} />} onClick={resetForm}>
|
||||
새 일지
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="kx-split">
|
||||
{/* 목록 */}
|
||||
<section className="kx-card" aria-label="업무일지 목록">
|
||||
<div className="kx-filterbar">
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">시작일</span>
|
||||
<input
|
||||
type="date"
|
||||
className="kx-input"
|
||||
value={fromDate}
|
||||
onChange={(e) => {
|
||||
setFromDate(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">종료일</span>
|
||||
<input
|
||||
type="date"
|
||||
className="kx-input"
|
||||
value={toDate}
|
||||
onChange={(e) => {
|
||||
setToDate(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">진행상태</span>
|
||||
<select
|
||||
className="kx-select"
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
{STATUS_OPTS.map((o) => (
|
||||
<option key={o.v} value={o.v}>
|
||||
{o.l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="kx-table-scroll kx-scroll-70">
|
||||
<table className="kx-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 72 }}>날짜</th>
|
||||
<th>제목</th>
|
||||
<th style={{ width: 92 }}>유형</th>
|
||||
<th style={{ width: 92 }}>진행상태</th>
|
||||
<th style={{ width: 64 }} className="kx-num">
|
||||
공수
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{listQ.isLoading &&
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td colSpan={5}>
|
||||
<Skeleton height={20} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!listQ.isLoading &&
|
||||
rows.map((r) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className={selectedId === r.id ? 'is-selected' : ''}
|
||||
onClick={() => selectRow(r)}
|
||||
>
|
||||
<td className="kx-num">{fmtDate(r.workDate)}</td>
|
||||
<td>
|
||||
<div className="kx-list-table__title">{r.title}</div>
|
||||
<div className="kx-list-table__muted">{r.writerName}</div>
|
||||
</td>
|
||||
<td className="kx-list-table__muted">{r.workType ?? '-'}</td>
|
||||
<td>
|
||||
<StatusPill value={r.status} />
|
||||
</td>
|
||||
<td className="kx-num">{r.hours ?? 0}h</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!listQ.isLoading && listQ.isError && (
|
||||
<ErrorState message={errMessage(listQ.error)} onRetry={() => listQ.refetch()} />
|
||||
)}
|
||||
{!listQ.isLoading && !listQ.isError && rows.length === 0 && (
|
||||
<EmptyState
|
||||
title="작성된 업무일지가 없습니다"
|
||||
description="우측에서 새 일지를 작성하세요."
|
||||
/>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="kx-pager">
|
||||
<button disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>
|
||||
이전
|
||||
</button>
|
||||
<span className="tnum">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
disabled={page + 1 >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 상세·작성 */}
|
||||
<aside className="kx-detail" aria-label="업무일지 작성">
|
||||
<div className="kx-detail__head">
|
||||
<h2 className="kx-detail__title">{editingTitle}</h2>
|
||||
{selectedId && (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
if (window.confirm('이 일지를 삭제할까요?')) delM.mutate(selectedId);
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="kx-formgrid">
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">작업일 *</span>
|
||||
<input
|
||||
type="date"
|
||||
className="kx-input"
|
||||
value={form.workDate}
|
||||
onChange={(e) => setForm({ ...form, workDate: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">공수(시간)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.5}
|
||||
className="kx-input"
|
||||
value={form.hours ?? 0}
|
||||
onChange={(e) => setForm({ ...form, hours: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="kx-field kx-field--full">
|
||||
<span className="kx-label">제목 *</span>
|
||||
<input
|
||||
className="kx-input"
|
||||
value={form.title}
|
||||
maxLength={200}
|
||||
placeholder="업무 제목"
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">유형</span>
|
||||
<select
|
||||
className="kx-select"
|
||||
value={form.workType ?? ''}
|
||||
onChange={(e) => setForm({ ...form, workType: e.target.value })}
|
||||
>
|
||||
{TYPE_OPTS.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="kx-field">
|
||||
<span className="kx-label">진행상태</span>
|
||||
<select
|
||||
className="kx-select"
|
||||
value={form.status ?? ''}
|
||||
onChange={(e) => setForm({ ...form, status: e.target.value })}
|
||||
>
|
||||
{STATUS_OPTS.filter((o) => o.v).map((o) => (
|
||||
<option key={o.v} value={o.v}>
|
||||
{o.l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="kx-field kx-field--full">
|
||||
<span className="kx-label">이슈·내용</span>
|
||||
<textarea
|
||||
className="kx-textarea"
|
||||
value={form.content ?? ''}
|
||||
placeholder="진행 내용, 이슈, 특이사항을 기록하세요."
|
||||
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="kx-detail__actions">
|
||||
<Button disabled={!canSave || saveM.isPending} onClick={() => saveM.mutate(form)}>
|
||||
{saveM.isPending ? '저장 중…' : selectedId ? '수정 저장' : '등록'}
|
||||
</Button>
|
||||
{selectedId && (
|
||||
<Button variant="ghost" onClick={resetForm}>
|
||||
취소
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{toast}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
855
src/frontend/src/screens/work/work.css
Normal file
855
src/frontend/src/screens/work/work.css
Normal file
@ -0,0 +1,855 @@
|
||||
/*
|
||||
* §5B 공통 업무 기능(SCR-39~48) 공유 스타일.
|
||||
* design.md §1 토큰만 참조. shared.css 프리미티브(kx-page·kx-card·kx-seg·kx-select·kx-kpi) 위에 얹는다.
|
||||
*/
|
||||
@import '../shared.css';
|
||||
|
||||
/* ── 페이지 헤더 ── */
|
||||
.kx-work__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.kx-work__title {
|
||||
font-size: var(--fs-h1);
|
||||
line-height: var(--lh-h1);
|
||||
color: var(--color-neutral-900);
|
||||
}
|
||||
.kx-work__subtitle {
|
||||
margin-top: 2px;
|
||||
font-size: var(--fs-body);
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
.kx-work__head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── 목록/상세 2-Pane 분할 (worklog·opinion) ── */
|
||||
.kx-split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.7fr) minmax(300px, 1fr);
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.kx-split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 필터 바 ── */
|
||||
.kx-filterbar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.kx-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.kx-field--grow {
|
||||
flex: 1;
|
||||
}
|
||||
.kx-label {
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
.kx-input,
|
||||
.kx-textarea {
|
||||
height: 36px;
|
||||
padding: 0 var(--space-3);
|
||||
border: var(--border-card);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-white);
|
||||
font-size: var(--fs-body);
|
||||
color: var(--color-neutral-900);
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
.kx-textarea {
|
||||
height: auto;
|
||||
min-height: 96px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
line-height: var(--lh-body);
|
||||
resize: vertical;
|
||||
}
|
||||
.kx-input:focus,
|
||||
.kx-textarea:focus,
|
||||
.kx-select:focus {
|
||||
outline: 2px solid var(--color-primary-100);
|
||||
border-color: var(--color-primary-600);
|
||||
}
|
||||
|
||||
/* ── 데이터 테이블 ── */
|
||||
.kx-list-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--fs-body);
|
||||
}
|
||||
.kx-list-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--color-neutral-050);
|
||||
text-align: left;
|
||||
padding: 0 var(--space-3);
|
||||
height: 40px;
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-500);
|
||||
border-bottom: var(--border-card);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.kx-list-table tbody td {
|
||||
padding: 0 var(--space-3);
|
||||
height: 44px;
|
||||
border-bottom: 1px solid var(--color-neutral-100);
|
||||
color: var(--color-neutral-700);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.kx-list-table tbody tr:nth-child(even) {
|
||||
background: var(--zebra);
|
||||
}
|
||||
.kx-list-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
.kx-list-table tbody tr:hover {
|
||||
background: var(--color-primary-050);
|
||||
}
|
||||
.kx-list-table tbody tr.is-selected {
|
||||
background: var(--color-primary-100);
|
||||
}
|
||||
.kx-list-table__title {
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-900);
|
||||
}
|
||||
.kx-list-table__muted {
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
.kx-num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── 상태 필/배지(경량) ── */
|
||||
.kx-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 1px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.kx-pill--neutral {
|
||||
background: var(--color-neutral-100);
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
.kx-pill--info {
|
||||
background: var(--color-primary-050);
|
||||
color: var(--color-primary-700);
|
||||
}
|
||||
.kx-pill--warn {
|
||||
background: var(--color-violation-warn-bg);
|
||||
color: var(--color-violation-warn-text);
|
||||
}
|
||||
.kx-pill--success {
|
||||
background: #e6f4ee;
|
||||
color: var(--color-success);
|
||||
}
|
||||
.kx-pill--danger {
|
||||
background: #fef3f2;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* ── 상세/작성 패널 ── */
|
||||
.kx-detail {
|
||||
border: var(--border-card);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-white);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.kx-detail__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.kx-detail__title {
|
||||
font-size: var(--fs-h3);
|
||||
color: var(--color-neutral-900);
|
||||
}
|
||||
.kx-detail__meta {
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--color-neutral-500);
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.kx-detail__body {
|
||||
font-size: var(--fs-body);
|
||||
line-height: var(--lh-body);
|
||||
color: var(--color-neutral-700);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.kx-detail__actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── 폼 그리드 ── */
|
||||
.kx-formgrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.kx-formgrid .kx-field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* ── 댓글/스레드 ── */
|
||||
.kx-thread {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
border-top: var(--border-card);
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
.kx-thread__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.kx-thread__meta {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
.kx-thread__author {
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
.kx-thread__text {
|
||||
font-size: var(--fs-body);
|
||||
color: var(--color-neutral-700);
|
||||
line-height: var(--lh-body);
|
||||
}
|
||||
.kx-thread__compose {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: flex-end;
|
||||
}
|
||||
.kx-thread__compose .kx-textarea {
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
/* ── SCR-41 쪽지 3-Pane ── */
|
||||
.kx-msg {
|
||||
display: grid;
|
||||
grid-template-columns: 200px minmax(0, 1.4fr) minmax(320px, 1.2fr);
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.kx-msg {
|
||||
grid-template-columns: 180px 1fr;
|
||||
}
|
||||
.kx-msg__read {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
.kx-msg__rail {
|
||||
border: var(--border-card);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-white);
|
||||
padding: var(--space-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.kx-msg__rail-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
padding: 8px var(--space-3);
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-700);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.kx-msg__rail-btn.is-active {
|
||||
background: var(--color-primary-050);
|
||||
color: var(--color-primary-700);
|
||||
}
|
||||
.kx-msg__rail-count {
|
||||
font-size: var(--fs-caption);
|
||||
background: var(--color-primary-600);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 0 7px;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.kx-msg__list {
|
||||
border: var(--border-card);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-white);
|
||||
overflow: hidden;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.kx-msg__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--color-neutral-100);
|
||||
cursor: pointer;
|
||||
}
|
||||
.kx-msg__item:hover {
|
||||
background: var(--color-primary-050);
|
||||
}
|
||||
.kx-msg__item.is-selected {
|
||||
background: var(--color-primary-100);
|
||||
}
|
||||
.kx-msg__item.is-unread .kx-msg__item-title {
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-900);
|
||||
}
|
||||
.kx-msg__item-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.kx-msg__item-sender {
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
.kx-msg__item-title {
|
||||
font-size: var(--fs-body);
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
.kx-msg__item-time {
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--color-neutral-500);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.kx-msg__read {
|
||||
border: var(--border-card);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-white);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.kx-msg__unread-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary-600);
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── 태그 입력(수신자 등) ── */
|
||||
.kx-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
border: var(--border-card);
|
||||
border-radius: var(--radius-sm);
|
||||
min-height: 36px;
|
||||
}
|
||||
.kx-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--color-primary-050);
|
||||
color: var(--color-primary-700);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 2px 6px 2px 10px;
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
.kx-tag button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-primary-700);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
padding: 0;
|
||||
}
|
||||
.kx-tags input {
|
||||
border: none;
|
||||
outline: none;
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
font-size: var(--fs-body);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ── SCR-40 캘린더 ── */
|
||||
.kx-cal {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.kx-cal {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.kx-cal__grid {
|
||||
border: var(--border-card);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-white);
|
||||
overflow: hidden;
|
||||
}
|
||||
.kx-cal__weekhead,
|
||||
.kx-cal__week {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
.kx-cal__weekhead {
|
||||
border-bottom: var(--border-card);
|
||||
}
|
||||
.kx-cal__wd {
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
.kx-cal__wd--sun {
|
||||
color: var(--color-error);
|
||||
}
|
||||
.kx-cal__cell {
|
||||
min-height: 96px;
|
||||
border-right: 1px solid var(--color-neutral-100);
|
||||
border-bottom: 1px solid var(--color-neutral-100);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
cursor: pointer;
|
||||
background: var(--color-white);
|
||||
}
|
||||
.kx-cal__cell:hover {
|
||||
background: var(--color-primary-050);
|
||||
}
|
||||
.kx-cal__cell.is-other {
|
||||
background: var(--color-neutral-050);
|
||||
}
|
||||
.kx-cal__cell.is-selected {
|
||||
outline: 2px solid var(--color-primary-600);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.kx-cal__daynum {
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
.kx-cal__cell.is-today .kx-cal__daynum {
|
||||
background: var(--color-primary-600);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.kx-cal__event {
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.kx-cal__more {
|
||||
font-size: 11px;
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
.kx-cal__legend {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
.kx-cal__legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.kx-cal__swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
display: inline-block;
|
||||
}
|
||||
.kx-cal__agenda {
|
||||
border: var(--border-card);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-white);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.kx-cal__agenda-item {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--color-primary-600);
|
||||
background: var(--color-neutral-050);
|
||||
}
|
||||
|
||||
/* ── SCR-44 검색 ── */
|
||||
.kx-search__hero {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
.kx-search__box {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
height: 52px;
|
||||
padding: 0 var(--space-4);
|
||||
border: 1.5px solid var(--color-neutral-200);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-white);
|
||||
}
|
||||
.kx-search__box:focus-within {
|
||||
border-color: var(--color-primary-600);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-050);
|
||||
}
|
||||
.kx-search__box input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: var(--fs-h3);
|
||||
background: transparent;
|
||||
}
|
||||
.kx-search__ai-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--color-ai-accent);
|
||||
background: var(--color-ai-surface);
|
||||
color: var(--color-ai-accent);
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.kx-search__ai-toggle.is-off {
|
||||
border-color: var(--color-neutral-200);
|
||||
background: var(--color-white);
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
.kx-search__tabs {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
border-bottom: var(--border-card);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.kx-search__tab {
|
||||
padding: 8px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-500);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.kx-search__tab.is-active {
|
||||
color: var(--color-primary-700);
|
||||
border-bottom-color: var(--color-primary-600);
|
||||
}
|
||||
.kx-search__result {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border: var(--border-card);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-white);
|
||||
cursor: pointer;
|
||||
}
|
||||
.kx-search__result:hover {
|
||||
border-color: var(--color-primary-600);
|
||||
background: var(--color-primary-050);
|
||||
}
|
||||
.kx-search__result-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-primary-050);
|
||||
color: var(--color-primary-600);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── SCR-45 회의록 ── */
|
||||
.kx-meet {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.kx-meet {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.kx-meet__list-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--color-neutral-100);
|
||||
cursor: pointer;
|
||||
}
|
||||
.kx-meet__list-item:hover {
|
||||
background: var(--color-primary-050);
|
||||
}
|
||||
.kx-meet__list-item.is-selected {
|
||||
background: var(--color-primary-100);
|
||||
}
|
||||
.kx-meet__ai-card {
|
||||
border: 1px solid var(--color-ai-accent);
|
||||
background: var(--color-ai-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.kx-meet__transcript {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
border: var(--border-card);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-neutral-050);
|
||||
font-size: var(--fs-body);
|
||||
line-height: var(--lh-body);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.kx-meet__player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border: var(--border-card);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-neutral-050);
|
||||
color: var(--color-neutral-500);
|
||||
font-size: var(--fs-body);
|
||||
}
|
||||
|
||||
/* ── SCR-47 알림센터 ── */
|
||||
.kx-noti__chips {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.kx-chip {
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: var(--border-card);
|
||||
background: var(--color-white);
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-700);
|
||||
cursor: pointer;
|
||||
}
|
||||
.kx-chip.is-active {
|
||||
background: var(--color-primary-600);
|
||||
border-color: var(--color-primary-600);
|
||||
color: #fff;
|
||||
}
|
||||
.kx-noti__item {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-neutral-100);
|
||||
align-items: flex-start;
|
||||
}
|
||||
.kx-noti__item.is-unread {
|
||||
background: var(--color-primary-050);
|
||||
}
|
||||
.kx-noti__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary-050);
|
||||
color: var(--color-primary-600);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.kx-noti__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.kx-noti__title {
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-900);
|
||||
font-size: var(--fs-body);
|
||||
}
|
||||
.kx-noti__desc {
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--color-neutral-500);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.kx-noti__time {
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--color-neutral-500);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.kx-noti__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary-600);
|
||||
margin-top: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── SCR-48 마이페이지 ── */
|
||||
.kx-my {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.kx-my {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.kx-my__rail {
|
||||
border: var(--border-card);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-white);
|
||||
padding: var(--space-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.kx-my__rail-btn {
|
||||
text-align: left;
|
||||
padding: 10px var(--space-3);
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-700);
|
||||
cursor: pointer;
|
||||
}
|
||||
.kx-my__rail-btn.is-active {
|
||||
background: var(--color-primary-050);
|
||||
color: var(--color-primary-700);
|
||||
}
|
||||
.kx-my__avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary-600);
|
||||
color: #fff;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.kx-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── 토스트 ── */
|
||||
.kx-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 60;
|
||||
background: var(--color-neutral-900);
|
||||
color: #fff;
|
||||
padding: 10px 18px;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--fs-body);
|
||||
box-shadow: 0 6px 20px rgba(16, 24, 40, 0.24);
|
||||
}
|
||||
|
||||
/* ── 페이지네이션 ── */
|
||||
.kx-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) 0 0;
|
||||
font-size: var(--fs-body);
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
.kx-pager button {
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
padding: 0 10px;
|
||||
border: var(--border-card);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-white);
|
||||
cursor: pointer;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
.kx-pager button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 스크롤 컨테이너 상한 */
|
||||
.kx-scroll-70 {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
85
src/frontend/src/screens/work/workShared.tsx
Normal file
85
src/frontend/src/screens/work/workShared.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
/*
|
||||
* §5B 업무 화면 공유 유틸 — 날짜 포맷·상태 필·토스트.
|
||||
* 백엔드 상태 문자열은 그대로 표기(권위)하되, 알려진 코드는 톤으로 매핑한다.
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ApiRequestError } from '../../api/client';
|
||||
|
||||
/** ISO/DB 문자열 → "MM.DD" (실패 시 원문 앞 10자). */
|
||||
export function fmtDate(s: string | null | undefined): string {
|
||||
if (!s) return '-';
|
||||
const d = new Date(s);
|
||||
if (Number.isNaN(d.getTime())) return s.slice(0, 10);
|
||||
return `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** ISO/DB 문자열 → "YYYY.MM.DD HH:mm". */
|
||||
export function fmtDateTime(s: string | null | undefined): string {
|
||||
if (!s) return '-';
|
||||
const d = new Date(s);
|
||||
if (Number.isNaN(d.getTime())) return s;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}.${p(d.getMonth() + 1)}.${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** 상대 시간(방금·N분·N시간·N일 전). */
|
||||
export function timeAgo(s: string | null | undefined): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (Number.isNaN(d.getTime())) return fmtDate(s);
|
||||
const diff = Date.now() - d.getTime();
|
||||
const min = Math.floor(diff / 60000);
|
||||
if (min < 1) return '방금';
|
||||
if (min < 60) return `${min}분 전`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}시간 전`;
|
||||
const day = Math.floor(hr / 24);
|
||||
if (day < 7) return `${day}일 전`;
|
||||
return fmtDate(s);
|
||||
}
|
||||
|
||||
/** 봉투 오류 → 한국어 메시지(코드 우선). */
|
||||
export function errMessage(e: unknown): string {
|
||||
if (e instanceof ApiRequestError) {
|
||||
if (e.code === 'FORBIDDEN') return '권한이 없습니다.';
|
||||
if (e.code === 'NOT_FOUND') return '대상을 찾을 수 없습니다.';
|
||||
if (e.code === 'NOT_IMPLEMENTED') return '준비 중인 기능입니다.';
|
||||
return e.message;
|
||||
}
|
||||
if (e instanceof Error) return e.message;
|
||||
return '요청을 처리하지 못했습니다.';
|
||||
}
|
||||
|
||||
type PillTone = 'neutral' | 'info' | 'warn' | 'success' | 'danger';
|
||||
|
||||
/** 상태 코드 → 필 톤·라벨(백엔드 원문 라벨 표기, 알려진 코드만 톤 부여). */
|
||||
export function statusPillTone(raw: string | null | undefined): PillTone {
|
||||
const v = (raw ?? '').toUpperCase();
|
||||
if (['DONE', 'ANSWERED', 'COMPLETED', 'COMPLETE', '완료', '답변완료', 'CLOSED'].includes(v)) return 'success';
|
||||
if (['REVIEWING', 'IN_PROGRESS', 'PROGRESS', '진행중', '검토중', 'OPEN'].includes(v)) return 'info';
|
||||
if (['RECEIVED', '접수', 'PENDING', 'WAIT'].includes(v)) return 'neutral';
|
||||
if (['REJECTED', '반려', 'BLOCKED', 'FAILED'].includes(v)) return 'danger';
|
||||
if (['HOLD', 'DELAYED', '보류'].includes(v)) return 'warn';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
/** 상태 필 — 라벨은 원문 유지. */
|
||||
export function StatusPill({ value }: { value: string | null | undefined }) {
|
||||
if (!value) return <span className="kx-pill kx-pill--neutral">-</span>;
|
||||
return <span className={`kx-pill kx-pill--${statusPillTone(value)}`}>{value}</span>;
|
||||
}
|
||||
|
||||
/** 간단 토스트 훅. */
|
||||
export function useToast() {
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const show = useCallback((msg: string) => {
|
||||
setToast(msg);
|
||||
window.setTimeout(() => setToast(null), 2600);
|
||||
}, []);
|
||||
const node = toast ? (
|
||||
<div className="kx-toast" role="status">
|
||||
{toast}
|
||||
</div>
|
||||
) : null;
|
||||
return { show, node };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user