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:
zio 2026-07-12 23:36:02 +09:00
parent 55a3114141
commit a5a78f1ea1
27 changed files with 2829 additions and 213 deletions

View File

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

View File

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

View File

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

View File

@ -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 // 연락처(선택)
) {
}
}

View File

@ -138,6 +138,91 @@ public interface HomeMapper {
@Param("tenantId") String tenantId,
@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): 고득점(score80) 리드로 팔로업이 필요한 . 내가 참가업체(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);
// 쇼케이스(진행 ·다가오는 전시)
/**

View File

@ -43,29 +43,38 @@ public class HomeService {
boolean broad = isBroadOversight(principal);
String horizon = LocalDate.now().plusDays(DOC_HORIZON_DAYS).toString();
// 원천별 자기선별( 서브쿼리가 event_member 멤버십·role_code 스스로 역할 스코프됨 역할별 필터는
// 여기서 하드 게이트하지 않고 원천 스코프에 위임한다: CONTRACTOR 정산/서류 미노출, EXHIBITOR 옥션낙찰
// 미노출 . 다중 역할 사용자도 해당 행사 역할에 맞는 항목만 병합된다). broad(홀매니저·관리자) 테넌트.
List<Map<String, Object>> rows = new ArrayList<>();
rows.addAll(mapper.findApprovalTodos(userId));
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.findAuctionBidTodos(userId, tenantId));
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<>();
for (Map<String, Object> r : rows) {
String type = str(r.get("type"));
Integer dday = toInt(r.get("dday"));
items.add(new TodoItem(
str(r.get("type")),
type,
str(r.get("title")),
str(r.get("target")),
str(r.get("dueDate")),
dday,
severity(dday),
severityFor(type, dday),
str(r.get("route"))));
}
// 임박순: dday 오름차순(경과·임박 우선), 기한 없는 결재는 후순위.
items.sort(Comparator.comparing(
(TodoItem t) -> t.dday() == null ? Integer.MAX_VALUE : t.dday()));
// 마감 임박 우선순위(2단): 심각도 랭크(경과 임박 일반) 잔여일 오름차순(기한 없는 건은 그룹 후순위).
// 이로써 경과·D-3 이내 항목이 항상 상단으로 부각되고(프론트가 강조색 렌더), 핫리드·체크인 준비도 임박 그룹에서 노출된다.
items.sort(Comparator
.comparingInt((TodoItem t) -> severityRank(t.severity()))
.thenComparingInt(t -> t.dday() == null ? Integer.MAX_VALUE : t.dday()));
int total = items.size();
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);
}
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) {
return "normal";
return "lead".equals(type) ? "warning" : "normal";
}
if (dday < 0) {
return "overdue";
@ -194,6 +207,15 @@ public class HomeService {
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) {
if (tab == null) {
return "ongoing";

View File

@ -7,6 +7,7 @@ import { BoothLayoutEditorPage } from './screens/floorplan/BoothLayoutEditorPage
import { LayoutComparisonPage } from './screens/floorplan/LayoutComparisonPage';
import { OrganizerDashboardPage } from './screens/dashboard/OrganizerDashboardPage';
import { ExhibitorHomePage } from './screens/exhibitor/ExhibitorHomePage';
import { ExhibitorsPage } from './screens/exhibitors/ExhibitorsPage';
import { BoothDesignStudioPage } from './screens/design/BoothDesignStudioPage';
import { UtilityWiringPage } from './screens/utility/UtilityWiringPage';
import { UtilityOrderSummaryPage } from './screens/utility/UtilityOrderSummaryPage';
@ -139,6 +140,8 @@ export function App() {
<Route path="/events/:eventId/dashboard" element={<OrganizerDashboardPage />} />
{/* SCR-05 참가업체 홈 */}
<Route path="/events/:eventId/booths/:boothId/home" element={<ExhibitorHomePage />} />
{/* #41 주최자용 참가업체 관리(전용 화면 — 대시보드 폴백 해소) */}
<Route path="/exhibitors" element={<ExhibitorsPage />} />
{/* SCR-03 부스 배치 에디터 (M2) */}
<Route
path="/events/:eventId/halls/:hallId/layout"

View File

@ -10,6 +10,7 @@ import { useUiStore, isMobileViewport } from '../../store/uiStore';
import { useIsAdmin } from '../../screens/admin/AdminGuard';
import { MdiTabBar } from './MdiTabBar';
import { ShellFooter } from './ShellFooter';
import { SettingsCustomizer } from './SettingsCustomizer';
import { labelForPath } from './mdiLabels';
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
import { DdayChip } from '../ui/Badge';
@ -97,7 +98,7 @@ const GROUPS: NavGroupDef[] = [
tracks: BIZ_OPS_ADMIN, // agency·visitor ✕
items: [
{ 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: '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' },
@ -209,12 +210,11 @@ const GROUPS: NavGroupDef[] = [
/**
* (App.tsx ).
* hallLabel (: "제2전시장 홀7" H7), 'H7' .
* ( ).
* (#41: 참가업체 `/exhibitors` .)
*/
const EVENT_SCOPED_PATH: Record<string, (eventId: string, hallId: string) => string> = {
dashboard: (id) => `/events/${id}/dashboard`,
floorplan: (id, hallId) => `/events/${id}/halls/${hallId}/layout`,
exhibitors: (id) => `/events/${id}/dashboard`,
};
/** hallLabel("제2전시장 홀7"·"홀 10" 등) → 홀 id("H7"). 파싱 불가 시 기본 H7. */
@ -263,6 +263,9 @@ export function AppShell() {
// 단일-열림 아코디언(소유자 지시 ②) — 한 번에 대분류 하나만 펼침. WISE LeftNav 동작 그대로.
const [openGroup, setOpenGroup] = useState<string | null>(() => activeGroupId(location.pathname));
// 설정 아이콘 → 우측 레이아웃 커스터마이저(#43) offcanvas 열림.
const [customizerOpen, setCustomizerOpen] = useState(false);
/**
* MDI (§2.7) + .
* (= ). .
@ -487,9 +490,11 @@ export function AppShell() {
<button
className="kx-shell__icon-btn"
type="button"
aria-label="설정"
title="설정"
onClick={() => navigate(isAdmin ? '/admin/settings' : '/me')}
aria-label="화면 설정"
title="화면 설정"
aria-haspopup="dialog"
aria-expanded={customizerOpen}
onClick={() => setCustomizerOpen(true)}
>
<IconSettings size={20} />
</button>
@ -505,6 +510,9 @@ export function AppShell() {
{/* 전 셸 화면 공통 하단 상태바 (design.md §2-2) */}
<ShellFooter />
</div>
{/* 설정 아이콘 → 우측 레이아웃 커스터마이저(테마·레이아웃·강조색, #43) */}
<SettingsCustomizer open={customizerOpen} onClose={() => setCustomizerOpen(false)} />
</div>
);
}

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

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

View File

@ -1927,6 +1927,56 @@
"s2": "Open the link shown and download the APK.",
"s3": "Allow \"Install unknown apps\".",
"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": {
@ -2544,5 +2594,112 @@
"ctaBandAria": "Vendor log in",
"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"
}
}
}

View File

@ -1927,6 +1927,56 @@
"s2": "表示されたリンクを開いてAPKをダウンロードします。",
"s3": "「提供元不明のアプリのインストール」を許可します。",
"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": {
@ -2544,5 +2594,112 @@
"ctaBandAria": "登録業者ログイン",
"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": "アイランドブース"
}
}
}

View File

@ -1927,6 +1927,56 @@
"s2": "표시된 링크를 열어 APK를 다운로드합니다.",
"s3": "'출처를 알 수 없는 앱 설치'를 허용합니다.",
"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": {
@ -2544,5 +2594,112 @@
"ctaBandAria": "등록업체 로그인",
"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": "아일랜드부스"
}
}
}

View File

@ -1927,6 +1927,56 @@
"s2": "打开显示的链接并下载 APK。",
"s3": "允许\"安装未知来源应用\"。",
"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": {
@ -2544,5 +2594,112 @@
"ctaBandAria": "注册企业登录",
"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": "岛式展位"
}
}
}

View File

@ -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 ).
*
* (WISE ): (··QR/)
* / + (Android APK / iOS ).
*
* APK 호스팅: 다운로드 URL = /downloads/kintex.apk (nginx , ).
* = /downloads/kintex-app.json ({ version, builtAt, size }).
* - 200: QR + + / + .
* - 404/: "앱 빌드 준비 중" APK가 (EAS G3 ) .
* - 200: 히어로에 QR + + / .
* - 404/실패: 히어로 QR "앱 빌드 준비 중" (EAS G3 ) / .
* 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 AppQrCode from './AppQrCode';
import { Button } from '../../components/ui/Button';
@ -19,6 +22,11 @@ import {
IconOperations,
IconGrid,
IconWifiOff,
IconCheckCircle,
IconShieldCheck,
IconImage,
IconDocument,
IconBell,
} from '../../components/ui/icons';
import './appqr.css';
@ -51,6 +59,27 @@ function formatSize(size?: number | string): string | null {
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() {
const { t } = useTranslation();
const [phase, setPhase] = useState<Phase>('loading');
@ -94,8 +123,6 @@ export function AppQrCodePage() {
const version = manifest?.version ?? null;
const builtAt = manifest?.builtAt ?? null;
const sizeLabel = formatSize(manifest?.size);
const metaParts = ['Android', 'APK'];
if (sizeLabel) metaParts.push(t('appqr.about', { size: sizeLabel }));
const steps = [
t('appqr.steps.s1'),
@ -104,98 +131,189 @@ export function AppQrCodePage() {
t('appqr.steps.s4'),
];
const requirements = [
t('appqr.requirements.os'),
t('appqr.requirements.space'),
t('appqr.requirements.network'),
];
return (
<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>
<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-card kx-appqr__card">
<Skeleton height={200} width={200} radius={8} />
<div className="kx-appqr__body">
<Skeleton height={22} width={220} />
<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>
<div className="kx-appqr__chips">
<span className="kx-appqr__chip">{t('appqr.platformChip')}</span>
{version ? <span className="kx-appqr__chip">v{version}</span> : null}
{sizeLabel ? (
<span className="kx-appqr__chip">{t('appqr.about', { size: sizeLabel })}</span>
) : null}
<div className="kx-appqr__actions">
<a className="kx-btn kx-btn--primary" href={APK_URL} download="kintex.apk">
<span className="kx-btn__icon" aria-hidden="true">
<IconDownload size={15} />
</span>
{t('appqr.download')}
</a>
<Button
variant="secondary"
onClick={copyLink}
leadingIcon={copied ? <IconCheck size={15} /> : undefined}
>
{copied ? t('appqr.copied') : t('appqr.copyLink')}
</Button>
<Button
variant="ghost"
onClick={() => void load()}
leadingIcon={<IconOperations size={15} />}
>
{t('appqr.refresh')}
</Button>
</div>
<p className="kx-appqr__ios">{t('appqr.iosNote')}</p>
</div>
</div>
)}
<section className="kx-appqr__steps-section" aria-label={t('appqr.steps.heading')}>
<h2 className="kx-appqr__steps-heading">{t('appqr.steps.heading')}</h2>
<ol className="kx-appqr__steps">
{steps.map((s, i) => (
<li key={i} className="kx-appqr__step">
<span className="kx-appqr__step-num" aria-hidden="true">
{i + 1}
{phase === 'ready' ? (
<>
<div className="kx-appqr__actions">
<a className="kx-btn kx-btn--primary" href={APK_URL} download="kintex.apk">
<span className="kx-btn__icon" aria-hidden="true">
<IconDownload size={15} />
</span>
{t('appqr.download')}
</a>
<Button
variant="secondary"
onClick={copyLink}
leadingIcon={copied ? <IconCheck size={15} /> : undefined}
>
{copied ? t('appqr.copied') : t('appqr.copyLink')}
</Button>
<Button
variant="ghost"
onClick={() => void load()}
leadingIcon={<IconOperations size={15} />}
>
{t('appqr.refresh')}
</Button>
</div>
{builtAt ? (
<p className="kx-appqr__meta">{t('appqr.builtAt', { date: builtAt })}</p>
) : null}
</>
) : null}
</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>
</section>
{/* ── ② 핵심 기능 카드 ── */}
<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>
<span className="kx-appqr__step-text">{s}</span>
</li>
<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>
))}
</ol>
</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">
{steps.map((s, i) => (
<li key={i} className="kx-appqr__step">
<span className="kx-appqr__step-num" aria-hidden="true">
{i + 1}
</span>
<span className="kx-appqr__step-text">{s}</span>
</li>
))}
</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>
</main>
);

View File

@ -1,52 +1,110 @@
/*
* SCR-APPQR 모바일 설치 QR 페이지 전용 스타일.
* SCR-APPQR 모바일 다운로드 랜딩 전용 스타일.
* design.md §1 토큰만 참조. shared.css 프리미티브(kx-page·kx-card) 위에 얹는다.
* 정본: WISE(UIWS) pages/MobileApp.css 구조 이식(토큰만 kintex로 치환).
* 정본: WISE(UIWS) 모바일앱 소개 패턴 이식(토큰만 kintex로 치환). 이모지 0· SVG만.
*/
@import '../shared.css';
.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;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
flex-direction: column;
gap: var(--space-2);
}
.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 {
font-size: var(--fs-h2);
font-size: var(--fs-h1);
line-height: 1.2;
color: var(--color-neutral-900);
margin: var(--space-1) 0 0;
}
.kx-appqr__subtitle {
margin-top: 2px;
font-size: var(--fs-body);
color: var(--color-neutral-500);
font-size: var(--fs-h3);
font-weight: var(--fw-regular);
color: var(--color-neutral-700);
margin: 0;
}
.kx-appqr__intro {
font-size: var(--fs-body);
color: var(--color-neutral-500);
line-height: 1.6;
margin: 0;
max-width: 720px;
margin: var(--space-1) 0 0;
max-width: 560px;
}
/* ── QR + 정보 카드 ── */
.kx-appqr__card {
display: grid;
grid-template-columns: 232px 1fr;
gap: var(--space-5);
align-items: start;
.kx-appqr__chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-2);
}
@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 {
background: var(--color-white);
border: var(--border-card);
@ -62,12 +120,6 @@
height: auto;
display: block;
}
.kx-appqr__body {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.kx-appqr__name-row {
display: flex;
align-items: center;
@ -75,62 +127,146 @@
color: var(--color-neutral-700);
}
.kx-appqr__name {
font-size: var(--fs-h3);
font-size: var(--fs-body);
font-weight: var(--fw-bold);
color: var(--color-neutral-900);
}
.kx-appqr__ver {
.kx-appqr__qr-hint {
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);
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);
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;
flex-wrap: wrap;
gap: var(--space-2);
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);
flex-direction: column;
gap: var(--space-3);
}
.kx-appqr__steps {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.kx-appqr__step {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-4);
padding: var(--space-3) var(--space-4);
border: var(--border-card);
border-radius: var(--radius-lg);
background: var(--color-white);
@ -152,3 +288,81 @@
color: var(--color-neutral-700);
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;
}

View File

@ -14,7 +14,7 @@ import {
IconPlus,
IconSpark,
} from '../../components/ui/icons';
import { approvalApi, type ApprovalLine } from './approvalApi';
import { approvalApi, type ApprovalLine, type ApprovalStatus } from './approvalApi';
import './review-details.css';
import './approval-form.css';
@ -32,6 +32,21 @@ const TABS = ['도면', '예상 사진 (참고용)', '버전 비교'];
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 {
switch (s) {
case 'CURRENT':
@ -111,24 +126,33 @@ export function ReviewDetailsPage() {
<span className="kx-rev__crumb-cur">{d ? d.title : `부스 ${booth}`}</span>
</nav>
<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 ? (
<>
<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-detailview__badge ${statusTone(d.status)}`}>{statusLabel}</span>
) : (
<span className="kx-rev__meta">{t('approval.demoMode')}</span>
<span className="kx-detailview__badge kx-detailview__badge--info">
{t('approval.demoMode')}
</span>
)}
</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>
<Button variant="ghost" leadingIcon={<IconDownload size={16} />}>
{t('approval.downloadOriginal')}

View File

@ -1,3 +1,6 @@
/* shared.css 프리미티브(kx-detailview 상세 뷰 hero·meta·badge 등) 선반영 */
@import '../shared.css';
.kx-rev {
display: flex;
flex-direction: column;

View File

@ -137,38 +137,50 @@ export function AuctionDetailPage() {
return (
<div className="kx-page kx-auc">
{/* 상단 컨텍스트 바 */}
<div className="kx-aucd__bar">
<div className="kx-aucd__bar-left">
{/* 상단 히어로 — Nifty blog 상세 헤더(브레드크럼·제목·상태·메타) */}
<header className="kx-detailview__hero">
<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 && (
<span className="kx-live">
<span className="kx-live__dot" aria-hidden="true" />
</span>
)}
<h1 className="kx-auc__title" style={{ fontSize: 'var(--fs-h2)' }}>
{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
className={`kx-aucd__metric-value ${!closed ? 'kx-aucd__metric-value--ok' : ''}`}
>
{detail.status === '진행중' ? '활성 응찰 중' : detail.status}
</span>
</div>
<div className="kx-aucd__metric">
<span className="kx-aucd__metric-label"> </span>
<span className="kx-aucd__metric-value tnum">
{detail.lowestPrice != null ? formatWon(detail.lowestPrice) : '—'}
</span>
<h1 className="kx-detailview__title">{detail.title}</h1>
<span
className={`kx-detailview__badge ${
closed ? 'kx-detailview__badge--info' : 'kx-detailview__badge--ok'
}`}
>
{detail.status === '진행중' ? '활성 응찰 중' : detail.status}
</span>
<div className="kx-detailview__hero-aside">
<div className="kx-aucd__metric">
<span className="kx-aucd__metric-label"> </span>
<span className="kx-aucd__metric-value tnum">
{detail.lowestPrice != null ? formatWon(detail.lowestPrice) : '—'}
</span>
</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">
{/* 좌 — AI 자료 뷰어 */}

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

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

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

View File

@ -881,10 +881,29 @@ function todoSeverityDot(sev: HomeTodoItem['severity']): string {
return sev === 'overdue' ? 'is-ended' : sev === 'warning' ? 'is-upcoming' : 'is-ongoing';
}
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)}`;
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() {
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__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>
<span className={`kx-home__todo-due tnum is-${item.severity}`}>

View File

@ -768,6 +768,28 @@
font-size: var(--fs-body);
color: var(--color-neutral-900);
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 {
font-size: var(--fs-caption);

View File

@ -8,8 +8,16 @@
*/
import { api } from '../../api/client';
// ── 1) GET /api/home/todos — 내 할 일(역할 기반) ──
export type TodoType = 'approval' | 'document' | 'auction' | 'settlement' | 'system';
// ── 1) GET /api/home/todos — 내 할 일(역할 기반·크로스도메인 자동 집계) ──
export type TodoType =
| 'approval'
| 'document'
| 'milestone'
| 'auction'
| 'settlement'
| 'checkin'
| 'lead'
| 'system';
export type TodoSeverity = 'normal' | 'warning' | 'overdue';
export interface HomeTodoItem {

View File

@ -338,6 +338,187 @@
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 {
display: inline-block;

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