feat(web): exhibitor mgmt, Nifty detail views, app-download QR, settings customizer, home todos + i18n
- exhibitors: organizer exhibitor roster page + read-only backend (Controller/Service/@Mapper/dto), /exhibitors route - detail views: Nifty blog-style .kx-detailview for auction & review-details (+shared.css) - app download: AppQrCodePage visual/class hardening - settings customizer: customizerStore + SettingsCustomizer panel (density/accent), AppShell gear wiring, menu-fallback removal - home: todos aggregation (8 sources) HomePage/homeApi/HomeMapper/HomeService - i18n: merge 112 new keys across ko/en/zh/ja (parity 0 missing) No new migrations (read-only queries); Flyway stays at V49. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
55a3114141
commit
a5a78f1ea1
@ -0,0 +1,38 @@
|
|||||||
|
package com.zioinfo.kintex.exhibitor;
|
||||||
|
|
||||||
|
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.exhibitor.dto.ExhibitorListDto;
|
||||||
|
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 (#41 · SCR /exhibitors).
|
||||||
|
* 참가사 로스터(회사·부스·설계/정산 상태·연락)를 행사 스코프로 반환한다.
|
||||||
|
* RBAC: 주최자·홀매니저만(참가업체 상호 연락처 비노출 — 타 참가사 프라이버시 보호).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/events/{eventId}/exhibitors")
|
||||||
|
public class ExhibitorController {
|
||||||
|
|
||||||
|
private final ExhibitorService service;
|
||||||
|
private final EventAccessGuard guard;
|
||||||
|
|
||||||
|
public ExhibitorController(ExhibitorService service, EventAccessGuard guard) {
|
||||||
|
this.service = service;
|
||||||
|
this.guard = guard;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET — 행사별 참가업체 로스터 + 요약. */
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<ExhibitorListDto> list(@AuthenticationPrincipal KintexPrincipal principal,
|
||||||
|
@PathVariable String eventId) {
|
||||||
|
guard.requireRole(principal, eventId, EventRole.ORGANIZER, EventRole.HALL_MANAGER);
|
||||||
|
return ApiResponse.ok(service.list(eventId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
package com.zioinfo.kintex.exhibitor;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주최자용 참가업체 관리 조회 매퍼 — read-only.
|
||||||
|
* booth 는 {@code layout.event_id} 경유로 행사에 귀속된다. 마이그레이션 불변.
|
||||||
|
* 부스 배정(booth_sale)·최신 설계안(design_plan)·정산(invoice)·연락(company/event_member) 좌조인.
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface ExhibitorMapper {
|
||||||
|
|
||||||
|
/** 행사명(존재 확인용). 없으면 null. */
|
||||||
|
@Select("SELECT name FROM event WHERE id = #{eventId}")
|
||||||
|
String findEventName(@Param("eventId") String eventId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 참가업체(= 배정 부스) 목록. company 가 배정된 부스만(빈 부스 제외).
|
||||||
|
* company/event_member 는 LATERAL LIMIT 1 로 부스당 1행을 보장(행 증식 방지).
|
||||||
|
* 최대 500건.
|
||||||
|
*/
|
||||||
|
@Select("""
|
||||||
|
SELECT b.id AS "boothId",
|
||||||
|
b.assigned_company_name AS "companyName",
|
||||||
|
b.booth_no AS "boothNo",
|
||||||
|
b.booth_type AS "boothType",
|
||||||
|
ROUND(ST_Area(b.geom)::numeric, 1) AS "areaM2",
|
||||||
|
bs.status AS "saleStatus",
|
||||||
|
COALESCE(dp.status, 'draft') AS "designStatus",
|
||||||
|
inv.status AS "invoiceStatus",
|
||||||
|
COALESCE(inv.total - inv.paid_amount, 0) AS "outstanding",
|
||||||
|
mem.display_name AS "contactName",
|
||||||
|
co.phone AS "contactPhone"
|
||||||
|
FROM booth b
|
||||||
|
JOIN layout l ON l.id = b.layout_id
|
||||||
|
LEFT JOIN booth_sale bs
|
||||||
|
ON bs.booth_id = b.id AND bs.event_id = l.event_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT d.status FROM design_plan d
|
||||||
|
WHERE d.booth_id = b.id ORDER BY d.version DESC LIMIT 1
|
||||||
|
) dp ON true
|
||||||
|
LEFT JOIN invoice inv
|
||||||
|
ON inv.event_id = l.event_id AND inv.exhibitor_name = b.assigned_company_name
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT au.display_name
|
||||||
|
FROM event_member em
|
||||||
|
JOIN app_user au ON au.id = em.user_id
|
||||||
|
WHERE em.event_id = l.event_id AND em.booth_id = b.id AND em.role_code = 'EXHIBITOR'
|
||||||
|
LIMIT 1
|
||||||
|
) mem ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT c.phone FROM company c
|
||||||
|
WHERE c.name = b.assigned_company_name LIMIT 1
|
||||||
|
) co ON true
|
||||||
|
WHERE l.event_id = #{eventId}
|
||||||
|
AND b.assigned_company_name IS NOT NULL
|
||||||
|
ORDER BY b.booth_no NULLS LAST
|
||||||
|
LIMIT 500
|
||||||
|
""")
|
||||||
|
List<Map<String, Object>> findExhibitors(@Param("eventId") String eventId);
|
||||||
|
}
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
package com.zioinfo.kintex.exhibitor;
|
||||||
|
|
||||||
|
import com.zioinfo.kintex.common.error.ApiException;
|
||||||
|
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||||
|
import com.zioinfo.kintex.exhibitor.dto.ExhibitorListDto;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주최자용 참가업체 관리 서비스 — 실 테이블 집계로 목록·요약을 조립.
|
||||||
|
* 부스에 배정된 참가업체만 반환한다(빈 부스 제외). 데이터가 없으면 빈 목록(정직).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ExhibitorService {
|
||||||
|
|
||||||
|
private final ExhibitorMapper mapper;
|
||||||
|
|
||||||
|
public ExhibitorService(ExhibitorMapper mapper) {
|
||||||
|
this.mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExhibitorListDto list(String eventId) {
|
||||||
|
String eventName = mapper.findEventName(eventId);
|
||||||
|
if (eventName == null) {
|
||||||
|
throw new ApiException(ErrorCode.NOT_FOUND, "행사를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
List<ExhibitorListDto.Row> rows = buildRows(mapper.findExhibitors(eventId));
|
||||||
|
return new ExhibitorListDto(eventId, eventName, buildSummary(rows), rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ExhibitorListDto.Row> buildRows(List<Map<String, Object>> src) {
|
||||||
|
List<ExhibitorListDto.Row> out = new ArrayList<>();
|
||||||
|
if (src == null) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (Map<String, Object> r : src) {
|
||||||
|
out.add(new ExhibitorListDto.Row(
|
||||||
|
str(r.get("boothId")),
|
||||||
|
str(r.get("companyName")),
|
||||||
|
str(r.get("boothNo")),
|
||||||
|
str(r.get("boothType")),
|
||||||
|
dbl(r.get("areaM2")),
|
||||||
|
str(r.get("saleStatus")),
|
||||||
|
str(r.get("designStatus")),
|
||||||
|
str(r.get("invoiceStatus")),
|
||||||
|
lng(r.get("outstanding")),
|
||||||
|
str(r.get("contactName")),
|
||||||
|
str(r.get("contactPhone"))));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExhibitorListDto.Summary buildSummary(List<ExhibitorListDto.Row> rows) {
|
||||||
|
int contracted = 0;
|
||||||
|
int designApproved = 0;
|
||||||
|
int settlementPaid = 0;
|
||||||
|
int outstandingCount = 0;
|
||||||
|
for (ExhibitorListDto.Row r : rows) {
|
||||||
|
if ("sold".equals(r.saleStatus())) {
|
||||||
|
contracted++;
|
||||||
|
}
|
||||||
|
if ("approved".equals(r.designStatus())) {
|
||||||
|
designApproved++;
|
||||||
|
}
|
||||||
|
if ("paid".equals(r.invoiceStatus())) {
|
||||||
|
settlementPaid++;
|
||||||
|
}
|
||||||
|
if (r.outstanding() > 0) {
|
||||||
|
outstandingCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ExhibitorListDto.Summary(rows.size(), contracted, designApproved, settlementPaid, outstandingCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Double dbl(Object o) {
|
||||||
|
if (o == null) return null;
|
||||||
|
if (o instanceof Number n) return n.doubleValue();
|
||||||
|
try {
|
||||||
|
return Double.parseDouble(String.valueOf(o));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long lng(Object o) {
|
||||||
|
if (o == null) return 0L;
|
||||||
|
if (o instanceof Number n) return n.longValue();
|
||||||
|
try {
|
||||||
|
return Long.parseLong(String.valueOf(o));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String str(Object o) {
|
||||||
|
return o == null ? null : String.valueOf(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
package com.zioinfo.kintex.exhibitor.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주최자용 참가업체 관리 목록 (#41 · SCR /exhibitors).
|
||||||
|
* 집계원: booth(layout.event_id) + booth_sale + design_plan(최신) + invoice + company/event_member(연락).
|
||||||
|
* 데이터 없으면 0/빈배열/ null(정직 반환). 민감정보(비밀번호·otp)는 포함하지 않는다.
|
||||||
|
*/
|
||||||
|
public record ExhibitorListDto(
|
||||||
|
String eventId,
|
||||||
|
String eventName,
|
||||||
|
Summary summary,
|
||||||
|
List<Row> exhibitors
|
||||||
|
) {
|
||||||
|
/** 상단 요약 밴드 — 참가사 규모/계약/설계승인/정산 현황. */
|
||||||
|
public record Summary(
|
||||||
|
int total,
|
||||||
|
int contracted,
|
||||||
|
int designApproved,
|
||||||
|
int settlementPaid,
|
||||||
|
int outstandingCount
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 참가업체 1행(= 배정 부스 1건). company·boothNo 는 표시용(민감정보 아님). */
|
||||||
|
public record Row(
|
||||||
|
String boothId,
|
||||||
|
String companyName,
|
||||||
|
String boothNo,
|
||||||
|
String boothType,
|
||||||
|
Double areaM2,
|
||||||
|
String saleStatus, // sold | held | available | null
|
||||||
|
String designStatus, // approved | review | draft | rejected
|
||||||
|
String invoiceStatus, // pending | invoiced | paid | overdue | cancelled | null
|
||||||
|
long outstanding, // 미수금(원) = total - paid_amount
|
||||||
|
String contactName, // 참가업체 담당자 표시명(선택)
|
||||||
|
String contactPhone // 연락처(선택)
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -138,6 +138,91 @@ public interface HomeMapper {
|
|||||||
@Param("tenantId") String tenantId,
|
@Param("tenantId") String tenantId,
|
||||||
@Param("broad") boolean broad);
|
@Param("broad") boolean broad);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마일스톤 준비(M6): 미완료 표준 일정 노드 중 마감 {@code horizon}(D-7) 이내. 내가 참여한 행사(또는 broad).
|
||||||
|
* target 은 "행사명 · 노드라벨"(민감정보 아님). route 는 서류·마일스톤 화면(SCR-22/23).
|
||||||
|
*/
|
||||||
|
@Select("""
|
||||||
|
SELECT 'milestone' AS "type",
|
||||||
|
'마일스톤 준비' AS "title",
|
||||||
|
(e.name || ' · ' || em.label) AS "target",
|
||||||
|
to_char(em.due_date, 'YYYY-MM-DD') AS "dueDate",
|
||||||
|
(em.due_date - CURRENT_DATE) AS "dday",
|
||||||
|
'/docs' AS "route"
|
||||||
|
FROM event_milestone em
|
||||||
|
JOIN event e ON e.id = em.event_id
|
||||||
|
WHERE e.tenant_id = #{tenantId}
|
||||||
|
AND em.state <> 'done'
|
||||||
|
AND em.due_date IS NOT NULL
|
||||||
|
AND em.due_date <= CAST(#{horizon} AS date)
|
||||||
|
AND (#{broad} OR EXISTS (
|
||||||
|
SELECT 1 FROM event_member m
|
||||||
|
WHERE m.event_id = e.id AND m.user_id = #{userId}))
|
||||||
|
ORDER BY em.due_date ASC
|
||||||
|
LIMIT 10
|
||||||
|
""")
|
||||||
|
List<Map<String, Object>> findMilestoneTodos(@Param("userId") String userId,
|
||||||
|
@Param("tenantId") String tenantId,
|
||||||
|
@Param("horizon") String horizon,
|
||||||
|
@Param("broad") boolean broad);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 체크인 준비(M10): 개장 D-3 이내(오늘~+3) 이면서 배지 미발급 관람객이 남은 행사. 내가 주최(또는 broad).
|
||||||
|
* target 은 "행사명 · 대기 N명". dday 는 개장까지 잔여(0~3). route 는 관람객 관리 화면.
|
||||||
|
*/
|
||||||
|
@Select("""
|
||||||
|
SELECT 'checkin' AS "type",
|
||||||
|
'체크인 준비' AS "title",
|
||||||
|
(e.name || ' · 대기 ' || w.cnt || '명') AS "target",
|
||||||
|
to_char(e.start_date, 'YYYY-MM-DD') AS "dueDate",
|
||||||
|
(e.start_date - CURRENT_DATE) AS "dday",
|
||||||
|
'/visitors' AS "route"
|
||||||
|
FROM event e
|
||||||
|
JOIN LATERAL (
|
||||||
|
SELECT count(*) AS cnt FROM visitor_registration v
|
||||||
|
WHERE v.event_id = e.id AND v.badge_issued = false
|
||||||
|
) w ON true
|
||||||
|
WHERE e.tenant_id = #{tenantId}
|
||||||
|
AND e.start_date >= CURRENT_DATE
|
||||||
|
AND e.start_date <= CURRENT_DATE + 3
|
||||||
|
AND w.cnt > 0
|
||||||
|
AND (#{broad} OR EXISTS (
|
||||||
|
SELECT 1 FROM event_member m
|
||||||
|
WHERE m.event_id = e.id AND m.user_id = #{userId} AND m.role_code = 'ORGANIZER'))
|
||||||
|
ORDER BY e.start_date ASC
|
||||||
|
LIMIT 10
|
||||||
|
""")
|
||||||
|
List<Map<String, Object>> findCheckinPrepTodos(@Param("userId") String userId,
|
||||||
|
@Param("tenantId") String tenantId,
|
||||||
|
@Param("broad") boolean broad);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 핫리드 팔로업(M10): 고득점(score≥80) 리드로 팔로업이 필요한 건. 내가 참가업체(EXHIBITOR)인 행사(또는 broad).
|
||||||
|
* 종료 후 7일 지난 행사는 제외(경과 리드 정리). 기한 없음(dday=null → 서비스가 warning 으로 부각).
|
||||||
|
* ★PII 금지: name/phone/email 은 SELECT 목록에서 완전 제외 — company/product/score(민감정보 아님)만 노출.
|
||||||
|
*/
|
||||||
|
@Select("""
|
||||||
|
SELECT 'lead' AS "type",
|
||||||
|
'핫리드 팔로업' AS "title",
|
||||||
|
(COALESCE(NULLIF(l.company, ''), '리드') || COALESCE(' · ' || l.product, '')) AS "target",
|
||||||
|
NULL AS "dueDate",
|
||||||
|
NULL AS "dday",
|
||||||
|
'/leads' AS "route"
|
||||||
|
FROM lead l
|
||||||
|
JOIN event e ON e.id = l.event_id
|
||||||
|
WHERE e.tenant_id = #{tenantId}
|
||||||
|
AND l.score >= 80
|
||||||
|
AND e.end_date >= CURRENT_DATE - 7
|
||||||
|
AND (#{broad} OR EXISTS (
|
||||||
|
SELECT 1 FROM event_member m
|
||||||
|
WHERE m.event_id = e.id AND m.user_id = #{userId} AND m.role_code = 'EXHIBITOR'))
|
||||||
|
ORDER BY l.score DESC
|
||||||
|
LIMIT 10
|
||||||
|
""")
|
||||||
|
List<Map<String, Object>> findLeadFollowupTodos(@Param("userId") String userId,
|
||||||
|
@Param("tenantId") String tenantId,
|
||||||
|
@Param("broad") boolean broad);
|
||||||
|
|
||||||
// ── 쇼케이스(진행 중·다가오는 전시) ────────────────────────────────────────────
|
// ── 쇼케이스(진행 중·다가오는 전시) ────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -43,29 +43,38 @@ public class HomeService {
|
|||||||
boolean broad = isBroadOversight(principal);
|
boolean broad = isBroadOversight(principal);
|
||||||
String horizon = LocalDate.now().plusDays(DOC_HORIZON_DAYS).toString();
|
String horizon = LocalDate.now().plusDays(DOC_HORIZON_DAYS).toString();
|
||||||
|
|
||||||
|
// 원천별 자기선별(각 서브쿼리가 event_member 멤버십·role_code 로 스스로 역할 스코프됨 → 역할별 필터는
|
||||||
|
// 여기서 하드 게이트하지 않고 원천 스코프에 위임한다: CONTRACTOR 는 정산/서류 미노출, EXHIBITOR 는 옥션낙찰
|
||||||
|
// 미노출 등. 다중 역할 사용자도 해당 행사 역할에 맞는 항목만 병합된다). broad(홀매니저·관리자)는 전 테넌트.
|
||||||
List<Map<String, Object>> rows = new ArrayList<>();
|
List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
rows.addAll(mapper.findApprovalTodos(userId));
|
rows.addAll(mapper.findApprovalTodos(userId));
|
||||||
rows.addAll(mapper.findDocumentTodos(userId, tenantId, horizon, broad));
|
rows.addAll(mapper.findDocumentTodos(userId, tenantId, horizon, broad));
|
||||||
|
rows.addAll(mapper.findMilestoneTodos(userId, tenantId, horizon, broad));
|
||||||
rows.addAll(mapper.findAuctionAwardTodos(userId, tenantId, broad));
|
rows.addAll(mapper.findAuctionAwardTodos(userId, tenantId, broad));
|
||||||
rows.addAll(mapper.findAuctionBidTodos(userId, tenantId));
|
rows.addAll(mapper.findAuctionBidTodos(userId, tenantId));
|
||||||
rows.addAll(mapper.findSettlementTodos(userId, tenantId, broad));
|
rows.addAll(mapper.findSettlementTodos(userId, tenantId, broad));
|
||||||
|
rows.addAll(mapper.findCheckinPrepTodos(userId, tenantId, broad));
|
||||||
|
rows.addAll(mapper.findLeadFollowupTodos(userId, tenantId, broad));
|
||||||
|
|
||||||
List<TodoItem> items = new ArrayList<>();
|
List<TodoItem> items = new ArrayList<>();
|
||||||
for (Map<String, Object> r : rows) {
|
for (Map<String, Object> r : rows) {
|
||||||
|
String type = str(r.get("type"));
|
||||||
Integer dday = toInt(r.get("dday"));
|
Integer dday = toInt(r.get("dday"));
|
||||||
items.add(new TodoItem(
|
items.add(new TodoItem(
|
||||||
str(r.get("type")),
|
type,
|
||||||
str(r.get("title")),
|
str(r.get("title")),
|
||||||
str(r.get("target")),
|
str(r.get("target")),
|
||||||
str(r.get("dueDate")),
|
str(r.get("dueDate")),
|
||||||
dday,
|
dday,
|
||||||
severity(dday),
|
severityFor(type, dday),
|
||||||
str(r.get("route"))));
|
str(r.get("route"))));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 임박순: dday 오름차순(경과·임박 우선), 기한 없는 결재는 후순위.
|
// 마감 임박 우선순위(2단): ① 심각도 랭크(경과 → 임박 → 일반) ② 잔여일 오름차순(기한 없는 건은 그룹 내 후순위).
|
||||||
items.sort(Comparator.comparing(
|
// 이로써 경과·D-3 이내 항목이 항상 상단으로 부각되고(프론트가 강조색 렌더), 핫리드·체크인 준비도 임박 그룹에서 노출된다.
|
||||||
(TodoItem t) -> t.dday() == null ? Integer.MAX_VALUE : t.dday()));
|
items.sort(Comparator
|
||||||
|
.comparingInt((TodoItem t) -> severityRank(t.severity()))
|
||||||
|
.thenComparingInt(t -> t.dday() == null ? Integer.MAX_VALUE : t.dday()));
|
||||||
|
|
||||||
int total = items.size();
|
int total = items.size();
|
||||||
List<TodoItem> top = items.size() > TODO_LIMIT ? items.subList(0, TODO_LIMIT) : items;
|
List<TodoItem> top = items.size() > TODO_LIMIT ? items.subList(0, TODO_LIMIT) : items;
|
||||||
@ -184,9 +193,13 @@ public class HomeService {
|
|||||||
return new SummaryTile(key, label, value, null, dday, sev, route);
|
return new SummaryTile(key, label, value, null, dday, sev, route);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String severity(Integer dday) {
|
/**
|
||||||
|
* 할 일 심각도. 기한이 있으면 잔여일 기준(경과 overdue · D-3 이내 warning · 그 외 normal).
|
||||||
|
* 기한 없는 핫리드 팔로업(lead)은 즉시 조치가 필요한 액션이므로 warning 으로 부각한다(그 외 무기한은 normal).
|
||||||
|
*/
|
||||||
|
private static String severityFor(String type, Integer dday) {
|
||||||
if (dday == null) {
|
if (dday == null) {
|
||||||
return "normal";
|
return "lead".equals(type) ? "warning" : "normal";
|
||||||
}
|
}
|
||||||
if (dday < 0) {
|
if (dday < 0) {
|
||||||
return "overdue";
|
return "overdue";
|
||||||
@ -194,6 +207,15 @@ public class HomeService {
|
|||||||
return dday <= 3 ? "warning" : "normal";
|
return dday <= 3 ? "warning" : "normal";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 정렬용 심각도 랭크(작을수록 상단): 경과 0 · 임박 1 · 일반 2. */
|
||||||
|
private static int severityRank(String severity) {
|
||||||
|
return switch (severity) {
|
||||||
|
case "overdue" -> 0;
|
||||||
|
case "warning" -> 1;
|
||||||
|
default -> 2;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static String normalizeTab(String tab) {
|
private static String normalizeTab(String tab) {
|
||||||
if (tab == null) {
|
if (tab == null) {
|
||||||
return "ongoing";
|
return "ongoing";
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { BoothLayoutEditorPage } from './screens/floorplan/BoothLayoutEditorPage
|
|||||||
import { LayoutComparisonPage } from './screens/floorplan/LayoutComparisonPage';
|
import { LayoutComparisonPage } from './screens/floorplan/LayoutComparisonPage';
|
||||||
import { OrganizerDashboardPage } from './screens/dashboard/OrganizerDashboardPage';
|
import { OrganizerDashboardPage } from './screens/dashboard/OrganizerDashboardPage';
|
||||||
import { ExhibitorHomePage } from './screens/exhibitor/ExhibitorHomePage';
|
import { ExhibitorHomePage } from './screens/exhibitor/ExhibitorHomePage';
|
||||||
|
import { ExhibitorsPage } from './screens/exhibitors/ExhibitorsPage';
|
||||||
import { BoothDesignStudioPage } from './screens/design/BoothDesignStudioPage';
|
import { BoothDesignStudioPage } from './screens/design/BoothDesignStudioPage';
|
||||||
import { UtilityWiringPage } from './screens/utility/UtilityWiringPage';
|
import { UtilityWiringPage } from './screens/utility/UtilityWiringPage';
|
||||||
import { UtilityOrderSummaryPage } from './screens/utility/UtilityOrderSummaryPage';
|
import { UtilityOrderSummaryPage } from './screens/utility/UtilityOrderSummaryPage';
|
||||||
@ -139,6 +140,8 @@ export function App() {
|
|||||||
<Route path="/events/:eventId/dashboard" element={<OrganizerDashboardPage />} />
|
<Route path="/events/:eventId/dashboard" element={<OrganizerDashboardPage />} />
|
||||||
{/* SCR-05 참가업체 홈 */}
|
{/* SCR-05 참가업체 홈 */}
|
||||||
<Route path="/events/:eventId/booths/:boothId/home" element={<ExhibitorHomePage />} />
|
<Route path="/events/:eventId/booths/:boothId/home" element={<ExhibitorHomePage />} />
|
||||||
|
{/* #41 주최자용 참가업체 관리(전용 화면 — 대시보드 폴백 해소) */}
|
||||||
|
<Route path="/exhibitors" element={<ExhibitorsPage />} />
|
||||||
{/* SCR-03 부스 배치 에디터 (M2) */}
|
{/* SCR-03 부스 배치 에디터 (M2) */}
|
||||||
<Route
|
<Route
|
||||||
path="/events/:eventId/halls/:hallId/layout"
|
path="/events/:eventId/halls/:hallId/layout"
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { useUiStore, isMobileViewport } from '../../store/uiStore';
|
|||||||
import { useIsAdmin } from '../../screens/admin/AdminGuard';
|
import { useIsAdmin } from '../../screens/admin/AdminGuard';
|
||||||
import { MdiTabBar } from './MdiTabBar';
|
import { MdiTabBar } from './MdiTabBar';
|
||||||
import { ShellFooter } from './ShellFooter';
|
import { ShellFooter } from './ShellFooter';
|
||||||
|
import { SettingsCustomizer } from './SettingsCustomizer';
|
||||||
import { labelForPath } from './mdiLabels';
|
import { labelForPath } from './mdiLabels';
|
||||||
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
|
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
|
||||||
import { DdayChip } from '../ui/Badge';
|
import { DdayChip } from '../ui/Badge';
|
||||||
@ -97,7 +98,7 @@ const GROUPS: NavGroupDef[] = [
|
|||||||
tracks: BIZ_OPS_ADMIN, // agency·visitor ✕
|
tracks: BIZ_OPS_ADMIN, // agency·visitor ✕
|
||||||
items: [
|
items: [
|
||||||
{ key: 'dashboard', label: '대시보드', labelKey: 'shell.menu.items.dashboard', Icon: IconDashboard, scoped: true },
|
{ key: 'dashboard', label: '대시보드', labelKey: 'shell.menu.items.dashboard', Icon: IconDashboard, scoped: true },
|
||||||
{ key: 'exhibitors', label: '참가업체 관리', labelKey: 'shell.menu.items.exhibitors', Icon: IconExhibitors, scoped: true },
|
{ key: 'exhibitors', label: '참가업체 관리', labelKey: 'shell.menu.items.exhibitors', Icon: IconExhibitors, to: '/exhibitors' },
|
||||||
{ key: 'schedule', label: '전시 일정', labelKey: 'shell.menu.items.schedule', Icon: IconCalendar, to: '/schedule' },
|
{ key: 'schedule', label: '전시 일정', labelKey: 'shell.menu.items.schedule', Icon: IconCalendar, to: '/schedule' },
|
||||||
{ key: 'halls', label: '홀 배정', labelKey: 'shell.menu.items.halls', Icon: IconGrid, to: '/halls' },
|
{ key: 'halls', label: '홀 배정', labelKey: 'shell.menu.items.halls', Icon: IconGrid, to: '/halls' },
|
||||||
{ key: 'booth-sales', label: '부스 판매', labelKey: 'shell.menu.items.boothSales', Icon: IconSettlement, to: '/booth-sales' },
|
{ key: 'booth-sales', label: '부스 판매', labelKey: 'shell.menu.items.boothSales', Icon: IconSettlement, to: '/booth-sales' },
|
||||||
@ -209,12 +210,11 @@ const GROUPS: NavGroupDef[] = [
|
|||||||
/**
|
/**
|
||||||
* 행사 스코프 항목 → 실제 경로 빌더(App.tsx 라우트와 정합).
|
* 행사 스코프 항목 → 실제 경로 빌더(App.tsx 라우트와 정합).
|
||||||
* 플로어플랜 홀은 워크스페이스 hallLabel 에서 파싱(예: "제2전시장 홀7" → H7), 실패 시 'H7' 폴백.
|
* 플로어플랜 홀은 워크스페이스 hallLabel 에서 파싱(예: "제2전시장 홀7" → H7), 실패 시 'H7' 폴백.
|
||||||
* 참가업체는 전용 라우트 부재 → 참가업체 로스터가 있는 주최자 대시보드로(신설 시 교체).
|
* (#41: 참가업체 관리는 전용 라우트 `/exhibitors` 신설 — 더 이상 스코프 폴백 대상 아님.)
|
||||||
*/
|
*/
|
||||||
const EVENT_SCOPED_PATH: Record<string, (eventId: string, hallId: string) => string> = {
|
const EVENT_SCOPED_PATH: Record<string, (eventId: string, hallId: string) => string> = {
|
||||||
dashboard: (id) => `/events/${id}/dashboard`,
|
dashboard: (id) => `/events/${id}/dashboard`,
|
||||||
floorplan: (id, hallId) => `/events/${id}/halls/${hallId}/layout`,
|
floorplan: (id, hallId) => `/events/${id}/halls/${hallId}/layout`,
|
||||||
exhibitors: (id) => `/events/${id}/dashboard`,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** hallLabel("제2전시장 홀7"·"홀 10" 등) → 홀 id("H7"). 파싱 불가 시 기본 H7. */
|
/** hallLabel("제2전시장 홀7"·"홀 10" 등) → 홀 id("H7"). 파싱 불가 시 기본 H7. */
|
||||||
@ -263,6 +263,9 @@ export function AppShell() {
|
|||||||
// 단일-열림 아코디언(소유자 지시 ②) — 한 번에 대분류 하나만 펼침. WISE LeftNav 동작 그대로.
|
// 단일-열림 아코디언(소유자 지시 ②) — 한 번에 대분류 하나만 펼침. WISE LeftNav 동작 그대로.
|
||||||
const [openGroup, setOpenGroup] = useState<string | null>(() => activeGroupId(location.pathname));
|
const [openGroup, setOpenGroup] = useState<string | null>(() => activeGroupId(location.pathname));
|
||||||
|
|
||||||
|
// 설정 아이콘 → 우측 레이아웃 커스터마이저(#43) offcanvas 열림.
|
||||||
|
const [customizerOpen, setCustomizerOpen] = useState(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 라우트 → MDI 탭 동기화(§2.7) + 활성 그룹 자동 펼침.
|
* 라우트 → MDI 탭 동기화(§2.7) + 활성 그룹 자동 펼침.
|
||||||
* 화면 이동 시 활성 대분류를 펼친다(라우트=단일 출처). 비메뉴 경로는 현 상태 유지.
|
* 화면 이동 시 활성 대분류를 펼친다(라우트=단일 출처). 비메뉴 경로는 현 상태 유지.
|
||||||
@ -487,9 +490,11 @@ export function AppShell() {
|
|||||||
<button
|
<button
|
||||||
className="kx-shell__icon-btn"
|
className="kx-shell__icon-btn"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="설정"
|
aria-label="화면 설정"
|
||||||
title="설정"
|
title="화면 설정"
|
||||||
onClick={() => navigate(isAdmin ? '/admin/settings' : '/me')}
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={customizerOpen}
|
||||||
|
onClick={() => setCustomizerOpen(true)}
|
||||||
>
|
>
|
||||||
<IconSettings size={20} />
|
<IconSettings size={20} />
|
||||||
</button>
|
</button>
|
||||||
@ -505,6 +510,9 @@ export function AppShell() {
|
|||||||
{/* 전 셸 화면 공통 하단 상태바 (design.md §2-2) */}
|
{/* 전 셸 화면 공통 하단 상태바 (design.md §2-2) */}
|
||||||
<ShellFooter />
|
<ShellFooter />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 설정 아이콘 → 우측 레이아웃 커스터마이저(테마·레이아웃·강조색, #43) */}
|
||||||
|
<SettingsCustomizer open={customizerOpen} onClose={() => setCustomizerOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
182
src/frontend/src/components/layout/SettingsCustomizer.tsx
Normal file
182
src/frontend/src/components/layout/SettingsCustomizer.tsx
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
/*
|
||||||
|
* 설정 레이아웃 커스터마이저(#43) — 상단바 설정 아이콘 → 우측 offcanvas.
|
||||||
|
* Nifty 설정 패널 톤: 테마(라이트/다크/시스템) · 레이아웃(사이드바·밀도) · 강조색 프리셋 + 기본값 복원.
|
||||||
|
*
|
||||||
|
* 상태 소스(단일 출처 재사용, 신규 store 최소):
|
||||||
|
* - 테마 → themeStore(mode/setMode)
|
||||||
|
* - 사이드바 → uiStore(navCollapsed/setNavCollapsed)
|
||||||
|
* - 밀도·강조색 → customizerStore(density·accent, localStorage 지속·documentElement 스탬프)
|
||||||
|
*
|
||||||
|
* 접근성: Offcanvas 가 role=dialog·aria-modal·Esc·포커스트랩 제공. 각 옵션 그룹은 radiogroup 시맨틱.
|
||||||
|
*/
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Offcanvas } from '../ui/Offcanvas';
|
||||||
|
import { IconCheck, IconMoon, IconSun, IconSettings } from '../ui/icons';
|
||||||
|
import { useThemeStore, type ThemeMode } from '../../store/themeStore';
|
||||||
|
import { useUiStore } from '../../store/uiStore';
|
||||||
|
import { ACCENT_PRESETS, useCustomizerStore, type AccentKey, type Density } from '../../store/customizerStore';
|
||||||
|
import './settings-customizer.css';
|
||||||
|
|
||||||
|
export interface SettingsCustomizerProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 강조색 프리셋 라벨 i18n 키(폴백=store label). */
|
||||||
|
const ACCENT_LABEL_KEY: Record<AccentKey, string> = {
|
||||||
|
default: 'customizer.accent.default',
|
||||||
|
indigo: 'customizer.accent.indigo',
|
||||||
|
teal: 'customizer.accent.teal',
|
||||||
|
violet: 'customizer.accent.violet',
|
||||||
|
rose: 'customizer.accent.rose',
|
||||||
|
amber: 'customizer.accent.amber',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SettingsCustomizer({ open, onClose }: SettingsCustomizerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const mode = useThemeStore((s) => s.mode);
|
||||||
|
const setMode = useThemeStore((s) => s.setMode);
|
||||||
|
const navCollapsed = useUiStore((s) => s.navCollapsed);
|
||||||
|
const setNavCollapsed = useUiStore((s) => s.setNavCollapsed);
|
||||||
|
const density = useCustomizerStore((s) => s.density);
|
||||||
|
const setDensity = useCustomizerStore((s) => s.setDensity);
|
||||||
|
const accent = useCustomizerStore((s) => s.accent);
|
||||||
|
const setAccent = useCustomizerStore((s) => s.setAccent);
|
||||||
|
const reset = useCustomizerStore((s) => s.reset);
|
||||||
|
|
||||||
|
const themeOptions: { value: ThemeMode; label: string; Icon?: typeof IconSun }[] = [
|
||||||
|
{ value: 'light', label: t('customizer.theme.light', { defaultValue: '라이트' }), Icon: IconSun },
|
||||||
|
{ value: 'dark', label: t('customizer.theme.dark', { defaultValue: '다크' }), Icon: IconMoon },
|
||||||
|
{ value: 'system', label: t('customizer.theme.system', { defaultValue: '시스템' }), Icon: IconSettings },
|
||||||
|
];
|
||||||
|
|
||||||
|
const densityOptions: { value: Density; label: string }[] = [
|
||||||
|
{ value: 'comfortable', label: t('customizer.density.comfortable', { defaultValue: '보통' }) },
|
||||||
|
{ value: 'compact', label: t('customizer.density.compact', { defaultValue: '컴팩트' }) },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 기본값 복원 — 커스터마이저 상태(밀도·강조색) + 테마·사이드바까지 초기값으로. */
|
||||||
|
const handleReset = () => {
|
||||||
|
reset();
|
||||||
|
setMode('system');
|
||||||
|
setNavCollapsed(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Offcanvas
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t('customizer.title', { defaultValue: '화면 설정' })}
|
||||||
|
width={360}
|
||||||
|
footer={
|
||||||
|
<button type="button" className="kx-cz__reset" onClick={handleReset}>
|
||||||
|
{t('customizer.reset', { defaultValue: '기본값으로 복원' })}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="kx-cz__lead">
|
||||||
|
{t('customizer.lead', { defaultValue: '테마·레이아웃·강조색을 개인화합니다. 이 기기에 저장됩니다.' })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* ── 테마 ── */}
|
||||||
|
<section className="kx-cz__section" role="radiogroup" aria-label={t('customizer.theme.title', { defaultValue: '테마' })}>
|
||||||
|
<h3 className="kx-cz__heading">{t('customizer.theme.title', { defaultValue: '테마' })}</h3>
|
||||||
|
<div className="kx-cz__seg">
|
||||||
|
{themeOptions.map((opt) => {
|
||||||
|
const active = mode === opt.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
className={`kx-cz__seg-btn${active ? ' is-active' : ''}`}
|
||||||
|
role="radio"
|
||||||
|
aria-checked={active}
|
||||||
|
onClick={() => setMode(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.Icon && <opt.Icon size={16} />}
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── 레이아웃 ── */}
|
||||||
|
<section className="kx-cz__section">
|
||||||
|
<h3 className="kx-cz__heading">{t('customizer.layout.title', { defaultValue: '레이아웃' })}</h3>
|
||||||
|
|
||||||
|
{/* 사이드바 접기/펼치기 */}
|
||||||
|
<div className="kx-cz__row">
|
||||||
|
<div className="kx-cz__row-text">
|
||||||
|
<span className="kx-cz__row-label">{t('customizer.layout.sidebar', { defaultValue: '사이드바' })}</span>
|
||||||
|
<span className="kx-cz__row-desc">
|
||||||
|
{navCollapsed
|
||||||
|
? t('customizer.layout.sidebarCollapsed', { defaultValue: '접힘 — 콘텐츠 전폭' })
|
||||||
|
: t('customizer.layout.sidebarExpanded', { defaultValue: '펼침 — 메뉴 표시' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`kx-cz__switch${!navCollapsed ? ' is-on' : ''}`}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={!navCollapsed}
|
||||||
|
aria-label={t('customizer.layout.sidebar', { defaultValue: '사이드바' })}
|
||||||
|
onClick={() => setNavCollapsed(!navCollapsed)}
|
||||||
|
>
|
||||||
|
<span className="kx-cz__switch-knob" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 밀도 */}
|
||||||
|
<div className="kx-cz__field" role="radiogroup" aria-label={t('customizer.density.title', { defaultValue: '밀도' })}>
|
||||||
|
<span className="kx-cz__field-label">{t('customizer.density.title', { defaultValue: '밀도' })}</span>
|
||||||
|
<div className="kx-cz__seg">
|
||||||
|
{densityOptions.map((opt) => {
|
||||||
|
const active = density === opt.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
className={`kx-cz__seg-btn${active ? ' is-active' : ''}`}
|
||||||
|
role="radio"
|
||||||
|
aria-checked={active}
|
||||||
|
onClick={() => setDensity(opt.value)}
|
||||||
|
>
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── 강조색 ── */}
|
||||||
|
<section className="kx-cz__section" role="radiogroup" aria-label={t('customizer.accent.title', { defaultValue: '강조색' })}>
|
||||||
|
<h3 className="kx-cz__heading">{t('customizer.accent.title', { defaultValue: '강조색' })}</h3>
|
||||||
|
<div className="kx-cz__swatches">
|
||||||
|
{ACCENT_PRESETS.map((preset) => {
|
||||||
|
const active = accent === preset.key;
|
||||||
|
const label = t(ACCENT_LABEL_KEY[preset.key], { defaultValue: preset.label });
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={preset.key}
|
||||||
|
type="button"
|
||||||
|
className={`kx-cz__swatch${active ? ' is-active' : ''}`}
|
||||||
|
role="radio"
|
||||||
|
aria-checked={active}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
onClick={() => setAccent(preset.key)}
|
||||||
|
>
|
||||||
|
<span className="kx-cz__swatch-dot" style={{ background: preset.swatch }} aria-hidden="true">
|
||||||
|
{active && <IconCheck size={14} />}
|
||||||
|
</span>
|
||||||
|
<span className="kx-cz__swatch-label">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Offcanvas>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
src/frontend/src/components/layout/settings-customizer.css
Normal file
228
src/frontend/src/components/layout/settings-customizer.css
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
/*
|
||||||
|
* 설정 레이아웃 커스터마이저(#43) 스타일 + 밀도(data-density) 전역 토큰 조정.
|
||||||
|
* 전역 디자인 토큰(--color-*·--space-*·--radius-*·--fs-*)만 사용. kx-cz__* 네임스페이스.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── 밀도: compact 시 간격·행높이 축소(전역, 되돌림 가능) ── */
|
||||||
|
:root[data-density='compact'] {
|
||||||
|
--space-1: 3px;
|
||||||
|
--space-2: 6px;
|
||||||
|
--space-3: 9px;
|
||||||
|
--space-4: 12px;
|
||||||
|
--space-5: 18px;
|
||||||
|
--space-6: 24px;
|
||||||
|
--space-8: 36px;
|
||||||
|
--row-h: 38px;
|
||||||
|
--row-h-mobile: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 리드 문구 ── */
|
||||||
|
.kx-cz__lead {
|
||||||
|
margin: 0 0 var(--space-4);
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
line-height: var(--lh-caption);
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 섹션 ── */
|
||||||
|
.kx-cz__section {
|
||||||
|
padding: var(--space-4) 0;
|
||||||
|
border-top: var(--border-card);
|
||||||
|
}
|
||||||
|
.kx-cz__section:first-of-type {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
.kx-cz__heading {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: var(--fs-micro);
|
||||||
|
line-height: var(--lh-micro);
|
||||||
|
font-weight: var(--fw-semibold);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 세그먼트(테마·밀도) ── */
|
||||||
|
.kx-cz__seg {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-1);
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
.kx-cz__seg-btn {
|
||||||
|
flex: 1 1 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
min-height: 34px;
|
||||||
|
padding: var(--space-2);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.kx-cz__seg-btn:hover {
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
}
|
||||||
|
.kx-cz__seg-btn.is-active {
|
||||||
|
background: var(--color-white);
|
||||||
|
color: var(--color-primary-600);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
.kx-cz__seg-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--focus-ring);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 행(사이드바 스위치) ── */
|
||||||
|
.kx-cz__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
.kx-cz__row-text {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.kx-cz__row-label {
|
||||||
|
font-size: var(--fs-body);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
}
|
||||||
|
.kx-cz__row-desc {
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 토글 스위치 */
|
||||||
|
.kx-cz__switch {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
position: relative;
|
||||||
|
width: 42px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-neutral-200);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.kx-cz__switch.is-on {
|
||||||
|
background: var(--color-primary-600);
|
||||||
|
}
|
||||||
|
.kx-cz__switch-knob {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-white);
|
||||||
|
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.25);
|
||||||
|
transition: transform 0.18s cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
}
|
||||||
|
.kx-cz__switch.is-on .kx-cz__switch-knob {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
.kx-cz__switch:focus-visible {
|
||||||
|
outline: 2px solid var(--focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 밀도 필드(라벨 + 세그먼트) ── */
|
||||||
|
.kx-cz__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.kx-cz__field-label {
|
||||||
|
font-size: var(--fs-body);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 강조색 스와치 ── */
|
||||||
|
.kx-cz__swatches {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.kx-cz__swatch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: var(--border-card);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-white);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.kx-cz__swatch:hover {
|
||||||
|
border-color: var(--color-primary-600);
|
||||||
|
}
|
||||||
|
.kx-cz__swatch.is-active {
|
||||||
|
border-color: var(--color-primary-600);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-primary-600) inset;
|
||||||
|
}
|
||||||
|
.kx-cz__swatch:focus-visible {
|
||||||
|
outline: 2px solid var(--focus-ring);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.kx-cz__swatch-dot {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--color-on-accent);
|
||||||
|
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.2);
|
||||||
|
}
|
||||||
|
.kx-cz__swatch-label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
.kx-cz__swatch.is-active .kx-cz__swatch-label {
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 기본값 복원(푸터) ── */
|
||||||
|
.kx-cz__reset {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: var(--border-card);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-white);
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
font-size: var(--fs-body);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.kx-cz__reset:hover {
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
}
|
||||||
|
.kx-cz__reset:focus-visible {
|
||||||
|
outline: 2px solid var(--focus-ring);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
@ -1927,6 +1927,56 @@
|
|||||||
"s2": "Open the link shown and download the APK.",
|
"s2": "Open the link shown and download the APK.",
|
||||||
"s3": "Allow \"Install unknown apps\".",
|
"s3": "Allow \"Install unknown apps\".",
|
||||||
"s4": "After installation, open the app and sign in."
|
"s4": "After installation, open the app and sign in."
|
||||||
|
},
|
||||||
|
"eyebrow": "Mobile app",
|
||||||
|
"platformChip": "Android",
|
||||||
|
"qrHint": "Scan the QR with your phone camera.",
|
||||||
|
"features": {
|
||||||
|
"heading": "Key features",
|
||||||
|
"sub": "Handle exhibition tasks on mobile — from on-site operations to approvals and visualization.",
|
||||||
|
"checkin": {
|
||||||
|
"title": "On-site check-in",
|
||||||
|
"desc": "Verify move-in passes and process checklists right on site."
|
||||||
|
},
|
||||||
|
"inspection": {
|
||||||
|
"title": "On-site inspection",
|
||||||
|
"desc": "Hall managers inspect and approve booth construction with photos."
|
||||||
|
},
|
||||||
|
"gallery": {
|
||||||
|
"title": "Visualization gallery",
|
||||||
|
"desc": "Browse AI-generated post-construction preview images in a gallery."
|
||||||
|
},
|
||||||
|
"approval": {
|
||||||
|
"title": "Documents & approvals",
|
||||||
|
"desc": "Review submitted documents and approval status on the go."
|
||||||
|
},
|
||||||
|
"notify": {
|
||||||
|
"title": "Real-time alerts",
|
||||||
|
"desc": "Receive auction rankings, approval results, and site notices via push."
|
||||||
|
},
|
||||||
|
"offline": {
|
||||||
|
"title": "Offline support",
|
||||||
|
"desc": "Save and sync your work even where connectivity is unstable."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"heading": "App preview",
|
||||||
|
"sub": "Take a look at the main screens. Actual screens may change with updates.",
|
||||||
|
"s1": "On-site checklist",
|
||||||
|
"s2": "Visualization gallery",
|
||||||
|
"s3": "Documents & approvals"
|
||||||
|
},
|
||||||
|
"requirements": {
|
||||||
|
"heading": "Requirements",
|
||||||
|
"os": "Android 8.0 or later",
|
||||||
|
"space": "At least 100MB of free storage",
|
||||||
|
"network": "Wi-Fi connection recommended for install"
|
||||||
|
},
|
||||||
|
"store": {
|
||||||
|
"getOn": "Get it on",
|
||||||
|
"android": "Android APK",
|
||||||
|
"comingSoon": "Coming soon",
|
||||||
|
"ios": "iOS (TestFlight)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"authPolicy": {
|
"authPolicy": {
|
||||||
@ -2544,5 +2594,112 @@
|
|||||||
"ctaBandAria": "Vendor log in",
|
"ctaBandAria": "Vendor log in",
|
||||||
"ctaBandText": "Already a registered vendor?"
|
"ctaBandText": "Already a registered vendor?"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"_note": "SCR-T1 visitor v2.3.2 신규 i18n 키 (append-only). frontend가 단독 편집자로 4개 로케일 병합. 병렬 편집 금지.",
|
||||||
|
"keys": {
|
||||||
|
"ko": "AI 도우미",
|
||||||
|
"en": "AI Guide",
|
||||||
|
"zh": "AI 助手",
|
||||||
|
"ja": "AI ガイド"
|
||||||
|
},
|
||||||
|
"customizer": {
|
||||||
|
"title": "Appearance",
|
||||||
|
"lead": "Personalize theme, layout and accent color. Saved on this device.",
|
||||||
|
"reset": "Restore defaults",
|
||||||
|
"theme": {
|
||||||
|
"title": "Theme",
|
||||||
|
"light": "Light",
|
||||||
|
"dark": "Dark",
|
||||||
|
"system": "System"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"title": "Layout",
|
||||||
|
"sidebar": "Sidebar",
|
||||||
|
"sidebarExpanded": "Expanded — menu shown",
|
||||||
|
"sidebarCollapsed": "Collapsed — full-width content"
|
||||||
|
},
|
||||||
|
"density": {
|
||||||
|
"title": "Density",
|
||||||
|
"comfortable": "Comfortable",
|
||||||
|
"compact": "Compact"
|
||||||
|
},
|
||||||
|
"accent": {
|
||||||
|
"title": "Accent color",
|
||||||
|
"default": "Brand blue",
|
||||||
|
"indigo": "Indigo",
|
||||||
|
"teal": "Teal",
|
||||||
|
"violet": "Violet",
|
||||||
|
"rose": "Rose",
|
||||||
|
"amber": "Amber"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exhibitorsPage": {
|
||||||
|
"title": "Exhibitor Management",
|
||||||
|
"subtitle": "Exhibitor roster, booth assignment, design/settlement status, and contacts",
|
||||||
|
"noEventTitle": "No event selected",
|
||||||
|
"noEventDesc": "Please select an event from your workspace first.",
|
||||||
|
"kpisAria": "Exhibitor summary metrics",
|
||||||
|
"kpiTotal": "Exhibitors",
|
||||||
|
"kpiContracted": "Contracted",
|
||||||
|
"kpiDesignApproved": "Design Approved",
|
||||||
|
"kpiOutstanding": "Outstanding",
|
||||||
|
"listTitle": "Exhibitor list",
|
||||||
|
"searchPlaceholder": "Search company or booth no.",
|
||||||
|
"saleFilterLabel": "Booth assignment filter",
|
||||||
|
"filterAll": "All",
|
||||||
|
"loadError": "Failed to load the exhibitor list.",
|
||||||
|
"emptyTitle": "No exhibitors to show",
|
||||||
|
"emptyDesc": "Adjust your search or filter, or assign booths first.",
|
||||||
|
"colCompany": "Company",
|
||||||
|
"colBooth": "Booth",
|
||||||
|
"colType": "Type",
|
||||||
|
"colArea": "Area (m²)",
|
||||||
|
"colSale": "Assignment",
|
||||||
|
"colDesign": "Design",
|
||||||
|
"colSettlement": "Settlement",
|
||||||
|
"colContact": "Contact",
|
||||||
|
"dueShort": "Due ₩{{won}}",
|
||||||
|
"contactNone": "Not registered",
|
||||||
|
"count": "{{n}} exhibitors",
|
||||||
|
"detailTitle": "Exhibitor detail",
|
||||||
|
"statusSection": "Status",
|
||||||
|
"contactSection": "Contact",
|
||||||
|
"boothLabel": "Booth no.",
|
||||||
|
"typeLabel": "Booth type",
|
||||||
|
"areaLabel": "Area",
|
||||||
|
"saleLabel": "Assignment",
|
||||||
|
"designLabel": "Design status",
|
||||||
|
"settlementLabel": "Settlement status",
|
||||||
|
"outstandingLabel": "Outstanding",
|
||||||
|
"contactNameLabel": "Contact name",
|
||||||
|
"contactPhoneLabel": "Phone",
|
||||||
|
"won": "₩{{won}}",
|
||||||
|
"sale": {
|
||||||
|
"sold": "Sold",
|
||||||
|
"held": "Held",
|
||||||
|
"available": "Available",
|
||||||
|
"none": "N/A"
|
||||||
|
},
|
||||||
|
"design": {
|
||||||
|
"approved": "Approved",
|
||||||
|
"review": "In review",
|
||||||
|
"draft": "Draft",
|
||||||
|
"rejected": "Rejected",
|
||||||
|
"none": "N/A"
|
||||||
|
},
|
||||||
|
"invoice": {
|
||||||
|
"pending": "Pending",
|
||||||
|
"invoiced": "Invoiced",
|
||||||
|
"paid": "Paid",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"cancelled": "Cancelled",
|
||||||
|
"none": "No invoice"
|
||||||
|
},
|
||||||
|
"boothType": {
|
||||||
|
"assembled": "Assembled",
|
||||||
|
"independent": "Independent",
|
||||||
|
"corner": "Corner",
|
||||||
|
"island": "Island"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1927,6 +1927,56 @@
|
|||||||
"s2": "表示されたリンクを開いてAPKをダウンロードします。",
|
"s2": "表示されたリンクを開いてAPKをダウンロードします。",
|
||||||
"s3": "「提供元不明のアプリのインストール」を許可します。",
|
"s3": "「提供元不明のアプリのインストール」を許可します。",
|
||||||
"s4": "インストール後、アプリを開いてログインします。"
|
"s4": "インストール後、アプリを開いてログインします。"
|
||||||
|
},
|
||||||
|
"eyebrow": "モバイルアプリ",
|
||||||
|
"platformChip": "Android",
|
||||||
|
"qrHint": "スマートフォンのカメラでQRを読み取ってください。",
|
||||||
|
"features": {
|
||||||
|
"heading": "主な機能",
|
||||||
|
"sub": "現場運営から承認・可視化まで、展示業務をモバイルで処理します。",
|
||||||
|
"checkin": {
|
||||||
|
"title": "現場チェックイン",
|
||||||
|
"desc": "搬入・通行証と工程チェックリストを現場ですぐ確認・記録します。"
|
||||||
|
},
|
||||||
|
"inspection": {
|
||||||
|
"title": "現場検収",
|
||||||
|
"desc": "ホールマネージャーがブース施工状態を写真付きで検収・承認します。"
|
||||||
|
},
|
||||||
|
"gallery": {
|
||||||
|
"title": "ビジュアライゼーションギャラリー",
|
||||||
|
"desc": "AIが生成した施工後の予想イメージをギャラリーで閲覧します。"
|
||||||
|
},
|
||||||
|
"approval": {
|
||||||
|
"title": "書類・承認",
|
||||||
|
"desc": "提出書類と承認の進捗を移動中でも確認します。"
|
||||||
|
},
|
||||||
|
"notify": {
|
||||||
|
"title": "リアルタイム通知",
|
||||||
|
"desc": "オークション順位・承認結果・現場通知をプッシュで受け取ります。"
|
||||||
|
},
|
||||||
|
"offline": {
|
||||||
|
"title": "オフライン対応",
|
||||||
|
"desc": "通信が不安定な会場でも入力内容を保存・同期します。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"heading": "アプリプレビュー",
|
||||||
|
"sub": "主要画面を先にご覧ください。実際の画面は更新により変わる場合があります。",
|
||||||
|
"s1": "現場チェックリスト",
|
||||||
|
"s2": "ビジュアライゼーションギャラリー",
|
||||||
|
"s3": "書類・承認照会"
|
||||||
|
},
|
||||||
|
"requirements": {
|
||||||
|
"heading": "インストール要件",
|
||||||
|
"os": "Android 8.0 以上",
|
||||||
|
"space": "空き容量 100MB 以上",
|
||||||
|
"network": "インストール時は Wi-Fi 接続を推奨"
|
||||||
|
},
|
||||||
|
"store": {
|
||||||
|
"getOn": "ダウンロード",
|
||||||
|
"android": "Android APK",
|
||||||
|
"comingSoon": "準備中",
|
||||||
|
"ios": "iOS (TestFlight)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"authPolicy": {
|
"authPolicy": {
|
||||||
@ -2544,5 +2594,112 @@
|
|||||||
"ctaBandAria": "登録業者ログイン",
|
"ctaBandAria": "登録業者ログイン",
|
||||||
"ctaBandText": "すでに登録業者ですか?"
|
"ctaBandText": "すでに登録業者ですか?"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"_note": "SCR-T1 visitor v2.3.2 신규 i18n 키 (append-only). frontend가 단독 편집자로 4개 로케일 병합. 병렬 편집 금지.",
|
||||||
|
"keys": {
|
||||||
|
"ko": "AI 도우미",
|
||||||
|
"en": "AI Guide",
|
||||||
|
"zh": "AI 助手",
|
||||||
|
"ja": "AI ガイド"
|
||||||
|
},
|
||||||
|
"customizer": {
|
||||||
|
"title": "画面設定",
|
||||||
|
"lead": "テーマ・レイアウト・アクセントカラーを個人設定します。この端末に保存されます。",
|
||||||
|
"reset": "既定値に戻す",
|
||||||
|
"theme": {
|
||||||
|
"title": "テーマ",
|
||||||
|
"light": "ライト",
|
||||||
|
"dark": "ダーク",
|
||||||
|
"system": "システム"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"title": "レイアウト",
|
||||||
|
"sidebar": "サイドバー",
|
||||||
|
"sidebarExpanded": "展開 — メニュー表示",
|
||||||
|
"sidebarCollapsed": "折りたたみ — コンテンツ全幅"
|
||||||
|
},
|
||||||
|
"density": {
|
||||||
|
"title": "密度",
|
||||||
|
"comfortable": "標準",
|
||||||
|
"compact": "コンパクト"
|
||||||
|
},
|
||||||
|
"accent": {
|
||||||
|
"title": "アクセントカラー",
|
||||||
|
"default": "ブランドブルー",
|
||||||
|
"indigo": "インディゴ",
|
||||||
|
"teal": "ティール",
|
||||||
|
"violet": "バイオレット",
|
||||||
|
"rose": "ローズ",
|
||||||
|
"amber": "アンバー"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exhibitorsPage": {
|
||||||
|
"title": "出展社管理",
|
||||||
|
"subtitle": "出展社リスト・ブース割当・設計/精算状況・担当連絡先",
|
||||||
|
"noEventTitle": "選択されたイベントがありません",
|
||||||
|
"noEventDesc": "まずワークスペースでイベントを選択してください。",
|
||||||
|
"kpisAria": "出展社サマリー指標",
|
||||||
|
"kpiTotal": "出展社",
|
||||||
|
"kpiContracted": "契約完了",
|
||||||
|
"kpiDesignApproved": "設計承認",
|
||||||
|
"kpiOutstanding": "未収発生",
|
||||||
|
"listTitle": "出展社一覧",
|
||||||
|
"searchPlaceholder": "会社名・ブース番号で検索",
|
||||||
|
"saleFilterLabel": "ブース割当状態フィルター",
|
||||||
|
"filterAll": "すべて",
|
||||||
|
"loadError": "出展社一覧を読み込めませんでした。",
|
||||||
|
"emptyTitle": "表示する出展社がありません",
|
||||||
|
"emptyDesc": "検索条件やフィルターを調整するか、先にブースを割り当ててください。",
|
||||||
|
"colCompany": "会社名",
|
||||||
|
"colBooth": "ブース",
|
||||||
|
"colType": "ブース種別",
|
||||||
|
"colArea": "面積(m²)",
|
||||||
|
"colSale": "割当",
|
||||||
|
"colDesign": "設計",
|
||||||
|
"colSettlement": "精算",
|
||||||
|
"colContact": "担当連絡先",
|
||||||
|
"dueShort": "未収 {{won}}ウォン",
|
||||||
|
"contactNone": "未登録",
|
||||||
|
"count": "合計 {{n}} 社",
|
||||||
|
"detailTitle": "出展社詳細",
|
||||||
|
"statusSection": "進行状況",
|
||||||
|
"contactSection": "担当連絡先",
|
||||||
|
"boothLabel": "ブース番号",
|
||||||
|
"typeLabel": "ブース種別",
|
||||||
|
"areaLabel": "面積",
|
||||||
|
"saleLabel": "ブース割当",
|
||||||
|
"designLabel": "設計状況",
|
||||||
|
"settlementLabel": "精算状況",
|
||||||
|
"outstandingLabel": "未収金",
|
||||||
|
"contactNameLabel": "担当者",
|
||||||
|
"contactPhoneLabel": "電話",
|
||||||
|
"won": "{{won}}ウォン",
|
||||||
|
"sale": {
|
||||||
|
"sold": "契約完了",
|
||||||
|
"held": "仮予約",
|
||||||
|
"available": "未割当",
|
||||||
|
"none": "不明"
|
||||||
|
},
|
||||||
|
"design": {
|
||||||
|
"approved": "承認",
|
||||||
|
"review": "審査中",
|
||||||
|
"draft": "作成中",
|
||||||
|
"rejected": "差戻し",
|
||||||
|
"none": "不明"
|
||||||
|
},
|
||||||
|
"invoice": {
|
||||||
|
"pending": "発行待ち",
|
||||||
|
"invoiced": "発行済",
|
||||||
|
"paid": "完納",
|
||||||
|
"overdue": "延滞",
|
||||||
|
"cancelled": "取消",
|
||||||
|
"none": "請求なし"
|
||||||
|
},
|
||||||
|
"boothType": {
|
||||||
|
"assembled": "組立ブース",
|
||||||
|
"independent": "独立ブース",
|
||||||
|
"corner": "コーナーブース",
|
||||||
|
"island": "アイランドブース"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1927,6 +1927,56 @@
|
|||||||
"s2": "표시된 링크를 열어 APK를 다운로드합니다.",
|
"s2": "표시된 링크를 열어 APK를 다운로드합니다.",
|
||||||
"s3": "'출처를 알 수 없는 앱 설치'를 허용합니다.",
|
"s3": "'출처를 알 수 없는 앱 설치'를 허용합니다.",
|
||||||
"s4": "설치 후 앱을 열어 로그인합니다."
|
"s4": "설치 후 앱을 열어 로그인합니다."
|
||||||
|
},
|
||||||
|
"eyebrow": "모바일 앱",
|
||||||
|
"platformChip": "Android",
|
||||||
|
"qrHint": "휴대폰 카메라로 QR을 스캔하세요.",
|
||||||
|
"features": {
|
||||||
|
"heading": "핵심 기능",
|
||||||
|
"sub": "현장 운영부터 승인·시각화까지 전시 업무를 모바일에서 처리합니다.",
|
||||||
|
"checkin": {
|
||||||
|
"title": "현장 체크인",
|
||||||
|
"desc": "반입·통행증과 공정 체크리스트를 현장에서 바로 확인하고 기록합니다."
|
||||||
|
},
|
||||||
|
"inspection": {
|
||||||
|
"title": "현장 검수",
|
||||||
|
"desc": "홀매니저가 부스 시공 상태를 사진과 함께 검수하고 승인합니다."
|
||||||
|
},
|
||||||
|
"gallery": {
|
||||||
|
"title": "시각화 갤러리",
|
||||||
|
"desc": "AI가 생성한 시공 후 예상 이미지를 갤러리로 열람합니다."
|
||||||
|
},
|
||||||
|
"approval": {
|
||||||
|
"title": "서류·승인",
|
||||||
|
"desc": "제출 서류와 승인 진행 상황을 이동 중에도 조회합니다."
|
||||||
|
},
|
||||||
|
"notify": {
|
||||||
|
"title": "실시간 알림",
|
||||||
|
"desc": "옥션 순위, 승인 결과, 현장 공지를 푸시 알림으로 받습니다."
|
||||||
|
},
|
||||||
|
"offline": {
|
||||||
|
"title": "오프라인 지원",
|
||||||
|
"desc": "통신이 불안정한 전시장에서도 작성한 내용을 저장·동기화합니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"heading": "앱 미리보기",
|
||||||
|
"sub": "주요 화면을 미리 살펴보세요. 실제 화면은 업데이트에 따라 달라질 수 있습니다.",
|
||||||
|
"s1": "현장 체크리스트",
|
||||||
|
"s2": "시각화 갤러리",
|
||||||
|
"s3": "서류·승인 조회"
|
||||||
|
},
|
||||||
|
"requirements": {
|
||||||
|
"heading": "설치 요구사항",
|
||||||
|
"os": "Android 8.0 이상",
|
||||||
|
"space": "여유 저장공간 100MB 이상",
|
||||||
|
"network": "설치 시 Wi-Fi 연결 권장"
|
||||||
|
},
|
||||||
|
"store": {
|
||||||
|
"getOn": "다운로드",
|
||||||
|
"android": "Android APK",
|
||||||
|
"comingSoon": "준비 중",
|
||||||
|
"ios": "iOS (TestFlight)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"authPolicy": {
|
"authPolicy": {
|
||||||
@ -2544,5 +2594,112 @@
|
|||||||
"ctaBandAria": "등록업체 로그인",
|
"ctaBandAria": "등록업체 로그인",
|
||||||
"ctaBandText": "이미 등록된 업체이신가요?"
|
"ctaBandText": "이미 등록된 업체이신가요?"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"_note": "SCR-T1 visitor v2.3.2 신규 i18n 키 (append-only). frontend가 단독 편집자로 4개 로케일 병합. 병렬 편집 금지.",
|
||||||
|
"keys": {
|
||||||
|
"ko": "AI 도우미",
|
||||||
|
"en": "AI Guide",
|
||||||
|
"zh": "AI 助手",
|
||||||
|
"ja": "AI ガイド"
|
||||||
|
},
|
||||||
|
"customizer": {
|
||||||
|
"title": "화면 설정",
|
||||||
|
"lead": "테마·레이아웃·강조색을 개인화합니다. 이 기기에 저장됩니다.",
|
||||||
|
"reset": "기본값으로 복원",
|
||||||
|
"theme": {
|
||||||
|
"title": "테마",
|
||||||
|
"light": "라이트",
|
||||||
|
"dark": "다크",
|
||||||
|
"system": "시스템"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"title": "레이아웃",
|
||||||
|
"sidebar": "사이드바",
|
||||||
|
"sidebarExpanded": "펼침 — 메뉴 표시",
|
||||||
|
"sidebarCollapsed": "접힘 — 콘텐츠 전폭"
|
||||||
|
},
|
||||||
|
"density": {
|
||||||
|
"title": "밀도",
|
||||||
|
"comfortable": "보통",
|
||||||
|
"compact": "컴팩트"
|
||||||
|
},
|
||||||
|
"accent": {
|
||||||
|
"title": "강조색",
|
||||||
|
"default": "브랜드 블루",
|
||||||
|
"indigo": "인디고",
|
||||||
|
"teal": "틸",
|
||||||
|
"violet": "바이올렛",
|
||||||
|
"rose": "로즈",
|
||||||
|
"amber": "앰버"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exhibitorsPage": {
|
||||||
|
"title": "참가업체 관리",
|
||||||
|
"subtitle": "참가사 로스터·부스 배정·설계/정산 현황·담당 연락",
|
||||||
|
"noEventTitle": "선택된 행사가 없습니다",
|
||||||
|
"noEventDesc": "워크스페이스에서 행사를 먼저 선택해 주세요.",
|
||||||
|
"kpisAria": "참가업체 요약 지표",
|
||||||
|
"kpiTotal": "참가업체",
|
||||||
|
"kpiContracted": "계약 완료",
|
||||||
|
"kpiDesignApproved": "설계 승인",
|
||||||
|
"kpiOutstanding": "미수 발생",
|
||||||
|
"listTitle": "참가업체 목록",
|
||||||
|
"searchPlaceholder": "회사명·부스 번호 검색",
|
||||||
|
"saleFilterLabel": "부스 배정 상태 필터",
|
||||||
|
"filterAll": "전체",
|
||||||
|
"loadError": "참가업체 목록을 불러오지 못했습니다.",
|
||||||
|
"emptyTitle": "표시할 참가업체가 없습니다",
|
||||||
|
"emptyDesc": "검색어나 필터를 조정하거나, 부스 배정을 먼저 진행하세요.",
|
||||||
|
"colCompany": "회사명",
|
||||||
|
"colBooth": "부스",
|
||||||
|
"colType": "부스 유형",
|
||||||
|
"colArea": "면적(m²)",
|
||||||
|
"colSale": "배정",
|
||||||
|
"colDesign": "설계",
|
||||||
|
"colSettlement": "정산",
|
||||||
|
"colContact": "담당 연락",
|
||||||
|
"dueShort": "미수 {{won}}원",
|
||||||
|
"contactNone": "미등록",
|
||||||
|
"count": "총 {{n}}개 참가업체",
|
||||||
|
"detailTitle": "참가업체 상세",
|
||||||
|
"statusSection": "진행 상태",
|
||||||
|
"contactSection": "담당 연락",
|
||||||
|
"boothLabel": "부스 번호",
|
||||||
|
"typeLabel": "부스 유형",
|
||||||
|
"areaLabel": "면적",
|
||||||
|
"saleLabel": "부스 배정",
|
||||||
|
"designLabel": "설계 상태",
|
||||||
|
"settlementLabel": "정산 상태",
|
||||||
|
"outstandingLabel": "미수금",
|
||||||
|
"contactNameLabel": "담당자",
|
||||||
|
"contactPhoneLabel": "연락처",
|
||||||
|
"won": "{{won}}원",
|
||||||
|
"sale": {
|
||||||
|
"sold": "계약 완료",
|
||||||
|
"held": "예약",
|
||||||
|
"available": "미배정",
|
||||||
|
"none": "미상"
|
||||||
|
},
|
||||||
|
"design": {
|
||||||
|
"approved": "승인",
|
||||||
|
"review": "검토 중",
|
||||||
|
"draft": "작성 중",
|
||||||
|
"rejected": "반려",
|
||||||
|
"none": "미상"
|
||||||
|
},
|
||||||
|
"invoice": {
|
||||||
|
"pending": "발행 대기",
|
||||||
|
"invoiced": "발행",
|
||||||
|
"paid": "완납",
|
||||||
|
"overdue": "연체",
|
||||||
|
"cancelled": "취소",
|
||||||
|
"none": "청구 없음"
|
||||||
|
},
|
||||||
|
"boothType": {
|
||||||
|
"assembled": "조립부스",
|
||||||
|
"independent": "독립부스",
|
||||||
|
"corner": "코너부스",
|
||||||
|
"island": "아일랜드부스"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1927,6 +1927,56 @@
|
|||||||
"s2": "打开显示的链接并下载 APK。",
|
"s2": "打开显示的链接并下载 APK。",
|
||||||
"s3": "允许\"安装未知来源应用\"。",
|
"s3": "允许\"安装未知来源应用\"。",
|
||||||
"s4": "安装后打开应用并登录。"
|
"s4": "安装后打开应用并登录。"
|
||||||
|
},
|
||||||
|
"eyebrow": "移动应用",
|
||||||
|
"platformChip": "Android",
|
||||||
|
"qrHint": "使用手机相机扫描二维码。",
|
||||||
|
"features": {
|
||||||
|
"heading": "核心功能",
|
||||||
|
"sub": "从现场运营到审批与可视化,在移动端处理展会业务。",
|
||||||
|
"checkin": {
|
||||||
|
"title": "现场签到",
|
||||||
|
"desc": "在现场即时确认并记录搬入通行证与工序清单。"
|
||||||
|
},
|
||||||
|
"inspection": {
|
||||||
|
"title": "现场验收",
|
||||||
|
"desc": "展厅经理结合照片验收并审批展位施工状态。"
|
||||||
|
},
|
||||||
|
"gallery": {
|
||||||
|
"title": "可视化图库",
|
||||||
|
"desc": "在图库中浏览 AI 生成的施工后预览图。"
|
||||||
|
},
|
||||||
|
"approval": {
|
||||||
|
"title": "文件与审批",
|
||||||
|
"desc": "随时随地查看提交文件与审批进度。"
|
||||||
|
},
|
||||||
|
"notify": {
|
||||||
|
"title": "实时通知",
|
||||||
|
"desc": "通过推送接收拍卖排名、审批结果与现场公告。"
|
||||||
|
},
|
||||||
|
"offline": {
|
||||||
|
"title": "离线支持",
|
||||||
|
"desc": "即使在网络不稳定的展馆也能保存并同步所填内容。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"heading": "应用预览",
|
||||||
|
"sub": "先浏览主要界面。实际界面可能随更新而变化。",
|
||||||
|
"s1": "现场清单",
|
||||||
|
"s2": "可视化图库",
|
||||||
|
"s3": "文件与审批查询"
|
||||||
|
},
|
||||||
|
"requirements": {
|
||||||
|
"heading": "安装要求",
|
||||||
|
"os": "Android 8.0 及以上",
|
||||||
|
"space": "至少 100MB 可用存储空间",
|
||||||
|
"network": "安装时建议连接 Wi-Fi"
|
||||||
|
},
|
||||||
|
"store": {
|
||||||
|
"getOn": "下载",
|
||||||
|
"android": "Android APK",
|
||||||
|
"comingSoon": "即将推出",
|
||||||
|
"ios": "iOS (TestFlight)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"authPolicy": {
|
"authPolicy": {
|
||||||
@ -2544,5 +2594,112 @@
|
|||||||
"ctaBandAria": "注册企业登录",
|
"ctaBandAria": "注册企业登录",
|
||||||
"ctaBandText": "已是注册企业?"
|
"ctaBandText": "已是注册企业?"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"_note": "SCR-T1 visitor v2.3.2 신규 i18n 키 (append-only). frontend가 단독 편집자로 4개 로케일 병합. 병렬 편집 금지.",
|
||||||
|
"keys": {
|
||||||
|
"ko": "AI 도우미",
|
||||||
|
"en": "AI Guide",
|
||||||
|
"zh": "AI 助手",
|
||||||
|
"ja": "AI ガイド"
|
||||||
|
},
|
||||||
|
"customizer": {
|
||||||
|
"title": "界面设置",
|
||||||
|
"lead": "个性化主题、布局和强调色。保存在此设备上。",
|
||||||
|
"reset": "恢复默认",
|
||||||
|
"theme": {
|
||||||
|
"title": "主题",
|
||||||
|
"light": "浅色",
|
||||||
|
"dark": "深色",
|
||||||
|
"system": "系统"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"title": "布局",
|
||||||
|
"sidebar": "侧边栏",
|
||||||
|
"sidebarExpanded": "展开 — 显示菜单",
|
||||||
|
"sidebarCollapsed": "收起 — 全宽内容"
|
||||||
|
},
|
||||||
|
"density": {
|
||||||
|
"title": "密度",
|
||||||
|
"comfortable": "标准",
|
||||||
|
"compact": "紧凑"
|
||||||
|
},
|
||||||
|
"accent": {
|
||||||
|
"title": "强调色",
|
||||||
|
"default": "品牌蓝",
|
||||||
|
"indigo": "靛蓝",
|
||||||
|
"teal": "青绿",
|
||||||
|
"violet": "紫罗兰",
|
||||||
|
"rose": "玫瑰红",
|
||||||
|
"amber": "琥珀"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exhibitorsPage": {
|
||||||
|
"title": "参展商管理",
|
||||||
|
"subtitle": "参展商名单、展位分配、设计/结算状态及联系人",
|
||||||
|
"noEventTitle": "未选择活动",
|
||||||
|
"noEventDesc": "请先在工作区中选择一个活动。",
|
||||||
|
"kpisAria": "参展商摘要指标",
|
||||||
|
"kpiTotal": "参展商",
|
||||||
|
"kpiContracted": "已签约",
|
||||||
|
"kpiDesignApproved": "设计已批准",
|
||||||
|
"kpiOutstanding": "未收款",
|
||||||
|
"listTitle": "参展商列表",
|
||||||
|
"searchPlaceholder": "搜索公司名或展位号",
|
||||||
|
"saleFilterLabel": "展位分配状态筛选",
|
||||||
|
"filterAll": "全部",
|
||||||
|
"loadError": "无法加载参展商列表。",
|
||||||
|
"emptyTitle": "没有可显示的参展商",
|
||||||
|
"emptyDesc": "请调整搜索或筛选条件,或先进行展位分配。",
|
||||||
|
"colCompany": "公司",
|
||||||
|
"colBooth": "展位",
|
||||||
|
"colType": "展位类型",
|
||||||
|
"colArea": "面积(m²)",
|
||||||
|
"colSale": "分配",
|
||||||
|
"colDesign": "设计",
|
||||||
|
"colSettlement": "结算",
|
||||||
|
"colContact": "联系人",
|
||||||
|
"dueShort": "未收 ₩{{won}}",
|
||||||
|
"contactNone": "未登记",
|
||||||
|
"count": "共 {{n}} 家参展商",
|
||||||
|
"detailTitle": "参展商详情",
|
||||||
|
"statusSection": "状态",
|
||||||
|
"contactSection": "联系人",
|
||||||
|
"boothLabel": "展位号",
|
||||||
|
"typeLabel": "展位类型",
|
||||||
|
"areaLabel": "面积",
|
||||||
|
"saleLabel": "展位分配",
|
||||||
|
"designLabel": "设计状态",
|
||||||
|
"settlementLabel": "结算状态",
|
||||||
|
"outstandingLabel": "未收款",
|
||||||
|
"contactNameLabel": "联系人",
|
||||||
|
"contactPhoneLabel": "电话",
|
||||||
|
"won": "₩{{won}}",
|
||||||
|
"sale": {
|
||||||
|
"sold": "已签约",
|
||||||
|
"held": "预留",
|
||||||
|
"available": "可用",
|
||||||
|
"none": "未知"
|
||||||
|
},
|
||||||
|
"design": {
|
||||||
|
"approved": "已批准",
|
||||||
|
"review": "审核中",
|
||||||
|
"draft": "草稿",
|
||||||
|
"rejected": "已驳回",
|
||||||
|
"none": "未知"
|
||||||
|
},
|
||||||
|
"invoice": {
|
||||||
|
"pending": "待开票",
|
||||||
|
"invoiced": "已开票",
|
||||||
|
"paid": "已付款",
|
||||||
|
"overdue": "逾期",
|
||||||
|
"cancelled": "已取消",
|
||||||
|
"none": "无账单"
|
||||||
|
},
|
||||||
|
"boothType": {
|
||||||
|
"assembled": "标准展位",
|
||||||
|
"independent": "独立展位",
|
||||||
|
"corner": "转角展位",
|
||||||
|
"island": "岛式展位"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,14 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* SCR-APPQR 모바일 앱 설치 QR 페이지. 정본: WISE(UIWS) pages/MobileApp.tsx 구조 이식.
|
* SCR-APPQR 모바일 앱 다운로드 랜딩. 정본: WISE(UIWS) 모바일앱 소개 패턴 이식(#30 보강).
|
||||||
* 라우트 계약: /app-qr (App.tsx 배선은 통합자 관할 — _workspace/impl_appqr.md 참조).
|
* 라우트 계약: /app-qr (App.tsx 배선은 통합자 관할 — _workspace/impl_appqr.md 참조).
|
||||||
*
|
*
|
||||||
|
* 구성(WISE 모바일앱 소개 컨벤션): ①앱 소개 히어로(제목·설명·QR/설치) ②핵심 기능 카드
|
||||||
|
* ③스크린샷/목업 미리보기 ④설치 안내 스텝 + 요구사항 ⑤스토어 배지(Android APK / iOS 준비중).
|
||||||
|
*
|
||||||
* APK 자체 호스팅: 다운로드 URL = /downloads/kintex.apk (nginx 서빙, 서버 배치는 통합자).
|
* APK 자체 호스팅: 다운로드 URL = /downloads/kintex.apk (nginx 서빙, 서버 배치는 통합자).
|
||||||
* 버전 매니페스트 = /downloads/kintex-app.json ({ version, builtAt, size }).
|
* 버전 매니페스트 = /downloads/kintex-app.json ({ version, builtAt, size }).
|
||||||
* - 200: QR + 버전 정보 + 다운로드/링크복사 + 설치 안내 스텝 표시.
|
* - 200: 히어로에 QR + 버전 + 다운로드/링크복사 노출.
|
||||||
* - 404/실패: "앱 빌드 준비 중" 빈 상태 — APK가 아직 없음(EAS 빌드 G3 게이트)을 정직 표기.
|
* - 404/실패: 히어로 QR 슬롯에 "앱 빌드 준비 중" 빈 상태(EAS 빌드 G3 게이트) — 소개/기능 섹션은 유지.
|
||||||
* QR은 다운로드 URL을 자체 인코딩(AppQrCode, 외부 API/라이브러리 0).
|
* QR은 다운로드 URL을 자체 인코딩(AppQrCode, 외부 API/라이브러리 0).
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import AppQrCode from './AppQrCode';
|
import AppQrCode from './AppQrCode';
|
||||||
import { Button } from '../../components/ui/Button';
|
import { Button } from '../../components/ui/Button';
|
||||||
@ -19,6 +22,11 @@ import {
|
|||||||
IconOperations,
|
IconOperations,
|
||||||
IconGrid,
|
IconGrid,
|
||||||
IconWifiOff,
|
IconWifiOff,
|
||||||
|
IconCheckCircle,
|
||||||
|
IconShieldCheck,
|
||||||
|
IconImage,
|
||||||
|
IconDocument,
|
||||||
|
IconBell,
|
||||||
} from '../../components/ui/icons';
|
} from '../../components/ui/icons';
|
||||||
import './appqr.css';
|
import './appqr.css';
|
||||||
|
|
||||||
@ -51,6 +59,27 @@ function formatSize(size?: number | string): string | null {
|
|||||||
|
|
||||||
type Phase = 'loading' | 'ready' | 'unavailable';
|
type Phase = 'loading' | 'ready' | 'unavailable';
|
||||||
|
|
||||||
|
/** 핵심 기능 카드 정의 — 아이콘 + i18n 키(선 SVG, 이모지 0). */
|
||||||
|
interface Feature {
|
||||||
|
key: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
}
|
||||||
|
const FEATURES: Feature[] = [
|
||||||
|
{ key: 'checkin', icon: <IconCheckCircle size={22} /> },
|
||||||
|
{ key: 'inspection', icon: <IconShieldCheck size={22} /> },
|
||||||
|
{ key: 'gallery', icon: <IconImage size={22} /> },
|
||||||
|
{ key: 'approval', icon: <IconDocument size={22} /> },
|
||||||
|
{ key: 'notify', icon: <IconBell size={22} /> },
|
||||||
|
{ key: 'offline', icon: <IconWifiOff size={22} /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 미리보기 목업 프레임 — 실제 스크린샷 부재 시 아이콘 플레이스홀더(있으면 이미지로 교체). */
|
||||||
|
const PREVIEWS: Feature[] = [
|
||||||
|
{ key: 's1', icon: <IconCheckCircle size={30} /> },
|
||||||
|
{ key: 's2', icon: <IconImage size={30} /> },
|
||||||
|
{ key: 's3', icon: <IconDocument size={30} /> },
|
||||||
|
];
|
||||||
|
|
||||||
export function AppQrCodePage() {
|
export function AppQrCodePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [phase, setPhase] = useState<Phase>('loading');
|
const [phase, setPhase] = useState<Phase>('loading');
|
||||||
@ -94,8 +123,6 @@ export function AppQrCodePage() {
|
|||||||
const version = manifest?.version ?? null;
|
const version = manifest?.version ?? null;
|
||||||
const builtAt = manifest?.builtAt ?? null;
|
const builtAt = manifest?.builtAt ?? null;
|
||||||
const sizeLabel = formatSize(manifest?.size);
|
const sizeLabel = formatSize(manifest?.size);
|
||||||
const metaParts = ['Android', 'APK'];
|
|
||||||
if (sizeLabel) metaParts.push(t('appqr.about', { size: sizeLabel }));
|
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
t('appqr.steps.s1'),
|
t('appqr.steps.s1'),
|
||||||
@ -104,58 +131,32 @@ export function AppQrCodePage() {
|
|||||||
t('appqr.steps.s4'),
|
t('appqr.steps.s4'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const requirements = [
|
||||||
|
t('appqr.requirements.os'),
|
||||||
|
t('appqr.requirements.space'),
|
||||||
|
t('appqr.requirements.network'),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="kx-page kx-appqr">
|
<main className="kx-page kx-appqr">
|
||||||
<header className="kx-appqr__head">
|
{/* ── ① 앱 소개 히어로 ── */}
|
||||||
<div>
|
<section className="kx-appqr__hero">
|
||||||
|
<div className="kx-appqr__hero-text">
|
||||||
|
<span className="kx-appqr__eyebrow">{t('appqr.eyebrow')}</span>
|
||||||
<h1 className="kx-appqr__title">{t('appqr.title')}</h1>
|
<h1 className="kx-appqr__title">{t('appqr.title')}</h1>
|
||||||
<p className="kx-appqr__subtitle">{t('appqr.subtitle')}</p>
|
<p className="kx-appqr__subtitle">{t('appqr.subtitle')}</p>
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p className="kx-appqr__intro">{t('appqr.intro')}</p>
|
<p className="kx-appqr__intro">{t('appqr.intro')}</p>
|
||||||
|
|
||||||
{phase === 'loading' ? (
|
<div className="kx-appqr__chips">
|
||||||
<div className="kx-card kx-appqr__card">
|
<span className="kx-appqr__chip">{t('appqr.platformChip')}</span>
|
||||||
<Skeleton height={200} width={200} radius={8} />
|
{version ? <span className="kx-appqr__chip">v{version}</span> : null}
|
||||||
<div className="kx-appqr__body">
|
{sizeLabel ? (
|
||||||
<Skeleton height={22} width={220} />
|
<span className="kx-appqr__chip">{t('appqr.about', { size: sizeLabel })}</span>
|
||||||
<Skeleton height={14} width={160} />
|
|
||||||
<Skeleton height={36} width={280} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : phase === 'unavailable' ? (
|
|
||||||
<div className="kx-card">
|
|
||||||
<EmptyState
|
|
||||||
icon={<IconWifiOff size={40} />}
|
|
||||||
title={t('appqr.notReady.title')}
|
|
||||||
description={t('appqr.notReady.desc')}
|
|
||||||
action={
|
|
||||||
<Button variant="secondary" onClick={() => void load()}>
|
|
||||||
{t('appqr.retry')}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="kx-card kx-appqr__card">
|
|
||||||
<div className="kx-appqr__qr">
|
|
||||||
{/* 다운로드 URL을 자체 인코딩한 QR(외부 API 0). */}
|
|
||||||
<AppQrCode value={absoluteApkUrl()} size={200} title={t('appqr.qrAlt')} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="kx-appqr__body">
|
|
||||||
<div className="kx-appqr__name-row">
|
|
||||||
<IconGrid size={20} />
|
|
||||||
<span className="kx-appqr__name">{t('appqr.appName')}</span>
|
|
||||||
{version ? <span className="kx-appqr__ver">v{version}</span> : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="kx-appqr__meta">{metaParts.join(' · ')}</p>
|
|
||||||
{builtAt ? (
|
|
||||||
<p className="kx-appqr__meta">{t('appqr.builtAt', { date: builtAt })}</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{phase === 'ready' ? (
|
||||||
|
<>
|
||||||
<div className="kx-appqr__actions">
|
<div className="kx-appqr__actions">
|
||||||
<a className="kx-btn kx-btn--primary" href={APK_URL} download="kintex.apk">
|
<a className="kx-btn kx-btn--primary" href={APK_URL} download="kintex.apk">
|
||||||
<span className="kx-btn__icon" aria-hidden="true">
|
<span className="kx-btn__icon" aria-hidden="true">
|
||||||
@ -178,14 +179,94 @@ export function AppQrCodePage() {
|
|||||||
{t('appqr.refresh')}
|
{t('appqr.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{builtAt ? (
|
||||||
<p className="kx-appqr__ios">{t('appqr.iosNote')}</p>
|
<p className="kx-appqr__meta">{t('appqr.builtAt', { date: builtAt })}</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 히어로 QR 슬롯 — phase별 로딩/미가용/QR */}
|
||||||
|
<div className="kx-appqr__hero-qr">
|
||||||
|
{phase === 'loading' ? (
|
||||||
|
<div className="kx-card kx-appqr__qr-card">
|
||||||
|
<Skeleton height={200} width={200} radius={8} />
|
||||||
|
<Skeleton height={16} width={160} />
|
||||||
|
</div>
|
||||||
|
) : phase === 'unavailable' ? (
|
||||||
|
<div className="kx-card kx-appqr__qr-card">
|
||||||
|
<EmptyState
|
||||||
|
icon={<IconWifiOff size={40} />}
|
||||||
|
title={t('appqr.notReady.title')}
|
||||||
|
description={t('appqr.notReady.desc')}
|
||||||
|
action={
|
||||||
|
<Button variant="secondary" onClick={() => void load()}>
|
||||||
|
{t('appqr.retry')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="kx-card kx-appqr__qr-card">
|
||||||
|
<div className="kx-appqr__qr">
|
||||||
|
{/* 다운로드 URL을 자체 인코딩한 QR(외부 API 0). */}
|
||||||
|
<AppQrCode value={absoluteApkUrl()} size={200} title={t('appqr.qrAlt')} />
|
||||||
|
</div>
|
||||||
|
<div className="kx-appqr__name-row">
|
||||||
|
<IconGrid size={18} />
|
||||||
|
<span className="kx-appqr__name">{t('appqr.appName')}</span>
|
||||||
|
</div>
|
||||||
|
<p className="kx-appqr__qr-hint">{t('appqr.qrHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="kx-appqr__steps-section" aria-label={t('appqr.steps.heading')}>
|
{/* ── ② 핵심 기능 카드 ── */}
|
||||||
<h2 className="kx-appqr__steps-heading">{t('appqr.steps.heading')}</h2>
|
<section className="kx-appqr__section" aria-label={t('appqr.features.heading')}>
|
||||||
|
<h2 className="kx-appqr__section-title">{t('appqr.features.heading')}</h2>
|
||||||
|
<p className="kx-appqr__section-sub">{t('appqr.features.sub')}</p>
|
||||||
|
<div className="kx-appqr__features">
|
||||||
|
{FEATURES.map((f) => (
|
||||||
|
<div key={f.key} className="kx-card kx-appqr__feature">
|
||||||
|
<span className="kx-appqr__feature-icon" aria-hidden="true">
|
||||||
|
{f.icon}
|
||||||
|
</span>
|
||||||
|
<div className="kx-appqr__feature-body">
|
||||||
|
<h3 className="kx-appqr__feature-title">
|
||||||
|
{t(`appqr.features.${f.key}.title`)}
|
||||||
|
</h3>
|
||||||
|
<p className="kx-appqr__feature-desc">
|
||||||
|
{t(`appqr.features.${f.key}.desc`)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── ③ 스크린샷/목업 미리보기 ── */}
|
||||||
|
<section className="kx-appqr__section" aria-label={t('appqr.preview.heading')}>
|
||||||
|
<h2 className="kx-appqr__section-title">{t('appqr.preview.heading')}</h2>
|
||||||
|
<p className="kx-appqr__section-sub">{t('appqr.preview.sub')}</p>
|
||||||
|
<div className="kx-appqr__previews">
|
||||||
|
{PREVIEWS.map((p) => (
|
||||||
|
<figure key={p.key} className="kx-appqr__preview">
|
||||||
|
<div className="kx-appqr__preview-frame" aria-hidden="true">
|
||||||
|
<span className="kx-appqr__preview-icon">{p.icon}</span>
|
||||||
|
</div>
|
||||||
|
<figcaption className="kx-appqr__preview-cap">
|
||||||
|
{t(`appqr.preview.${p.key}`)}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── ④ 설치 안내 + ⑤ 요구사항/스토어 배지 ── */}
|
||||||
|
<section className="kx-appqr__section kx-appqr__install">
|
||||||
|
<div className="kx-appqr__install-col">
|
||||||
|
<h2 className="kx-appqr__section-title">{t('appqr.steps.heading')}</h2>
|
||||||
<ol className="kx-appqr__steps">
|
<ol className="kx-appqr__steps">
|
||||||
{steps.map((s, i) => (
|
{steps.map((s, i) => (
|
||||||
<li key={i} className="kx-appqr__step">
|
<li key={i} className="kx-appqr__step">
|
||||||
@ -196,6 +277,43 @@ export function AppQrCodePage() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="kx-appqr__install-col">
|
||||||
|
<h2 className="kx-appqr__section-title">{t('appqr.requirements.heading')}</h2>
|
||||||
|
<ul className="kx-appqr__reqs">
|
||||||
|
{requirements.map((r, i) => (
|
||||||
|
<li key={i} className="kx-appqr__req">
|
||||||
|
<span className="kx-appqr__req-icon" aria-hidden="true">
|
||||||
|
<IconCheck size={15} />
|
||||||
|
</span>
|
||||||
|
<span>{r}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="kx-appqr__badges">
|
||||||
|
<div className="kx-appqr__badge kx-appqr__badge--android">
|
||||||
|
<span className="kx-appqr__badge-icon" aria-hidden="true">
|
||||||
|
<IconDownload size={20} />
|
||||||
|
</span>
|
||||||
|
<div className="kx-appqr__badge-text">
|
||||||
|
<span className="kx-appqr__badge-sub">{t('appqr.store.getOn')}</span>
|
||||||
|
<span className="kx-appqr__badge-main">{t('appqr.store.android')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kx-appqr__badge kx-appqr__badge--ios">
|
||||||
|
<span className="kx-appqr__badge-icon" aria-hidden="true">
|
||||||
|
<IconShieldCheck size={20} />
|
||||||
|
</span>
|
||||||
|
<div className="kx-appqr__badge-text">
|
||||||
|
<span className="kx-appqr__badge-sub">{t('appqr.store.comingSoon')}</span>
|
||||||
|
<span className="kx-appqr__badge-main">{t('appqr.store.ios')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="kx-appqr__ios">{t('appqr.iosNote')}</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,52 +1,110 @@
|
|||||||
/*
|
/*
|
||||||
* SCR-APPQR 모바일 앱 설치 QR 페이지 전용 스타일.
|
* SCR-APPQR 모바일 앱 다운로드 랜딩 전용 스타일.
|
||||||
* design.md §1 토큰만 참조. shared.css 프리미티브(kx-page·kx-card) 위에 얹는다.
|
* design.md §1 토큰만 참조. shared.css 프리미티브(kx-page·kx-card) 위에 얹는다.
|
||||||
* 정본: WISE(UIWS) pages/MobileApp.css 구조 이식(토큰만 kintex로 치환).
|
* 정본: WISE(UIWS) 모바일앱 소개 패턴 이식(토큰만 kintex로 치환). 이모지 0·선 SVG만.
|
||||||
*/
|
*/
|
||||||
@import '../shared.css';
|
@import '../shared.css';
|
||||||
|
|
||||||
.kx-appqr {
|
.kx-appqr {
|
||||||
max-width: 900px;
|
max-width: 1120px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 페이지 헤더 ── */
|
/* ── ① 히어로 ── */
|
||||||
.kx-appqr__head {
|
.kx-appqr__hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 320px;
|
||||||
|
gap: var(--space-6);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.kx-appqr__hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.kx-appqr__hero-text {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
gap: var(--space-2);
|
||||||
gap: var(--space-4);
|
}
|
||||||
flex-wrap: wrap;
|
.kx-appqr__eyebrow {
|
||||||
|
align-self: flex-start;
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
font-weight: var(--fw-bold);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-primary-700);
|
||||||
|
background: var(--color-primary-050);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
padding: 3px 12px;
|
||||||
}
|
}
|
||||||
.kx-appqr__title {
|
.kx-appqr__title {
|
||||||
font-size: var(--fs-h2);
|
font-size: var(--fs-h1);
|
||||||
|
line-height: 1.2;
|
||||||
color: var(--color-neutral-900);
|
color: var(--color-neutral-900);
|
||||||
|
margin: var(--space-1) 0 0;
|
||||||
}
|
}
|
||||||
.kx-appqr__subtitle {
|
.kx-appqr__subtitle {
|
||||||
margin-top: 2px;
|
font-size: var(--fs-h3);
|
||||||
font-size: var(--fs-body);
|
font-weight: var(--fw-regular);
|
||||||
color: var(--color-neutral-500);
|
color: var(--color-neutral-700);
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
.kx-appqr__intro {
|
.kx-appqr__intro {
|
||||||
font-size: var(--fs-body);
|
font-size: var(--fs-body);
|
||||||
color: var(--color-neutral-500);
|
color: var(--color-neutral-500);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin: 0;
|
margin: var(--space-1) 0 0;
|
||||||
max-width: 720px;
|
max-width: 560px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── QR + 정보 카드 ── */
|
.kx-appqr__chips {
|
||||||
.kx-appqr__card {
|
display: flex;
|
||||||
display: grid;
|
flex-wrap: wrap;
|
||||||
grid-template-columns: 232px 1fr;
|
gap: var(--space-2);
|
||||||
gap: var(--space-5);
|
margin-top: var(--space-2);
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.kx-appqr__card {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
}
|
||||||
|
.kx-appqr__chip {
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
background: var(--color-neutral-050);
|
||||||
|
border: var(--border-card);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
padding: 4px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.kx-appqr__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
.kx-appqr__actions .kx-btn {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.kx-appqr__meta {
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 히어로 QR 카드 */
|
||||||
|
.kx-appqr__hero-qr {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.kx-appqr__qr-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
.kx-appqr__qr {
|
.kx-appqr__qr {
|
||||||
background: var(--color-white);
|
background: var(--color-white);
|
||||||
border: var(--border-card);
|
border: var(--border-card);
|
||||||
@ -62,12 +120,6 @@
|
|||||||
height: auto;
|
height: auto;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kx-appqr__body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
.kx-appqr__name-row {
|
.kx-appqr__name-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -75,62 +127,146 @@
|
|||||||
color: var(--color-neutral-700);
|
color: var(--color-neutral-700);
|
||||||
}
|
}
|
||||||
.kx-appqr__name {
|
.kx-appqr__name {
|
||||||
font-size: var(--fs-h3);
|
font-size: var(--fs-body);
|
||||||
font-weight: var(--fw-bold);
|
font-weight: var(--fw-bold);
|
||||||
color: var(--color-neutral-900);
|
color: var(--color-neutral-900);
|
||||||
}
|
}
|
||||||
.kx-appqr__ver {
|
.kx-appqr__qr-hint {
|
||||||
font-size: var(--fs-caption);
|
font-size: var(--fs-caption);
|
||||||
font-weight: var(--fw-bold);
|
|
||||||
color: var(--color-on-accent);
|
|
||||||
background: var(--color-primary-600);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
padding: 2px 10px;
|
|
||||||
}
|
|
||||||
.kx-appqr__meta {
|
|
||||||
font-size: var(--fs-body);
|
|
||||||
color: var(--color-neutral-500);
|
color: var(--color-neutral-500);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.kx-appqr__ios {
|
|
||||||
|
/* ── 섹션 공통 ── */
|
||||||
|
.kx-appqr__section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.kx-appqr__section-title {
|
||||||
|
font-size: var(--fs-h2);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.kx-appqr__section-sub {
|
||||||
|
font-size: var(--fs-body);
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
margin: calc(-1 * var(--space-2)) 0 0;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ② 핵심 기능 카드 ── */
|
||||||
|
.kx-appqr__features {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
.kx-appqr__feature {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.kx-appqr__feature-icon {
|
||||||
|
flex: none;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-primary-050);
|
||||||
|
color: var(--color-primary-700);
|
||||||
|
}
|
||||||
|
.kx-appqr__feature-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.kx-appqr__feature-title {
|
||||||
|
font-size: var(--fs-h3);
|
||||||
|
font-weight: var(--fw-bold);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.kx-appqr__feature-desc {
|
||||||
|
font-size: var(--fs-body);
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ③ 미리보기 목업 ── */
|
||||||
|
.kx-appqr__previews {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
.kx-appqr__preview {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.kx-appqr__preview-frame {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 220px;
|
||||||
|
aspect-ratio: 9 / 18;
|
||||||
|
border: 2px solid var(--color-neutral-200);
|
||||||
|
border-radius: 24px;
|
||||||
|
background: linear-gradient(
|
||||||
|
160deg,
|
||||||
|
var(--color-primary-050) 0%,
|
||||||
|
var(--color-neutral-050) 100%
|
||||||
|
);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--color-primary-600);
|
||||||
|
}
|
||||||
|
.kx-appqr__preview-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-white);
|
||||||
|
border: var(--border-card);
|
||||||
|
}
|
||||||
|
.kx-appqr__preview-cap {
|
||||||
font-size: var(--fs-caption);
|
font-size: var(--fs-caption);
|
||||||
color: var(--color-neutral-500);
|
color: var(--color-neutral-500);
|
||||||
margin: var(--space-2) 0 0;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kx-appqr__actions {
|
/* ── ④⑤ 설치 안내 + 요구사항/배지 ── */
|
||||||
|
.kx-appqr__install {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-6);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.kx-appqr__install {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.kx-appqr__install-col {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
gap: var(--space-2);
|
gap: var(--space-3);
|
||||||
margin-top: var(--space-2);
|
|
||||||
}
|
|
||||||
/* 다운로드는 <a> 이지만 kx-btn 룩을 그대로 사용(버튼 컴포넌트와 정렬) */
|
|
||||||
.kx-appqr__actions .kx-btn {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 설치 안내 스텝 ── */
|
|
||||||
.kx-appqr__steps-section {
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
}
|
|
||||||
.kx-appqr__steps-heading {
|
|
||||||
font-size: var(--fs-h3);
|
|
||||||
color: var(--color-neutral-900);
|
|
||||||
margin: 0 0 var(--space-3);
|
|
||||||
}
|
}
|
||||||
.kx-appqr__steps {
|
.kx-appqr__steps {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
flex-direction: column;
|
||||||
gap: var(--space-3);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
.kx-appqr__step {
|
.kx-appqr__step {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
padding: var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border: var(--border-card);
|
border: var(--border-card);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: var(--color-white);
|
background: var(--color-white);
|
||||||
@ -152,3 +288,81 @@
|
|||||||
color: var(--color-neutral-700);
|
color: var(--color-neutral-700);
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.kx-appqr__reqs {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.kx-appqr__req {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--fs-body);
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
.kx-appqr__req-icon {
|
||||||
|
flex: none;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-success-050, var(--color-primary-050));
|
||||||
|
color: var(--color-success-600, var(--color-primary-700));
|
||||||
|
}
|
||||||
|
|
||||||
|
.kx-appqr__badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
.kx-appqr__badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border: var(--border-card);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
.kx-appqr__badge--android {
|
||||||
|
background: var(--color-neutral-900);
|
||||||
|
border-color: var(--color-neutral-900);
|
||||||
|
color: var(--color-white);
|
||||||
|
}
|
||||||
|
.kx-appqr__badge--android .kx-appqr__badge-icon,
|
||||||
|
.kx-appqr__badge--android .kx-appqr__badge-sub {
|
||||||
|
color: var(--color-neutral-200);
|
||||||
|
}
|
||||||
|
.kx-appqr__badge--ios {
|
||||||
|
background: var(--color-white);
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
}
|
||||||
|
.kx-appqr__badge-icon {
|
||||||
|
flex: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.kx-appqr__badge-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.kx-appqr__badge-sub {
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
}
|
||||||
|
.kx-appqr__badge-main {
|
||||||
|
font-size: var(--fs-body);
|
||||||
|
font-weight: var(--fw-bold);
|
||||||
|
}
|
||||||
|
.kx-appqr__ios {
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
}
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import {
|
|||||||
IconPlus,
|
IconPlus,
|
||||||
IconSpark,
|
IconSpark,
|
||||||
} from '../../components/ui/icons';
|
} from '../../components/ui/icons';
|
||||||
import { approvalApi, type ApprovalLine } from './approvalApi';
|
import { approvalApi, type ApprovalLine, type ApprovalStatus } from './approvalApi';
|
||||||
import './review-details.css';
|
import './review-details.css';
|
||||||
import './approval-form.css';
|
import './approval-form.css';
|
||||||
|
|
||||||
@ -32,6 +32,21 @@ const TABS = ['도면', '예상 사진 (참고용)', '버전 비교'];
|
|||||||
|
|
||||||
type ActionKind = 'approve' | 'reject';
|
type ActionKind = 'approve' | 'reject';
|
||||||
|
|
||||||
|
// 상태 → Nifty blog contextual 배지 톤(공용 .kx-detailview__badge)
|
||||||
|
function statusTone(s: ApprovalStatus): string {
|
||||||
|
switch (s) {
|
||||||
|
case 'APPROVED':
|
||||||
|
return 'kx-detailview__badge--ok';
|
||||||
|
case 'REJECTED':
|
||||||
|
return 'kx-detailview__badge--danger';
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
case 'SUBMITTED':
|
||||||
|
return 'kx-detailview__badge--info';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function lineTone(s: ApprovalLine['lineStatus']): string {
|
function lineTone(s: ApprovalLine['lineStatus']): string {
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case 'CURRENT':
|
case 'CURRENT':
|
||||||
@ -111,24 +126,33 @@ export function ReviewDetailsPage() {
|
|||||||
<span className="kx-rev__crumb-cur">{d ? d.title : `부스 ${booth}`}</span>
|
<span className="kx-rev__crumb-cur">{d ? d.title : `부스 ${booth}`}</span>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="kx-rev__title-row">
|
<div className="kx-rev__title-row">
|
||||||
<h1>{d ? d.title : t('approval.reviewTitle')}</h1>
|
<h1 className="kx-detailview__title">{d ? d.title : t('approval.reviewTitle')}</h1>
|
||||||
{d ? (
|
{d ? (
|
||||||
<>
|
<span className={`kx-detailview__badge ${statusTone(d.status)}`}>{statusLabel}</span>
|
||||||
<span className="kx-rev__meta">
|
|
||||||
{t('approval.version')}: v{d.version}
|
|
||||||
</span>
|
|
||||||
<span className="kx-rev__meta">
|
|
||||||
{t('approval.drafter')}: {d.drafterName ?? d.drafterId}
|
|
||||||
</span>
|
|
||||||
<span className="kx-rev__meta">{statusLabel}</span>
|
|
||||||
<span className="kx-rev__meta">
|
|
||||||
{t('approval.step')} {d.curStep}/{d.totalSteps}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="kx-rev__meta">{t('approval.demoMode')}</span>
|
<span className="kx-detailview__badge kx-detailview__badge--info">
|
||||||
|
{t('approval.demoMode')}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{d ? (
|
||||||
|
<div className="kx-detailview__meta">
|
||||||
|
<span className="kx-detailview__meta-item">
|
||||||
|
<span className="kx-detailview__meta-label">{t('approval.version')}</span>
|
||||||
|
<span className="kx-detailview__meta-value">v{d.version}</span>
|
||||||
|
</span>
|
||||||
|
<span className="kx-detailview__meta-item">
|
||||||
|
<span className="kx-detailview__meta-label">{t('approval.drafter')}</span>
|
||||||
|
<span className="kx-detailview__meta-value">{d.drafterName ?? d.drafterId}</span>
|
||||||
|
</span>
|
||||||
|
<span className="kx-detailview__meta-item">
|
||||||
|
<span className="kx-detailview__meta-label">{t('approval.step')}</span>
|
||||||
|
<span className="kx-detailview__meta-value">
|
||||||
|
{d.curStep}/{d.totalSteps}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" leadingIcon={<IconDownload size={16} />}>
|
<Button variant="ghost" leadingIcon={<IconDownload size={16} />}>
|
||||||
{t('approval.downloadOriginal')}
|
{t('approval.downloadOriginal')}
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
/* shared.css 프리미티브(kx-detailview 상세 뷰 hero·meta·badge 등) 선반영 */
|
||||||
|
@import '../shared.css';
|
||||||
|
|
||||||
.kx-rev {
|
.kx-rev {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@ -137,30 +137,31 @@ export function AuctionDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="kx-page kx-auc">
|
<div className="kx-page kx-auc">
|
||||||
{/* 상단 컨텍스트 바 */}
|
{/* 상단 히어로 — Nifty blog 상세 헤더(브레드크럼·제목·상태·메타) */}
|
||||||
<div className="kx-aucd__bar">
|
<header className="kx-detailview__hero">
|
||||||
<div className="kx-aucd__bar-left">
|
<nav className="kx-detailview__crumb" aria-label="경로">
|
||||||
|
<button type="button" onClick={() => navigate('/auctions')}>
|
||||||
|
공사 옥션
|
||||||
|
</button>
|
||||||
|
<span aria-hidden="true">/</span>
|
||||||
|
<span className="kx-detailview__crumb-cur">{detail.title}</span>
|
||||||
|
</nav>
|
||||||
|
<div className="kx-detailview__title-row">
|
||||||
{!closed && (
|
{!closed && (
|
||||||
<span className="kx-live">
|
<span className="kx-live">
|
||||||
<span className="kx-live__dot" aria-hidden="true" />
|
<span className="kx-live__dot" aria-hidden="true" />
|
||||||
실시간
|
실시간
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<h1 className="kx-auc__title" style={{ fontSize: 'var(--fs-h2)' }}>
|
<h1 className="kx-detailview__title">{detail.title}</h1>
|
||||||
{detail.title}
|
|
||||||
</h1>
|
|
||||||
<span className="kx-tag kx-tag--type">{detail.type}</span>
|
|
||||||
<span className="kx-tag kx-tag--muted">라운드 {detail.round}</span>
|
|
||||||
</div>
|
|
||||||
<div className="kx-aucd__bar-metrics">
|
|
||||||
<div className="kx-aucd__metric">
|
|
||||||
<span className="kx-aucd__metric-label">상태</span>
|
|
||||||
<span
|
<span
|
||||||
className={`kx-aucd__metric-value ${!closed ? 'kx-aucd__metric-value--ok' : ''}`}
|
className={`kx-detailview__badge ${
|
||||||
|
closed ? 'kx-detailview__badge--info' : 'kx-detailview__badge--ok'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{detail.status === '진행중' ? '활성 응찰 중' : detail.status}
|
{detail.status === '진행중' ? '활성 응찰 중' : detail.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<div className="kx-detailview__hero-aside">
|
||||||
<div className="kx-aucd__metric">
|
<div className="kx-aucd__metric">
|
||||||
<span className="kx-aucd__metric-label">현재 최저가</span>
|
<span className="kx-aucd__metric-label">현재 최저가</span>
|
||||||
<span className="kx-aucd__metric-value tnum">
|
<span className="kx-aucd__metric-value tnum">
|
||||||
@ -169,6 +170,17 @@ export function AuctionDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="kx-detailview__meta">
|
||||||
|
<span className="kx-detailview__meta-item">
|
||||||
|
<span className="kx-detailview__meta-label">유형</span>
|
||||||
|
<span className="kx-detailview__meta-value">{detail.type}</span>
|
||||||
|
</span>
|
||||||
|
<span className="kx-detailview__meta-item">
|
||||||
|
<span className="kx-detailview__meta-label">라운드</span>
|
||||||
|
<span className="kx-detailview__meta-value">{detail.round}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div className="kx-aucd__grid">
|
<div className="kx-aucd__grid">
|
||||||
{/* 좌 — AI 자료 뷰어 */}
|
{/* 좌 — AI 자료 뷰어 */}
|
||||||
|
|||||||
292
src/frontend/src/screens/exhibitors/ExhibitorsPage.tsx
Normal file
292
src/frontend/src/screens/exhibitors/ExhibitorsPage.tsx
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
/*
|
||||||
|
* SCR /exhibitors — 주최자용 참가업체 관리(#41).
|
||||||
|
* 진입: 사이드바 "참가업체 관리"(전용 라우트 — 종전 대시보드 폴백 해소).
|
||||||
|
* 정본: GET /api/events/{eventId}/exhibitors (ExhibitorList — 요약 + 로스터).
|
||||||
|
* 구성: ①상단 요약 밴드 ②검색/상태 필터 ③참가사 로스터(회사·부스·설계/정산·연락) ④상세 드로어.
|
||||||
|
* ★ 상태·금액은 서버 계산 권위 — 프론트는 표시/클라이언트 필터만. 이모지 금지·선 SVG 아이콘.
|
||||||
|
*/
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||||
|
import { Offcanvas } from '../../components/ui/Offcanvas';
|
||||||
|
import { IconUsers, IconExhibitors, IconCheckCircle, IconSettlement } from '../../components/ui/icons';
|
||||||
|
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
|
||||||
|
import { exhibitorsApi } from './exhibitorsApi';
|
||||||
|
import type {
|
||||||
|
DesignStatus,
|
||||||
|
ExhibitorRow,
|
||||||
|
InvoiceStatus,
|
||||||
|
SaleStatus,
|
||||||
|
} from './exhibitorsApi';
|
||||||
|
import './exhibitors.css';
|
||||||
|
|
||||||
|
type SaleFilter = '' | SaleStatus;
|
||||||
|
|
||||||
|
const SALE_FILTERS: SaleFilter[] = ['', 'sold', 'held', 'available'];
|
||||||
|
|
||||||
|
/** 상태 → 톤(색 클래스). 미상은 muted. */
|
||||||
|
function saleTone(s: SaleStatus | null): string {
|
||||||
|
return s === 'sold' ? 'ok' : s === 'held' ? 'info' : 'muted';
|
||||||
|
}
|
||||||
|
function designTone(s: DesignStatus | null): string {
|
||||||
|
return s === 'approved' ? 'ok' : s === 'rejected' ? 'error' : s === 'review' ? 'info' : 'muted';
|
||||||
|
}
|
||||||
|
function invoiceTone(s: InvoiceStatus | null): string {
|
||||||
|
if (s === 'paid') return 'ok';
|
||||||
|
if (s === 'overdue') return 'error';
|
||||||
|
if (s === 'invoiced') return 'info';
|
||||||
|
return 'muted';
|
||||||
|
}
|
||||||
|
|
||||||
|
function saleLabel(t: TFunction, s: SaleStatus | null): string {
|
||||||
|
return t(`exhibitorsPage.sale.${s ?? 'none'}`, { defaultValue: '-' });
|
||||||
|
}
|
||||||
|
function designLabel(t: TFunction, s: DesignStatus | null): string {
|
||||||
|
return t(`exhibitorsPage.design.${s ?? 'none'}`, { defaultValue: '-' });
|
||||||
|
}
|
||||||
|
function invoiceLabel(t: TFunction, s: InvoiceStatus | null): string {
|
||||||
|
return t(`exhibitorsPage.invoice.${s ?? 'none'}`, { defaultValue: '-' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtWon(n: number): string {
|
||||||
|
return `${n.toLocaleString('ko-KR')}`;
|
||||||
|
}
|
||||||
|
function fmtArea(a: number | null): string {
|
||||||
|
return a == null ? '-' : `${a.toLocaleString('ko-KR')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExhibitorsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const eventId = useResolvedEventId();
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [saleFilter, setSaleFilter] = useState<SaleFilter>('');
|
||||||
|
const [selected, setSelected] = useState<ExhibitorRow | null>(null);
|
||||||
|
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ['exhibitors', eventId],
|
||||||
|
queryFn: () => exhibitorsApi.list(eventId as string),
|
||||||
|
enabled: !!eventId,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const all = q.data?.exhibitors ?? [];
|
||||||
|
const kw = keyword.trim().toLowerCase();
|
||||||
|
return all.filter((r) => {
|
||||||
|
if (saleFilter && r.saleStatus !== saleFilter) return false;
|
||||||
|
if (kw) {
|
||||||
|
const hay = `${r.companyName ?? ''} ${r.boothNo ?? ''}`.toLowerCase();
|
||||||
|
if (!hay.includes(kw)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [q.data, keyword, saleFilter]);
|
||||||
|
|
||||||
|
if (!eventId) {
|
||||||
|
return (
|
||||||
|
<div className="kx-page">
|
||||||
|
<PageHeader t={t} />
|
||||||
|
<EmptyState title={t('exhibitorsPage.noEventTitle')} description={t('exhibitorsPage.noEventDesc')} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = q.data?.summary;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kx-page">
|
||||||
|
<PageHeader t={t} eventName={q.data?.eventName} />
|
||||||
|
|
||||||
|
{/* 요약 밴드 */}
|
||||||
|
{q.isLoading ? (
|
||||||
|
<div className="kx-exh__kpis" aria-hidden="true">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} height={92} radius={12} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : summary ? (
|
||||||
|
<section className="kx-exh__kpis" aria-label={t('exhibitorsPage.kpisAria')}>
|
||||||
|
<KpiCard icon={<IconUsers size={16} />} label={t('exhibitorsPage.kpiTotal')} value={String(summary.total)} tone="info" />
|
||||||
|
<KpiCard icon={<IconCheckCircle size={16} />} label={t('exhibitorsPage.kpiContracted')} value={String(summary.contracted)} tone="ok" />
|
||||||
|
<KpiCard icon={<IconExhibitors size={16} />} label={t('exhibitorsPage.kpiDesignApproved')} value={String(summary.designApproved)} tone="ok" />
|
||||||
|
<KpiCard
|
||||||
|
icon={<IconSettlement size={16} />}
|
||||||
|
label={t('exhibitorsPage.kpiOutstanding')}
|
||||||
|
value={String(summary.outstandingCount)}
|
||||||
|
tone={summary.outstandingCount > 0 ? 'error' : 'muted'}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 목록 카드 */}
|
||||||
|
<section className="kx-card kx-exh__list-card" aria-label={t('exhibitorsPage.listTitle')}>
|
||||||
|
<div className="kx-card__head kx-exh__toolbar">
|
||||||
|
<div className="kx-exh__search">
|
||||||
|
<input
|
||||||
|
className="kx-input kx-exh__search-input"
|
||||||
|
type="search"
|
||||||
|
placeholder={t('exhibitorsPage.searchPlaceholder')}
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
aria-label={t('exhibitorsPage.searchPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="kx-seg" role="tablist" aria-label={t('exhibitorsPage.saleFilterLabel')}>
|
||||||
|
{SALE_FILTERS.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v || 'all'}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={saleFilter === v}
|
||||||
|
className={`kx-seg__btn ${saleFilter === v ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setSaleFilter(v)}
|
||||||
|
>
|
||||||
|
{v === '' ? t('exhibitorsPage.filterAll') : saleLabel(t, v)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{q.isLoading ? (
|
||||||
|
<div style={{ display: 'grid', gap: 8 }} aria-hidden="true">
|
||||||
|
{[0, 1, 2, 3, 4].map((i) => (
|
||||||
|
<Skeleton key={i} height={44} radius={8} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : q.isError ? (
|
||||||
|
<ErrorState message={t('exhibitorsPage.loadError')} onRetry={() => q.refetch()} />
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('exhibitorsPage.emptyTitle')}
|
||||||
|
description={t('exhibitorsPage.emptyDesc')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="kx-table-scroll">
|
||||||
|
<table className="kx-table kx-table--zebra">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('exhibitorsPage.colCompany')}</th>
|
||||||
|
<th>{t('exhibitorsPage.colBooth')}</th>
|
||||||
|
<th>{t('exhibitorsPage.colType')}</th>
|
||||||
|
<th className="kx-num">{t('exhibitorsPage.colArea')}</th>
|
||||||
|
<th>{t('exhibitorsPage.colSale')}</th>
|
||||||
|
<th>{t('exhibitorsPage.colDesign')}</th>
|
||||||
|
<th>{t('exhibitorsPage.colSettlement')}</th>
|
||||||
|
<th>{t('exhibitorsPage.colContact')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.boothId} className="kx-exh__row" onClick={() => setSelected(r)}>
|
||||||
|
<td className="kx-exh__company">{r.companyName}</td>
|
||||||
|
<td className="tnum">{r.boothNo ?? '-'}</td>
|
||||||
|
<td>{r.boothType ? t(`exhibitorsPage.boothType.${r.boothType}`, { defaultValue: r.boothType }) : '-'}</td>
|
||||||
|
<td className="kx-num tnum">{fmtArea(r.areaM2)}</td>
|
||||||
|
<td><Pill tone={saleTone(r.saleStatus)} label={saleLabel(t, r.saleStatus)} /></td>
|
||||||
|
<td><Pill tone={designTone(r.designStatus)} label={designLabel(t, r.designStatus)} /></td>
|
||||||
|
<td>
|
||||||
|
<Pill tone={invoiceTone(r.invoiceStatus)} label={invoiceLabel(t, r.invoiceStatus)} />
|
||||||
|
{r.outstanding > 0 && (
|
||||||
|
<span className="kx-exh__due tnum">{t('exhibitorsPage.dueShort', { won: fmtWon(r.outstanding) })}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{r.contactName ?? r.contactPhone ?? <span className="kx-exh__muted">{t('exhibitorsPage.contactNone')}</span>}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!q.isLoading && !q.isError && rows.length > 0 && (
|
||||||
|
<p className="kx-exh__count">{t('exhibitorsPage.count', { n: rows.length })}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 상세 드로어(부스·서류·연락) */}
|
||||||
|
<Offcanvas
|
||||||
|
open={!!selected}
|
||||||
|
onClose={() => setSelected(null)}
|
||||||
|
title={selected?.companyName ?? t('exhibitorsPage.detailTitle')}
|
||||||
|
width={420}
|
||||||
|
>
|
||||||
|
{selected && <ExhibitorDetail t={t} row={selected} />}
|
||||||
|
</Offcanvas>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExhibitorDetail({ t, row }: { t: TFunction; row: ExhibitorRow }) {
|
||||||
|
return (
|
||||||
|
<div className="kx-exh__detail">
|
||||||
|
<dl className="kx-exh__dl">
|
||||||
|
<DetailRow k={t('exhibitorsPage.boothLabel')} v={row.boothNo ?? '-'} />
|
||||||
|
<DetailRow k={t('exhibitorsPage.typeLabel')} v={row.boothType ? t(`exhibitorsPage.boothType.${row.boothType}`, { defaultValue: row.boothType }) : '-'} />
|
||||||
|
<DetailRow k={t('exhibitorsPage.areaLabel')} v={row.areaM2 == null ? '-' : `${fmtArea(row.areaM2)} m²`} />
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h3 className="kx-exh__detail-h">{t('exhibitorsPage.statusSection')}</h3>
|
||||||
|
<dl className="kx-exh__dl">
|
||||||
|
<DetailRow k={t('exhibitorsPage.saleLabel')} v={<Pill tone={saleTone(row.saleStatus)} label={saleLabel(t, row.saleStatus)} />} />
|
||||||
|
<DetailRow k={t('exhibitorsPage.designLabel')} v={<Pill tone={designTone(row.designStatus)} label={designLabel(t, row.designStatus)} />} />
|
||||||
|
<DetailRow k={t('exhibitorsPage.settlementLabel')} v={<Pill tone={invoiceTone(row.invoiceStatus)} label={invoiceLabel(t, row.invoiceStatus)} />} />
|
||||||
|
<DetailRow
|
||||||
|
k={t('exhibitorsPage.outstandingLabel')}
|
||||||
|
v={<span className="tnum">{t('exhibitorsPage.won', { won: fmtWon(row.outstanding) })}</span>}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h3 className="kx-exh__detail-h">{t('exhibitorsPage.contactSection')}</h3>
|
||||||
|
<dl className="kx-exh__dl">
|
||||||
|
<DetailRow k={t('exhibitorsPage.contactNameLabel')} v={row.contactName ?? '-'} />
|
||||||
|
<DetailRow k={t('exhibitorsPage.contactPhoneLabel')} v={row.contactPhone ?? '-'} />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ k, v }: { k: string; v: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="kx-exh__dl-row">
|
||||||
|
<dt>{k}</dt>
|
||||||
|
<dd>{v}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pill({ tone, label }: { tone: string; label: string }) {
|
||||||
|
return <span className={`kx-exh__pill kx-exh__pill--${tone}`}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone: 'info' | 'ok' | 'error' | 'muted';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`kx-kpi kx-exh__kpi kx-exh__kpi--${tone}`}>
|
||||||
|
<span className="kx-kpi__label">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<strong className="kx-kpi__value tnum">{value}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageHeader({ t, eventName }: { t: TFunction; eventName?: string }) {
|
||||||
|
return (
|
||||||
|
<header className="kx-exh__head">
|
||||||
|
<div>
|
||||||
|
<h1 className="kx-page__title">{t('exhibitorsPage.title')}</h1>
|
||||||
|
<p className="kx-page__subtitle">{eventName ? `${eventName} · ${t('exhibitorsPage.subtitle')}` : t('exhibitorsPage.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
src/frontend/src/screens/exhibitors/exhibitors.css
Normal file
157
src/frontend/src/screens/exhibitors/exhibitors.css
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
/*
|
||||||
|
* 주최자용 참가업체 관리 화면 스타일 (#41) — 전 규칙 .kx-exh 로 스코프 격리.
|
||||||
|
* design.md §1 토큰(색·radius·spacing)만 사용. 선 SVG/텍스트만 — 이모지 장식 없음.
|
||||||
|
* shared.css 프리미티브(kx-page·kx-card·kx-table·kx-seg·kx-kpi) 위에 얹는다.
|
||||||
|
*/
|
||||||
|
@import '../shared.css';
|
||||||
|
|
||||||
|
/* 헤더 */
|
||||||
|
.kx-exh__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 요약 밴드 */
|
||||||
|
.kx-exh__kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
.kx-exh__kpi--ok .kx-kpi__value {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
.kx-exh__kpi--error .kx-kpi__value {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
.kx-exh__kpi--info .kx-kpi__value {
|
||||||
|
color: var(--color-primary-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 툴바(검색 + 상태 필터) */
|
||||||
|
.kx-exh__toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.kx-exh__search {
|
||||||
|
flex: 1 1 260px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
.kx-exh__search-input {
|
||||||
|
width: 100%;
|
||||||
|
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-700);
|
||||||
|
}
|
||||||
|
.kx-exh__search-input:focus {
|
||||||
|
outline: 2px solid var(--color-primary-600);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 목록 */
|
||||||
|
.kx-exh__list-card {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
.kx-exh__row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.kx-exh__company {
|
||||||
|
font-weight: var(--fw-semibold);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
}
|
||||||
|
.kx-exh__due {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
.kx-exh__muted {
|
||||||
|
color: var(--color-neutral-400);
|
||||||
|
}
|
||||||
|
.kx-exh__count {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 상태 필 */
|
||||||
|
.kx-exh__pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
font-weight: var(--fw-semibold);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.kx-exh__pill--ok {
|
||||||
|
background: var(--color-success-050, #ecfdf3);
|
||||||
|
color: var(--color-success, #12b76a);
|
||||||
|
}
|
||||||
|
.kx-exh__pill--info {
|
||||||
|
background: var(--color-primary-050, #eff8ff);
|
||||||
|
color: var(--color-primary-700, #175cd3);
|
||||||
|
}
|
||||||
|
.kx-exh__pill--error {
|
||||||
|
background: var(--color-error-050, #fef3f2);
|
||||||
|
color: var(--color-error, #f04438);
|
||||||
|
}
|
||||||
|
.kx-exh__pill--muted {
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
color: var(--color-neutral-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 상세 드로어 */
|
||||||
|
.kx-exh__detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
.kx-exh__detail-h {
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
font-weight: var(--fw-semibold);
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
}
|
||||||
|
.kx-exh__dl {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.kx-exh__dl-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--color-neutral-200);
|
||||||
|
}
|
||||||
|
.kx-exh__dl-row dt {
|
||||||
|
font-size: var(--fs-body);
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
}
|
||||||
|
.kx-exh__dl-row dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--fs-body);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 반응형 — 요약 밴드 2열/1열 */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.kx-exh__kpis {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.kx-exh__kpis {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/frontend/src/screens/exhibitors/exhibitorsApi.ts
Normal file
45
src/frontend/src/screens/exhibitors/exhibitorsApi.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* 주최자용 참가업체 관리 실 API 클라이언트 (#41 · SCR /exhibitors).
|
||||||
|
* 정본: 백엔드 com.zioinfo.kintex.exhibitor (ExhibitorController) 시그니처.
|
||||||
|
* GET /api/events/{eventId}/exhibitors → ExhibitorList (요약 + 로스터).
|
||||||
|
* RBAC: 주최자·홀매니저만(서버 재검증). 금액·상태는 서버 계산 권위 — 프론트는 표시만.
|
||||||
|
*/
|
||||||
|
import { api } from '../../api/client';
|
||||||
|
|
||||||
|
export type SaleStatus = 'sold' | 'held' | 'available';
|
||||||
|
export type DesignStatus = 'approved' | 'review' | 'draft' | 'rejected';
|
||||||
|
export type InvoiceStatus = 'pending' | 'invoiced' | 'paid' | 'overdue' | 'cancelled';
|
||||||
|
|
||||||
|
export interface ExhibitorRow {
|
||||||
|
boothId: string;
|
||||||
|
companyName: string;
|
||||||
|
boothNo: string | null;
|
||||||
|
boothType: string | null;
|
||||||
|
areaM2: number | null;
|
||||||
|
saleStatus: SaleStatus | null;
|
||||||
|
designStatus: DesignStatus | null;
|
||||||
|
invoiceStatus: InvoiceStatus | null;
|
||||||
|
outstanding: number;
|
||||||
|
contactName: string | null;
|
||||||
|
contactPhone: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExhibitorSummary {
|
||||||
|
total: number;
|
||||||
|
contracted: number;
|
||||||
|
designApproved: number;
|
||||||
|
settlementPaid: number;
|
||||||
|
outstandingCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExhibitorList {
|
||||||
|
eventId: string;
|
||||||
|
eventName: string;
|
||||||
|
summary: ExhibitorSummary;
|
||||||
|
exhibitors: ExhibitorRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const exhibitorsApi = {
|
||||||
|
list: (eventId: string) =>
|
||||||
|
api.get<ExhibitorList>(`/api/events/${encodeURIComponent(eventId)}/exhibitors`),
|
||||||
|
};
|
||||||
@ -881,10 +881,29 @@ function todoSeverityDot(sev: HomeTodoItem['severity']): string {
|
|||||||
return sev === 'overdue' ? 'is-ended' : sev === 'warning' ? 'is-upcoming' : 'is-ongoing';
|
return sev === 'overdue' ? 'is-ended' : sev === 'warning' ? 'is-upcoming' : 'is-ongoing';
|
||||||
}
|
}
|
||||||
function todoDueText(item: HomeTodoItem, t: (k: string, o?: Record<string, unknown>) => string): string {
|
function todoDueText(item: HomeTodoItem, t: (k: string, o?: Record<string, unknown>) => string): string {
|
||||||
if (item.dday == null) return t('home.todo.noDue');
|
// 기한 없는 핫리드 팔로업은 '팔로업' 라벨로 부각(다른 무기한 항목은 '기한 없음').
|
||||||
|
if (item.dday == null) {
|
||||||
|
return item.type === 'lead'
|
||||||
|
? t('home.todo.followup', { defaultValue: '팔로업' })
|
||||||
|
: t('home.todo.noDue');
|
||||||
|
}
|
||||||
if (item.dday < 0) return `D+${Math.abs(item.dday)}`;
|
if (item.dday < 0) return `D+${Math.abs(item.dday)}`;
|
||||||
return `D-${item.dday}`;
|
return `D-${item.dday}`;
|
||||||
}
|
}
|
||||||
|
/** 할 일 유형 칩 라벨(로컬라이즈; i18n 병합 전에는 한국어 폴백). */
|
||||||
|
const TODO_TYPE_FALLBACK: Record<string, string> = {
|
||||||
|
approval: '결재',
|
||||||
|
document: '서류',
|
||||||
|
milestone: '마일스톤',
|
||||||
|
auction: '옥션',
|
||||||
|
settlement: '정산',
|
||||||
|
checkin: '체크인',
|
||||||
|
lead: '리드',
|
||||||
|
system: '시스템',
|
||||||
|
};
|
||||||
|
function todoTypeLabel(type: string, t: (k: string, o?: Record<string, unknown>) => string): string {
|
||||||
|
return t(`home.todo.type.${type}`, { defaultValue: TODO_TYPE_FALLBACK[type] ?? type });
|
||||||
|
}
|
||||||
|
|
||||||
function TodoWidget() {
|
function TodoWidget() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -928,7 +947,12 @@ function TodoWidget() {
|
|||||||
>
|
>
|
||||||
<span className={`kx-home__legend-dot ${todoSeverityDot(item.severity)}`} aria-hidden="true" />
|
<span className={`kx-home__legend-dot ${todoSeverityDot(item.severity)}`} aria-hidden="true" />
|
||||||
<span className="kx-home__todo-main">
|
<span className="kx-home__todo-main">
|
||||||
<span className="kx-home__todo-title">{item.title}</span>
|
<span className="kx-home__todo-title">
|
||||||
|
<span className={`kx-home__todo-tag is-${item.type}`}>
|
||||||
|
{todoTypeLabel(item.type, t)}
|
||||||
|
</span>
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
<span className="kx-home__todo-target">{item.target}</span>
|
<span className="kx-home__todo-target">{item.target}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className={`kx-home__todo-due tnum is-${item.severity}`}>
|
<span className={`kx-home__todo-due tnum is-${item.severity}`}>
|
||||||
|
|||||||
@ -768,6 +768,28 @@
|
|||||||
font-size: var(--fs-body);
|
font-size: var(--fs-body);
|
||||||
color: var(--color-neutral-900);
|
color: var(--color-neutral-900);
|
||||||
font-weight: var(--fw-medium);
|
font-weight: var(--fw-medium);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.kx-home__todo-tag {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
border: var(--border-card);
|
||||||
|
}
|
||||||
|
.kx-home__todo-tag.is-checkin,
|
||||||
|
.kx-home__todo-tag.is-lead {
|
||||||
|
color: var(--color-primary-700);
|
||||||
|
background: var(--color-primary-050);
|
||||||
|
border-color: var(--color-primary-100);
|
||||||
}
|
}
|
||||||
.kx-home__todo-target {
|
.kx-home__todo-target {
|
||||||
font-size: var(--fs-caption);
|
font-size: var(--fs-caption);
|
||||||
|
|||||||
@ -8,8 +8,16 @@
|
|||||||
*/
|
*/
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
|
|
||||||
// ── 1) GET /api/home/todos — 내 할 일(역할 기반) ──
|
// ── 1) GET /api/home/todos — 내 할 일(역할 기반·크로스도메인 자동 집계) ──
|
||||||
export type TodoType = 'approval' | 'document' | 'auction' | 'settlement' | 'system';
|
export type TodoType =
|
||||||
|
| 'approval'
|
||||||
|
| 'document'
|
||||||
|
| 'milestone'
|
||||||
|
| 'auction'
|
||||||
|
| 'settlement'
|
||||||
|
| 'checkin'
|
||||||
|
| 'lead'
|
||||||
|
| 'system';
|
||||||
export type TodoSeverity = 'normal' | 'warning' | 'overdue';
|
export type TodoSeverity = 'normal' | 'warning' | 'overdue';
|
||||||
|
|
||||||
export interface HomeTodoItem {
|
export interface HomeTodoItem {
|
||||||
|
|||||||
@ -338,6 +338,187 @@
|
|||||||
background: var(--color-primary-600);
|
background: var(--color-primary-600);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════
|
||||||
|
* 공용 상세 뷰 — Nifty blog(blog-apps/blog) 계보
|
||||||
|
* 상세/기사형 화면(옥션·검수·행사·전시 상세 등)의 히어로·메타·본문·사이드바를 통일한다.
|
||||||
|
* 신규 색 토큰 0 — 기존 --color-*·--space-*·--fs-*·--shadow-* 만 재사용.
|
||||||
|
* 구조: __hero(브레드크럼+제목+메타/상태배지) → __grid(main + sidebar) → __section(본문 리듬).
|
||||||
|
* 기존 work 모듈의 .kx-detail(마스터-디테일 패널)과 이름 충돌 회피를 위해 .kx-detailview 네임스페이스 사용.
|
||||||
|
* ══════════════════════════════════════════════════════════ */
|
||||||
|
.kx-detailview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 히어로 — blog 기사 헤더(브레드크럼·제목·메타·상태) */
|
||||||
|
.kx-detailview__hero {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-5);
|
||||||
|
border: var(--border-card);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-white);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
.kx-detailview__crumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.kx-detailview__crumb button,
|
||||||
|
.kx-detailview__crumb a {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
font-size: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.kx-detailview__crumb button:hover,
|
||||||
|
.kx-detailview__crumb a:hover {
|
||||||
|
color: var(--color-primary-700);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.kx-detailview__crumb-cur {
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
font-weight: var(--fw-semibold);
|
||||||
|
}
|
||||||
|
.kx-detailview__title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.kx-detailview__title {
|
||||||
|
font-size: var(--fs-h1);
|
||||||
|
line-height: var(--lh-h1);
|
||||||
|
font-weight: var(--fw-bold);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
}
|
||||||
|
.kx-detailview__hero-aside {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
.kx-detailview__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
}
|
||||||
|
.kx-detailview__meta-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
/* 메타 항목 사이 중간점 구분 */
|
||||||
|
.kx-detailview__meta-item + .kx-detailview__meta-item::before {
|
||||||
|
content: "·";
|
||||||
|
margin-right: var(--space-3);
|
||||||
|
color: var(--color-neutral-200);
|
||||||
|
}
|
||||||
|
.kx-detailview__meta-label {
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
}
|
||||||
|
.kx-detailview__meta-value {
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
font-weight: var(--fw-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 상태 배지(작성·기간·상태) — Nifty contextual pill */
|
||||||
|
.kx-detailview__badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
font-weight: var(--fw-semibold);
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.kx-detailview__badge--ok {
|
||||||
|
background: var(--color-success-bg);
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
.kx-detailview__badge--warn {
|
||||||
|
background: var(--color-warning-bg);
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
.kx-detailview__badge--danger {
|
||||||
|
background: var(--color-error-bg);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
.kx-detailview__badge--info {
|
||||||
|
background: var(--color-primary-050);
|
||||||
|
color: var(--color-primary-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 본문 그리드 — 메인 콘텐츠 + 우측 사이드바(관련정보·CTA) */
|
||||||
|
.kx-detailview__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
|
||||||
|
gap: var(--space-5);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.kx-detailview__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.kx-detailview__main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-5);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.kx-detailview__sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
position: sticky;
|
||||||
|
top: var(--space-5);
|
||||||
|
}
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.kx-detailview__sidebar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 섹션 — 본문 리듬(제목 구분선 + 리치 프로즈) */
|
||||||
|
.kx-detailview__section {
|
||||||
|
border: var(--border-card);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-white);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
.kx-detailview__section-title {
|
||||||
|
font-size: var(--fs-h3);
|
||||||
|
line-height: var(--lh-h3);
|
||||||
|
font-weight: var(--fw-semibold);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
padding-bottom: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--color-neutral-200);
|
||||||
|
}
|
||||||
|
.kx-detailview__prose {
|
||||||
|
font-size: var(--fs-body);
|
||||||
|
line-height: var(--lh-body);
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
/* 성과 배지 */
|
/* 성과 배지 */
|
||||||
.kx-grade {
|
.kx-grade {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
114
src/frontend/src/store/customizerStore.ts
Normal file
114
src/frontend/src/store/customizerStore.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
/*
|
||||||
|
* 레이아웃 커스터마이저 상태 (zustand) — 강조색(accent) · 밀도(density) + localStorage 지속.
|
||||||
|
* themeStore(라이트/다크)·uiStore(사이드바 접기)와 상호보완: 이 store 는 "테마·레이아웃 외" 개인화만 담당.
|
||||||
|
*
|
||||||
|
* 적용 방식(themeStore stamp 관례와 동일):
|
||||||
|
* - accent: documentElement 인라인 CSS 변수(--color-primary-600/700 + --focus-ring)를 프리셋 hex 로 override.
|
||||||
|
* 신규 색 토큰을 발명하지 않고 tokens.css 팔레트 계열을 스와핑한다. 'default'=override 제거(원본 카스케이드·다크 remap 복원).
|
||||||
|
* - density: documentElement[data-density]='comfortable'|'compact' — settings-customizer.css 가 간격/행높이 토큰을 좁힌다.
|
||||||
|
*/
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export type Density = 'comfortable' | 'compact';
|
||||||
|
export type AccentKey = 'default' | 'indigo' | 'teal' | 'violet' | 'rose' | 'amber';
|
||||||
|
|
||||||
|
const ACCENT_KEY = 'kx-accent';
|
||||||
|
const DENSITY_KEY = 'kx-density';
|
||||||
|
|
||||||
|
/** 강조색 프리셋 — 각 프리셋은 base(600)·hover(700) hex 쌍. 'default'=킨텍스 CI(override 없음). */
|
||||||
|
export interface AccentPreset {
|
||||||
|
key: AccentKey;
|
||||||
|
/** i18n 폴백 라벨(ko). */
|
||||||
|
label: string;
|
||||||
|
/** 스와치·미리보기용 대표 색(default 는 브랜드 블루). */
|
||||||
|
swatch: string;
|
||||||
|
/** override 값. null=override 제거(원본 토큰 사용). */
|
||||||
|
base: string | null;
|
||||||
|
hover: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ACCENT_PRESETS: AccentPreset[] = [
|
||||||
|
{ key: 'default', label: '브랜드 블루', swatch: '#0066b3', base: null, hover: null },
|
||||||
|
{ key: 'indigo', label: '인디고', swatch: '#4f46e5', base: '#4f46e5', hover: '#4338ca' },
|
||||||
|
{ key: 'teal', label: '틸', swatch: '#0d9488', base: '#0d9488', hover: '#0f766e' },
|
||||||
|
{ key: 'violet', label: '바이올렛', swatch: '#7c3aed', base: '#7c3aed', hover: '#6d28d9' },
|
||||||
|
{ key: 'rose', label: '로즈', swatch: '#e11d48', base: '#e11d48', hover: '#be123c' },
|
||||||
|
{ key: 'amber', label: '앰버', swatch: '#d97706', base: '#d97706', hover: '#b45309' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_ACCENT: AccentKey = 'default';
|
||||||
|
const DEFAULT_DENSITY: Density = 'comfortable';
|
||||||
|
|
||||||
|
function readAccent(): AccentKey {
|
||||||
|
if (typeof localStorage === 'undefined') return DEFAULT_ACCENT;
|
||||||
|
const v = localStorage.getItem(ACCENT_KEY);
|
||||||
|
return ACCENT_PRESETS.some((p) => p.key === v) ? (v as AccentKey) : DEFAULT_ACCENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDensity(): Density {
|
||||||
|
if (typeof localStorage === 'undefined') return DEFAULT_DENSITY;
|
||||||
|
const v = localStorage.getItem(DENSITY_KEY);
|
||||||
|
return v === 'compact' || v === 'comfortable' ? v : DEFAULT_DENSITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** accent 프리셋을 documentElement 인라인 변수로 스탬프('default'=제거). */
|
||||||
|
function stampAccent(key: AccentKey) {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
const root = document.documentElement;
|
||||||
|
const preset = ACCENT_PRESETS.find((p) => p.key === key) ?? ACCENT_PRESETS[0];
|
||||||
|
if (preset.base && preset.hover) {
|
||||||
|
root.style.setProperty('--color-primary-600', preset.base);
|
||||||
|
root.style.setProperty('--color-primary-700', preset.hover);
|
||||||
|
root.style.setProperty('--focus-ring', preset.base);
|
||||||
|
} else {
|
||||||
|
root.style.removeProperty('--color-primary-600');
|
||||||
|
root.style.removeProperty('--color-primary-700');
|
||||||
|
root.style.removeProperty('--focus-ring');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** density 를 documentElement[data-density] 로 스탬프. */
|
||||||
|
function stampDensity(density: Density) {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
document.documentElement.dataset.density = density;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomizerState {
|
||||||
|
accent: AccentKey;
|
||||||
|
density: Density;
|
||||||
|
setAccent: (key: AccentKey) => void;
|
||||||
|
setDensity: (density: Density) => void;
|
||||||
|
/** 기본값 복원(accent=default·density=comfortable). 테마·사이드바는 각 store 소관. */
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCustomizerStore = create<CustomizerState>((set) => ({
|
||||||
|
accent: readAccent(),
|
||||||
|
density: readDensity(),
|
||||||
|
|
||||||
|
setAccent: (key) => {
|
||||||
|
if (typeof localStorage !== 'undefined') localStorage.setItem(ACCENT_KEY, key);
|
||||||
|
stampAccent(key);
|
||||||
|
set({ accent: key });
|
||||||
|
},
|
||||||
|
|
||||||
|
setDensity: (density) => {
|
||||||
|
if (typeof localStorage !== 'undefined') localStorage.setItem(DENSITY_KEY, density);
|
||||||
|
stampDensity(density);
|
||||||
|
set({ density });
|
||||||
|
},
|
||||||
|
|
||||||
|
reset: () => {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem(ACCENT_KEY, DEFAULT_ACCENT);
|
||||||
|
localStorage.setItem(DENSITY_KEY, DEFAULT_DENSITY);
|
||||||
|
}
|
||||||
|
stampAccent(DEFAULT_ACCENT);
|
||||||
|
stampDensity(DEFAULT_DENSITY);
|
||||||
|
set({ accent: DEFAULT_ACCENT, density: DEFAULT_DENSITY });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── 앱 진입 즉시 스탬프(모듈 import 시점 — main.tsx 렌더 전에 로드) ──
|
||||||
|
stampAccent(useCustomizerStore.getState().accent);
|
||||||
|
stampDensity(useCustomizerStore.getState().density);
|
||||||
Loading…
Reference in New Issue
Block a user