diff --git a/src/backend/src/main/java/com/zioinfo/kintex/exhibitor/ExhibitorController.java b/src/backend/src/main/java/com/zioinfo/kintex/exhibitor/ExhibitorController.java new file mode 100644 index 0000000..7b3da0b --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/exhibitor/ExhibitorController.java @@ -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 list(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId) { + guard.requireRole(principal, eventId, EventRole.ORGANIZER, EventRole.HALL_MANAGER); + return ApiResponse.ok(service.list(eventId)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/exhibitor/ExhibitorMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/exhibitor/ExhibitorMapper.java new file mode 100644 index 0000000..8861344 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/exhibitor/ExhibitorMapper.java @@ -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> findExhibitors(@Param("eventId") String eventId); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/exhibitor/ExhibitorService.java b/src/backend/src/main/java/com/zioinfo/kintex/exhibitor/ExhibitorService.java new file mode 100644 index 0000000..6469beb --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/exhibitor/ExhibitorService.java @@ -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 rows = buildRows(mapper.findExhibitors(eventId)); + return new ExhibitorListDto(eventId, eventName, buildSummary(rows), rows); + } + + private List buildRows(List> src) { + List out = new ArrayList<>(); + if (src == null) { + return out; + } + for (Map 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 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); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/exhibitor/dto/ExhibitorListDto.java b/src/backend/src/main/java/com/zioinfo/kintex/exhibitor/dto/ExhibitorListDto.java new file mode 100644 index 0000000..35a2370 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/exhibitor/dto/ExhibitorListDto.java @@ -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 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 // 연락처(선택) + ) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/home/HomeMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/home/HomeMapper.java index e3240d8..878a8bc 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/home/HomeMapper.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/home/HomeMapper.java @@ -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> 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> 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> findLeadFollowupTodos(@Param("userId") String userId, + @Param("tenantId") String tenantId, + @Param("broad") boolean broad); + // ── 쇼케이스(진행 중·다가오는 전시) ──────────────────────────────────────────── /** diff --git a/src/backend/src/main/java/com/zioinfo/kintex/home/HomeService.java b/src/backend/src/main/java/com/zioinfo/kintex/home/HomeService.java index 5b8060f..25a9ac1 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/home/HomeService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/home/HomeService.java @@ -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> 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 items = new ArrayList<>(); for (Map 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 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"; diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 2e0418a..e45f1d7 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -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() { } /> {/* SCR-05 참가업체 홈 */} } /> + {/* #41 주최자용 참가업체 관리(전용 화면 — 대시보드 폴백 해소) */} + } /> {/* SCR-03 부스 배치 에디터 (M2) */} 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(() => activeGroupId(location.pathname)); + // 설정 아이콘 → 우측 레이아웃 커스터마이저(#43) offcanvas 열림. + const [customizerOpen, setCustomizerOpen] = useState(false); + /** * 라우트 → MDI 탭 동기화(§2.7) + 활성 그룹 자동 펼침. * 화면 이동 시 활성 대분류를 펼친다(라우트=단일 출처). 비메뉴 경로는 현 상태 유지. @@ -487,9 +490,11 @@ export function AppShell() { @@ -505,6 +510,9 @@ export function AppShell() { {/* 전 셸 화면 공통 하단 상태바 (design.md §2-2) */} + + {/* 설정 아이콘 → 우측 레이아웃 커스터마이저(테마·레이아웃·강조색, #43) */} + setCustomizerOpen(false)} /> ); } diff --git a/src/frontend/src/components/layout/SettingsCustomizer.tsx b/src/frontend/src/components/layout/SettingsCustomizer.tsx new file mode 100644 index 0000000..62f3ea0 --- /dev/null +++ b/src/frontend/src/components/layout/SettingsCustomizer.tsx @@ -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 = { + 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 ( + + {t('customizer.reset', { defaultValue: '기본값으로 복원' })} + + } + > +

+ {t('customizer.lead', { defaultValue: '테마·레이아웃·강조색을 개인화합니다. 이 기기에 저장됩니다.' })} +

+ + {/* ── 테마 ── */} +
+

{t('customizer.theme.title', { defaultValue: '테마' })}

+
+ {themeOptions.map((opt) => { + const active = mode === opt.value; + return ( + + ); + })} +
+
+ + {/* ── 레이아웃 ── */} +
+

{t('customizer.layout.title', { defaultValue: '레이아웃' })}

+ + {/* 사이드바 접기/펼치기 */} +
+
+ {t('customizer.layout.sidebar', { defaultValue: '사이드바' })} + + {navCollapsed + ? t('customizer.layout.sidebarCollapsed', { defaultValue: '접힘 — 콘텐츠 전폭' }) + : t('customizer.layout.sidebarExpanded', { defaultValue: '펼침 — 메뉴 표시' })} + +
+ +
+ + {/* 밀도 */} +
+ {t('customizer.density.title', { defaultValue: '밀도' })} +
+ {densityOptions.map((opt) => { + const active = density === opt.value; + return ( + + ); + })} +
+
+
+ + {/* ── 강조색 ── */} +
+

{t('customizer.accent.title', { defaultValue: '강조색' })}

+
+ {ACCENT_PRESETS.map((preset) => { + const active = accent === preset.key; + const label = t(ACCENT_LABEL_KEY[preset.key], { defaultValue: preset.label }); + return ( + + ); + })} +
+
+
+ ); +} diff --git a/src/frontend/src/components/layout/settings-customizer.css b/src/frontend/src/components/layout/settings-customizer.css new file mode 100644 index 0000000..682fb27 --- /dev/null +++ b/src/frontend/src/components/layout/settings-customizer.css @@ -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; +} diff --git a/src/frontend/src/i18n/locales/en.json b/src/frontend/src/i18n/locales/en.json index e2ef249..ed28b40 100644 --- a/src/frontend/src/i18n/locales/en.json +++ b/src/frontend/src/i18n/locales/en.json @@ -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" + } } -} +} \ No newline at end of file diff --git a/src/frontend/src/i18n/locales/ja.json b/src/frontend/src/i18n/locales/ja.json index 7e6808a..22b084c 100644 --- a/src/frontend/src/i18n/locales/ja.json +++ b/src/frontend/src/i18n/locales/ja.json @@ -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": "アイランドブース" + } } -} +} \ No newline at end of file diff --git a/src/frontend/src/i18n/locales/ko.json b/src/frontend/src/i18n/locales/ko.json index c9ca5e8..7cdcff2 100644 --- a/src/frontend/src/i18n/locales/ko.json +++ b/src/frontend/src/i18n/locales/ko.json @@ -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": "아일랜드부스" + } } -} +} \ No newline at end of file diff --git a/src/frontend/src/i18n/locales/zh.json b/src/frontend/src/i18n/locales/zh.json index 8e13c43..f587c08 100644 --- a/src/frontend/src/i18n/locales/zh.json +++ b/src/frontend/src/i18n/locales/zh.json @@ -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": "岛式展位" + } } -} +} \ No newline at end of file diff --git a/src/frontend/src/screens/appqr/AppQrCodePage.tsx b/src/frontend/src/screens/appqr/AppQrCodePage.tsx index ba7c03d..daa9798 100644 --- a/src/frontend/src/screens/appqr/AppQrCodePage.tsx +++ b/src/frontend/src/screens/appqr/AppQrCodePage.tsx @@ -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: }, + { key: 'inspection', icon: }, + { key: 'gallery', icon: }, + { key: 'approval', icon: }, + { key: 'notify', icon: }, + { key: 'offline', icon: }, +]; + +/** 미리보기 목업 프레임 — 실제 스크린샷 부재 시 아이콘 플레이스홀더(있으면 이미지로 교체). */ +const PREVIEWS: Feature[] = [ + { key: 's1', icon: }, + { key: 's2', icon: }, + { key: 's3', icon: }, +]; + export function AppQrCodePage() { const { t } = useTranslation(); const [phase, setPhase] = useState('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 (
-
-
+ {/* ── ① 앱 소개 히어로 ── */} +
+
+ {t('appqr.eyebrow')}

{t('appqr.title')}

{t('appqr.subtitle')}

-
-
+

{t('appqr.intro')}

-

{t('appqr.intro')}

- - {phase === 'loading' ? ( -
- -
- - - -
-
- ) : phase === 'unavailable' ? ( -
- } - title={t('appqr.notReady.title')} - description={t('appqr.notReady.desc')} - action={ - - } - /> -
- ) : ( -
-
- {/* 다운로드 URL을 자체 인코딩한 QR(외부 API 0). */} - -
- -
-
- - {t('appqr.appName')} - {version ? v{version} : null} -
- -

{metaParts.join(' · ')}

- {builtAt ? ( -

{t('appqr.builtAt', { date: builtAt })}

+
+ {t('appqr.platformChip')} + {version ? v{version} : null} + {sizeLabel ? ( + {t('appqr.about', { size: sizeLabel })} ) : null} - -
- - - {t('appqr.download')} - - - -
- -

{t('appqr.iosNote')}

-
- )} -
-

{t('appqr.steps.heading')}

-
    - {steps.map((s, i) => ( -
  1. -
+ + {/* 히어로 QR 슬롯 — phase별 로딩/미가용/QR */} +
+ {phase === 'loading' ? ( +
+ + +
+ ) : phase === 'unavailable' ? ( +
+ } + title={t('appqr.notReady.title')} + description={t('appqr.notReady.desc')} + action={ + + } + /> +
+ ) : ( +
+
+ {/* 다운로드 URL을 자체 인코딩한 QR(외부 API 0). */} + +
+
+ + {t('appqr.appName')} +
+

{t('appqr.qrHint')}

+
+ )} +
+ + + {/* ── ② 핵심 기능 카드 ── */} +
+

{t('appqr.features.heading')}

+

{t('appqr.features.sub')}

+
+ {FEATURES.map((f) => ( +
+ - {s} - +
+

+ {t(`appqr.features.${f.key}.title`)} +

+

+ {t(`appqr.features.${f.key}.desc`)} +

+
+
))} - +
+
+ + {/* ── ③ 스크린샷/목업 미리보기 ── */} +
+

{t('appqr.preview.heading')}

+

{t('appqr.preview.sub')}

+
+ {PREVIEWS.map((p) => ( +
+ +
+ {t(`appqr.preview.${p.key}`)} +
+
+ ))} +
+
+ + {/* ── ④ 설치 안내 + ⑤ 요구사항/스토어 배지 ── */} +
+
+

{t('appqr.steps.heading')}

+
    + {steps.map((s, i) => ( +
  1. + + {s} +
  2. + ))} +
+
+ +
+

{t('appqr.requirements.heading')}

+
    + {requirements.map((r, i) => ( +
  • + + {r} +
  • + ))} +
+ +
+
+ +
+ {t('appqr.store.getOn')} + {t('appqr.store.android')} +
+
+
+ +
+ {t('appqr.store.comingSoon')} + {t('appqr.store.ios')} +
+
+
+

{t('appqr.iosNote')}

+
); diff --git a/src/frontend/src/screens/appqr/appqr.css b/src/frontend/src/screens/appqr/appqr.css index a431f15..62eb3f8 100644 --- a/src/frontend/src/screens/appqr/appqr.css +++ b/src/frontend/src/screens/appqr/appqr.css @@ -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); -} -/* 다운로드는 이지만 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; +} diff --git a/src/frontend/src/screens/approval/ReviewDetailsPage.tsx b/src/frontend/src/screens/approval/ReviewDetailsPage.tsx index d9636b9..a871e6c 100644 --- a/src/frontend/src/screens/approval/ReviewDetailsPage.tsx +++ b/src/frontend/src/screens/approval/ReviewDetailsPage.tsx @@ -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() { {d ? d.title : `부스 ${booth}`}
-

{d ? d.title : t('approval.reviewTitle')}

+

{d ? d.title : t('approval.reviewTitle')}

{d ? ( - <> - - {t('approval.version')}: v{d.version} - - - {t('approval.drafter')}: {d.drafterName ?? d.drafterId} - - {statusLabel} - - {t('approval.step')} {d.curStep}/{d.totalSteps} - - + {statusLabel} ) : ( - {t('approval.demoMode')} + + {t('approval.demoMode')} + )}
+ {d ? ( +
+ + {t('approval.version')} + v{d.version} + + + {t('approval.drafter')} + {d.drafterName ?? d.drafterId} + + + {t('approval.step')} + + {d.curStep}/{d.totalSteps} + + +
+ ) : null} + + {detail.title} + +
{!closed && ( )} -

- {detail.title} -

- {detail.type} - 라운드 {detail.round} -
-
-
- 상태 - - {detail.status === '진행중' ? '활성 응찰 중' : detail.status} - -
-
- 현재 최저가 - - {detail.lowestPrice != null ? formatWon(detail.lowestPrice) : '—'} - +

{detail.title}

+ + {detail.status === '진행중' ? '활성 응찰 중' : detail.status} + +
+
+ 현재 최저가 + + {detail.lowestPrice != null ? formatWon(detail.lowestPrice) : '—'} + +
-
+
+ + 유형 + {detail.type} + + + 라운드 + {detail.round} + +
+
{/* 좌 — AI 자료 뷰어 */} diff --git a/src/frontend/src/screens/exhibitors/ExhibitorsPage.tsx b/src/frontend/src/screens/exhibitors/ExhibitorsPage.tsx new file mode 100644 index 0000000..06d2a44 --- /dev/null +++ b/src/frontend/src/screens/exhibitors/ExhibitorsPage.tsx @@ -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(''); + const [selected, setSelected] = useState(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 ( +
+ + +
+ ); + } + + const summary = q.data?.summary; + + return ( +
+ + + {/* 요약 밴드 */} + {q.isLoading ? ( + + ) : summary ? ( +
+ } label={t('exhibitorsPage.kpiTotal')} value={String(summary.total)} tone="info" /> + } label={t('exhibitorsPage.kpiContracted')} value={String(summary.contracted)} tone="ok" /> + } label={t('exhibitorsPage.kpiDesignApproved')} value={String(summary.designApproved)} tone="ok" /> + } + label={t('exhibitorsPage.kpiOutstanding')} + value={String(summary.outstandingCount)} + tone={summary.outstandingCount > 0 ? 'error' : 'muted'} + /> +
+ ) : null} + + {/* 목록 카드 */} +
+
+
+ setKeyword(e.target.value)} + aria-label={t('exhibitorsPage.searchPlaceholder')} + /> +
+
+ {SALE_FILTERS.map((v) => ( + + ))} +
+
+ + {q.isLoading ? ( + + ) : q.isError ? ( + q.refetch()} /> + ) : rows.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + + + {rows.map((r) => ( + setSelected(r)}> + + + + + + + + + + ))} + +
{t('exhibitorsPage.colCompany')}{t('exhibitorsPage.colBooth')}{t('exhibitorsPage.colType')}{t('exhibitorsPage.colArea')}{t('exhibitorsPage.colSale')}{t('exhibitorsPage.colDesign')}{t('exhibitorsPage.colSettlement')}{t('exhibitorsPage.colContact')}
{r.companyName}{r.boothNo ?? '-'}{r.boothType ? t(`exhibitorsPage.boothType.${r.boothType}`, { defaultValue: r.boothType }) : '-'}{fmtArea(r.areaM2)} + + {r.outstanding > 0 && ( + {t('exhibitorsPage.dueShort', { won: fmtWon(r.outstanding) })} + )} + {r.contactName ?? r.contactPhone ?? {t('exhibitorsPage.contactNone')}}
+
+ )} + {!q.isLoading && !q.isError && rows.length > 0 && ( +

{t('exhibitorsPage.count', { n: rows.length })}

+ )} +
+ + {/* 상세 드로어(부스·서류·연락) */} + setSelected(null)} + title={selected?.companyName ?? t('exhibitorsPage.detailTitle')} + width={420} + > + {selected && } + +
+ ); +} + +function ExhibitorDetail({ t, row }: { t: TFunction; row: ExhibitorRow }) { + return ( +
+
+ + + +
+ +

{t('exhibitorsPage.statusSection')}

+
+ } /> + } /> + } /> + {t('exhibitorsPage.won', { won: fmtWon(row.outstanding) })}} + /> +
+ +

{t('exhibitorsPage.contactSection')}

+
+ + +
+
+ ); +} + +function DetailRow({ k, v }: { k: string; v: React.ReactNode }) { + return ( +
+
{k}
+
{v}
+
+ ); +} + +function Pill({ tone, label }: { tone: string; label: string }) { + return {label}; +} + +function KpiCard({ + icon, + label, + value, + tone, +}: { + icon: React.ReactNode; + label: string; + value: string; + tone: 'info' | 'ok' | 'error' | 'muted'; +}) { + return ( +
+ + {icon} + {label} + + {value} +
+ ); +} + +function PageHeader({ t, eventName }: { t: TFunction; eventName?: string }) { + return ( +
+
+

{t('exhibitorsPage.title')}

+

{eventName ? `${eventName} · ${t('exhibitorsPage.subtitle')}` : t('exhibitorsPage.subtitle')}

+
+
+ ); +} diff --git a/src/frontend/src/screens/exhibitors/exhibitors.css b/src/frontend/src/screens/exhibitors/exhibitors.css new file mode 100644 index 0000000..890e73f --- /dev/null +++ b/src/frontend/src/screens/exhibitors/exhibitors.css @@ -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; + } +} diff --git a/src/frontend/src/screens/exhibitors/exhibitorsApi.ts b/src/frontend/src/screens/exhibitors/exhibitorsApi.ts new file mode 100644 index 0000000..6543043 --- /dev/null +++ b/src/frontend/src/screens/exhibitors/exhibitorsApi.ts @@ -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(`/api/events/${encodeURIComponent(eventId)}/exhibitors`), +}; diff --git a/src/frontend/src/screens/home/HomePage.tsx b/src/frontend/src/screens/home/HomePage.tsx index 2944a89..384e972 100644 --- a/src/frontend/src/screens/home/HomePage.tsx +++ b/src/frontend/src/screens/home/HomePage.tsx @@ -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): 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 = { + approval: '결재', + document: '서류', + milestone: '마일스톤', + auction: '옥션', + settlement: '정산', + checkin: '체크인', + lead: '리드', + system: '시스템', +}; +function todoTypeLabel(type: string, t: (k: string, o?: Record) => 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() { >