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:
zio 2026-07-12 03:26:47 +09:00
parent c7b853be17
commit 16bfb238c1
77 changed files with 7718 additions and 576 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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&lt;오늘, ongoing=시작오늘종료, upcoming=시작&gt;오늘.
*/
@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 &lt; CURRENT_DATE</if>
<if test="status == 'ongoing'">AND e.start_date &lt;= CURRENT_DATE AND e.end_date &gt;= CURRENT_DATE</if>
<if test="status == 'upcoming'">AND e.start_date &gt; 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 &lt; CURRENT_DATE</if>
<if test="status == 'ongoing'">AND e.start_date &lt;= CURRENT_DATE AND e.end_date &gt;= CURRENT_DATE</if>
<if test="status == 'upcoming'">AND e.start_date &gt; CURRENT_DATE</if>
</where>
</script>
""")
long countEvents(@Param("year") String year, @Param("status") String status);
}

View File

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

View File

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

View File

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

View File

@ -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",
// 공개 콘텐츠(로그인 슬라이드 ) + 업로드 정적 파일

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
package com.zioinfo.kintex.security.dto;
import jakarta.validation.constraints.NotBlank;
/** OTP 등록 강제 흐름(로그인 중) — setup 단계 요청. 프리오스 challengeToken 만 필요. */
public record OtpEnrollRequest(@NotBlank String challengeToken) {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()); // 공시가 고지 항상 포함
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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) — 셸 없이 전폭, 조회·현장 */}

View File

@ -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) — 파일 업로드 경로 전용. */

View File

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

View File

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

View File

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

View File

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

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

View File

@ -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,49 +66,57 @@ export function AdminDashboardPage() {
</div>
</header>
{degraded && (
<div className="kx-admin__degraded-bar">
<span className="kx-bi__degraded"> API ()</span>
·· . (M18) .
<span className="kx-bi__degraded"> </span>
API에 .
</div>
)}
{q.isLoading && <AdminSkeleton />}
{hardError && (
<ErrorState message="관리자 대시보드를 불러오지 못했습니다." onRetry={() => q.refetch()} />
)}
{data && (
<>
<section className="kx-admin__kpis" aria-label="핵심 지표">
{ADMIN_KPIS.map((k) => (
<div key={k.key} className={`kx-kpi kx-kpi--${k.tone ?? 'normal'}`}>
{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.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}%` }} />
{k.sub && <span className="kx-kpi__sub">{k.sub}</span>}
</div>
))
)}
</div>
))}
</section>
<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>
<h2> </h2>
</div>
<div className="kx-admin__chart">
{data.visitorTrend.length === 0 ? (
<EmptyState
title="관람객 추이 미집계"
description="관람객 계측 센서(M14)가 연동되면 시간대별 추이가 표시됩니다."
/>
) : (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={VISITOR_TREND} margin={{ top: 8, right: 8, bottom: 0, left: -8 }}>
<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={range} name="방문객" fill={CHART.primary600} radius={[4, 4, 0, 0]} maxBarSize={34} />
<Bar dataKey="count" name="관람객" fill={CHART.primary600} radius={[4, 4, 0, 0]} maxBarSize={34} />
</BarChart>
</ResponsiveContainer>
)}
</div>
</section>
@ -110,29 +124,34 @@ export function AdminDashboardPage() {
<div className="kx-card__head">
<h2> </h2>
</div>
{data.liveEvents.length === 0 ? (
<EmptyState title="진행 중인 행사가 없습니다" />
) : (
<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" />
{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 ${e.live ? 'is-live' : 'is-prep'}`} aria-hidden="true" />
<span className="kx-livecard__state">{e.live ? 'LIVE NOW' : '준비중'}</span>
<span className="kx-livecard__dot is-live" aria-hidden="true" />
<StatusPill value={e.status} />
</div>
<h3 className="kx-livecard__title">{e.title}</h3>
<p className="kx-livecard__meta">{e.hallTime}</p>
{e.progress != null && (
<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: `${e.progress}%` }} />
<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>
{/* 플로팅 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,
};

View File

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

View File

@ -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,55 +95,69 @@ export function AnalyticsDashboardPage() {
</div>
</header>
<div className="kx-bi__degraded-bar">
<span className="kx-bi__degraded"> API ()</span>
BI · .
</div>
{!eventId && (
<EmptyState title="행사를 선택해 주세요" description="분석할 행사가 지정되지 않았습니다." />
)}
{degraded && (
<div className="kx-bi__degraded-bar">
<span className="kx-bi__degraded"> </span>
API에 .
</div>
)}
{q.isLoading && <BiSkeleton />}
{hardError && (
<ErrorState message="경영분석 집계를 불러오지 못했습니다." onRetry={() => q.refetch()} />
)}
{data && (
<>
{/* KPI 밴드 */}
<section className="kx-bi__kpis" aria-label="핵심 지표">
{kpis.map((k) => (
<KpiCard key={k.key} kpi={k} />
))}
{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>{isOperator ? '매출 구성 추이' : '참가업체 vs 바이어 매칭 추이'}</h2>
<AiLabel>AI </AiLabel>
<h2> · </h2>
<AiLabel> </AiLabel>
</div>
<p className="kx-card__hint"> AI (단위: 백만원 / ).</p>
<p className="kx-card__hint"> · ( 단위: ).</p>
<div className="kx-bi__chart">
{data.trend.length === 0 ? (
<EmptyState title="추이 데이터가 없습니다" description="집계 가능한 기간의 매출·부스 레코드가 없습니다." />
) : (
<ResponsiveContainer width="100%" height={320}>
{isOperator ? (
<AreaChart data={OPERATOR_TREND} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<AreaChart data={data.trend} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<defs>
<linearGradient id="gRental" x1="0" y1="0" x2="0" y2="1">
<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="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} />
<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>
) : (
<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>
)}
</ResponsiveContainer>
)}
</div>
</section>
@ -137,75 +165,76 @@ export function AnalyticsDashboardPage() {
<div className="kx-card__head">
<h2> </h2>
</div>
{data.sectors.length === 0 ? (
<EmptyState
title="업종 분류 미집계"
description="업종 분류 원천(M13)이 연동되면 표시됩니다."
/>
) : (
<ul className="kx-barlist">
{SECTORS.map((s) => (
<li key={s.label} className="kx-barlist__item">
{data.sectors.map((s) => (
<li key={s.name} className="kx-barlist__item">
<div className="kx-barlist__top">
<span>{s.label}</span>
<span className="tnum">{s.pct}%</span>
<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.pct}%` }} />
<span className="kx-barlist__fill" style={{ width: `${s.sharePercent}%` }} />
</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>
<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></th>
<th className="kx-num"></th>
<th className="kx-num"></th>
<th className="kx-num"></th>
<th></th>
<th></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>
{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></th>
<th className="kx-num"> </th>
<th className="kx-num"> </th>
<th> </th>
<th></th>
<th className="kx-num"></th>
<th className="kx-num"></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>
{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>
@ -213,6 +242,8 @@ export function AnalyticsDashboardPage() {
)}
</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);
}

View File

@ -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,44 +62,68 @@ export function OrganizerDashboardPage() {
<span className="kx-dash__countdown tnum"> D-{dday}</span>
</header>
{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">
{SAMPLE_MILESTONES.map((m, i) => (
<li key={m.key} className={`kx-timeline__node is-${m.state}`}>
{i < SAMPLE_MILESTONES.length - 1 && (
{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">{m.dLabel}</span>
<span className="kx-timeline__title">{m.title}</span>
<span className="kx-timeline__d tnum">{ddayLabel(m.date)}</span>
<span className="kx-timeline__title">{m.label}</span>
</div>
<div className="kx-timeline__card">
{m.progress ? (
<span className={m.state === 'imminent' ? 'kx-timeline__imminent' : ''}>
{m.progress}
</span>
) : (
<span className="kx-timeline__muted">{stateLabel(m)}</span>
)}
<span className="kx-timeline__muted">{stateLabel(m.state)}</span>
</div>
</li>
))}
</ol>
)}
</section>
{/* KPI 카드 4개 */}
{/* KPI 카드 */}
<section className="kx-dash__kpis" aria-label="핵심 지표">
{SAMPLE_KPIS.map((k) => (
<div key={k.key} className={`kx-kpi kx-kpi--${k.tone ?? 'normal'}`}>
{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.sub && <span className="kx-kpi__sub">{k.sub}</span>}
{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) */}
@ -92,55 +131,53 @@ export function OrganizerDashboardPage() {
<section className="kx-dash__table-wrap" aria-label="참가업체 현황">
<div className="kx-dash__section-head">
<h2> </h2>
<span className="kx-dash__degraded"> API ()</span>
{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>
<th></th>
<th> </th>
</tr>
</thead>
<tbody>
{SAMPLE_EXHIBITORS.map((r, i) => (
<tr key={r.boothNo}>
{data.exhibitors.map((r, i) => (
<tr key={`${r.boothNo}-${i}`}>
<td className="tnum">{r.boothNo}</td>
<td>{r.company}</td>
<td>{r.companyName}</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"
/>
<StatusPill value={r.status} />
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<aside className="kx-dash__aside">
<section className="kx-feed" aria-label="알림 피드">
<h2 className="kx-feed__title"></h2>
{data.feed.length === 0 ? (
<EmptyState title="새 알림이 없습니다" />
) : (
<ul className="kx-feed__list">
{SAMPLE_FEED.map((f) => (
<li key={f.key} className={`kx-feed__item is-${f.kind}`}>
{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.text}</span>
<span className="kx-feed__time">{f.time}</span>
<span className="kx-feed__text">{f.message}</span>
<span className="kx-feed__time">{timeAgo(f.at)}</span>
</li>
))}
</ul>
)}
</section>
<section className="kx-dash__s7" aria-label="홀 전경 조감">
@ -160,19 +197,90 @@ export function OrganizerDashboardPage() {
</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: '' })),
};

View File

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

View File

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

View 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 '인증 중 오류가 발생했습니다.';
}

View 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 처리 중 오류가 발생했습니다.';
}

View File

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

View File

@ -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,55 +49,58 @@ 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>
{!eventId && (
<EmptyState title="행사를 선택해 주세요" description="현장 운영을 조회할 행사가 지정되지 않았습니다." />
)}
{degraded && (
<div className="kx-ops__degraded-bar">
<span className="kx-ops__degraded-badge"> </span>
API에 .
</div>
)}
{q.isLoading && <OpsSkeleton />}
{hardError && (
<ErrorState message="현장 운영 현황을 불러오지 못했습니다." onRetry={() => q.refetch()} />
)}
{data && (
<div className="kx-ops__grid">
{/* 히어로: 혼잡도 히트맵 (span 8) */}
<section className="kx-card kx-ops__heat" aria-label="실시간 혼잡도 히트맵">
{/* 히어로: 혼잡도 */}
<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>
<h2> </h2>
</div>
<span className="kx-ops__sensor-badge"> · (M10) /</span>
<span className="kx-ops__sensor-badge">
{hasPeople ? '체크인(M10) 파생' : '센서 미연동 · 배정 홀 목록'}
</span>
{data.halls.length === 0 ? (
<EmptyState title="배정된 홀이 없습니다" description="행사에 홀이 배정되면 표시됩니다." />
) : (
<>
<div className="kx-ops__halls">
{HALL_CELLS.map((c) => (
<div key={c.id} className={`kx-ops__hall is-${c.level}`}>
{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.level === 'busy' && <span className="kx-ops__hall-tag"></span>}
<span className="kx-ops__hall-people tnum">{c.estPeople.toLocaleString()}</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 className="kx-ops__heat-foot">
<ul className="kx-ops__legend">
<li><span className="kx-dot is-smooth" /> </li>
@ -91,103 +108,131 @@ export function HallOperationsPage() {
<li><span className="kx-dot is-busy" /> </li>
</ul>
<span className="kx-ops__total">
<strong className="tnum">{TOTAL_EST_PEOPLE.toLocaleString()}</strong>
{hasPeople ? (
<> <strong className="tnum">{totalPeople.toLocaleString()}</strong></>
) : (
<> </>
)}
</span>
</div>
</>
)}
</section>
{/* 우측: HVAC + 조명존 (span 4) */}
{/* 우측: HVAC + 조명존 */}
<aside className="kx-ops__aside">
<section className="kx-card" aria-label="HVAC 최적화">
<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>
</div>
<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.avgTempC}°C</strong>
<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">{powerPct}%</strong>
</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>
<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>
{data.lighting.length === 0 ? (
<EmptyState title="조명 IoT 미연동" description="조명 제어 존이 연동되면 점등률이 표시됩니다." />
) : (
<ul className="kx-barlist">
{LIGHTING_ZONES.map((z) => (
<li key={z.label} className="kx-barlist__item">
{data.lighting.map((z) => (
<li key={z.zone} className="kx-barlist__item">
<div className="kx-barlist__top">
<span>{z.label}</span>
<span className="tnum">{z.pct}%</span>
<span>{z.zone}</span>
<span className="tnum">{z.onPercent}%</span>
</div>
<div className="kx-barlist__track">
<span className="kx-barlist__fill" style={{ width: `${z.pct}%` }} />
<span className="kx-barlist__fill" style={{ width: `${z.onPercent}%` }} />
</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>
{data.parking.length === 0 ? (
<EmptyState title="주차 연계 미연동" description="iparking 연계가 활성화되면 존별 점유가 표시됩니다." />
) : (
<div className="kx-ops__zones">
{PARKING_ZONES.map((z) => {
const pct = Math.round((z.occupied / z.total) * 100);
{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.id} className="kx-ops__zone">
<div key={z.zone} className="kx-ops__zone">
<div className="kx-ops__zone-top">
<span className="kx-ops__zone-label">{z.label}</span>
<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.occupied.toLocaleString()} / {z.total.toLocaleString()}
{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 })),
};

View File

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

View File

@ -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.1108.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 ? (

View File

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

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

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

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

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

View 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 }}>
&gt; .
</p>
</div>
);
}

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

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

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

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

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

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

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