Compare commits
3 Commits
ecc00f8739
...
3c8058546e
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c8058546e | |||
| a3fdb62fc6 | |||
| 3860e7e88a |
@ -1,4 +1,6 @@
|
|||||||
|
import React from 'react';
|
||||||
import { Navigate, Outlet, Route, Routes, useNavigate } from 'react-router-dom';
|
import { Navigate, Outlet, Route, Routes, useNavigate } from 'react-router-dom';
|
||||||
|
import { authApi } from './api/endpoints';
|
||||||
import { useAuthStore } from './store/authStore';
|
import { useAuthStore } from './store/authStore';
|
||||||
import { landingPathFor } from './lib/roleTrack';
|
import { landingPathFor } from './lib/roleTrack';
|
||||||
import { LoginPage } from './screens/login/LoginPage';
|
import { LoginPage } from './screens/login/LoginPage';
|
||||||
@ -95,10 +97,45 @@ function DocsMilestoneRoute() {
|
|||||||
return <DocsMilestonePage onOpenAuthoring={() => navigate('/docs/authoring')} />;
|
return <DocsMilestonePage onOpenAuthoring={() => navigate('/docs/authoring')} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 인증 가드 — 미인증 시 로그인으로. (역할별 라우팅은 화면 추가 시 확장) */
|
/**
|
||||||
|
* 인증 가드 — 미인증 시 로그인으로. (역할별 라우팅은 화면 추가 시 확장)
|
||||||
|
* 새로고침 재수화: 토큰(sessionStorage)은 살아있는데 스토어가 빈 상태(user=null·workspaces=[])면
|
||||||
|
* /api/auth/me + /api/auth/workspaces 로 복원한다 — 없으면 행사스코프 화면 전체가
|
||||||
|
* "행사를 선택해 주세요"로 깨진다(2026-07-14 소유자 제보). 401이면 토큰 무효 → 로그아웃.
|
||||||
|
*/
|
||||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const applySession = useAuthStore((s) => s.applySession);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
const needsHydration = isAuthenticated && user == null;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!needsHydration) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void Promise.all([authApi.me(), authApi.workspaces()])
|
||||||
|
.then(([me, workspaces]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
applySession(
|
||||||
|
{
|
||||||
|
userId: me.userId,
|
||||||
|
displayName: me.displayName,
|
||||||
|
hallManager: me.hallManager,
|
||||||
|
roleCode: me.roleCode ?? null,
|
||||||
|
},
|
||||||
|
workspaces,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) logout();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [needsHydration, applySession, logout]);
|
||||||
|
|
||||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||||
|
if (needsHydration) return null; // 복원 완료까지 렌더 보류(빈 workspaces로 화면 오판 방지)
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
|
/* 자신이 flex/grid 자식일 때 부모 폭 초과로 셸을 밀지 않도록 축소 허용. */
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 툴바(검색 · 외부 필터 · CSV) ── */
|
/* ── 툴바(검색 · 외부 필터 · CSV) ── */
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
@ -22,6 +22,7 @@ import {
|
|||||||
IconCalendar,
|
IconCalendar,
|
||||||
IconCheckCircle,
|
IconCheckCircle,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
|
IconClose,
|
||||||
IconDashboard,
|
IconDashboard,
|
||||||
IconDocument,
|
IconDocument,
|
||||||
IconExhibitors,
|
IconExhibitors,
|
||||||
@ -32,6 +33,7 @@ import {
|
|||||||
IconMenu,
|
IconMenu,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
IconOperations,
|
IconOperations,
|
||||||
|
IconSearch,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconSettlement,
|
IconSettlement,
|
||||||
IconSun,
|
IconSun,
|
||||||
@ -225,6 +227,53 @@ function hallIdFromLabel(label: string | null | undefined): string {
|
|||||||
return m ? `H${m[1]}` : 'H7';
|
return m ? `H${m[1]}` : 'H7';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴 검색(WISE NavSearch/filterMenuTree 패턴 이식) — 검색어로 그룹을 prune.
|
||||||
|
* 매칭: 항목 라벨(i18n 해소값) 부분일치(대소문자 무시). section 헤더는 결과에서 제외.
|
||||||
|
* 매칭 항목을 보유한 그룹만, 매칭 항목만 담아 반환(WISE 는 매칭 그룹 force-open 렌더).
|
||||||
|
* resolveLabel 로 i18n 해소값을 넘겨 4개 언어 라벨 모두 검색 가능.
|
||||||
|
*/
|
||||||
|
interface FilteredGroup {
|
||||||
|
group: NavGroupDef;
|
||||||
|
items: NavItem[];
|
||||||
|
}
|
||||||
|
function filterGroups(
|
||||||
|
groups: NavGroupDef[],
|
||||||
|
query: string,
|
||||||
|
resolveLabel: (item: NavItem) => string,
|
||||||
|
): FilteredGroup[] {
|
||||||
|
const nq = query.trim().toLowerCase();
|
||||||
|
if (!nq) return [];
|
||||||
|
const out: FilteredGroup[] = [];
|
||||||
|
for (const group of groups) {
|
||||||
|
const items = group.items.filter(
|
||||||
|
(item) => !item.section && resolveLabel(item).toLowerCase().includes(nq),
|
||||||
|
);
|
||||||
|
if (items.length) out.push({ group, items });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 라벨을 검색어 매칭 구간에서 분할(하이라이트용). 미매칭 시 [{text,hit:false}] 단일. */
|
||||||
|
function highlightParts(label: string, query: string): { text: string; hit: boolean }[] {
|
||||||
|
const nq = query.trim().toLowerCase();
|
||||||
|
if (!nq) return [{ text: label, hit: false }];
|
||||||
|
const parts: { text: string; hit: boolean }[] = [];
|
||||||
|
const lower = label.toLowerCase();
|
||||||
|
let i = 0;
|
||||||
|
while (i < label.length) {
|
||||||
|
const idx = lower.indexOf(nq, i);
|
||||||
|
if (idx < 0) {
|
||||||
|
parts.push({ text: label.slice(i), hit: false });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (idx > i) parts.push({ text: label.slice(i, idx), hit: false });
|
||||||
|
parts.push({ text: label.slice(idx, idx + nq.length), hit: true });
|
||||||
|
i = idx + nq.length;
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
/** 현재 경로가 속한 대분류 그룹 id(활성 그룹 자동 펼침용). 정적 to 항목만 매칭. */
|
/** 현재 경로가 속한 대분류 그룹 id(활성 그룹 자동 펼침용). 정적 to 항목만 매칭. */
|
||||||
function activeGroupId(pathname: string): string | null {
|
function activeGroupId(pathname: string): string | null {
|
||||||
for (const g of GROUPS) {
|
for (const g of GROUPS) {
|
||||||
@ -265,6 +314,11 @@ export function AppShell() {
|
|||||||
// 단일-열림 아코디언(소유자 지시 ②) — 한 번에 대분류 하나만 펼침. WISE LeftNav 동작 그대로.
|
// 단일-열림 아코디언(소유자 지시 ②) — 한 번에 대분류 하나만 펼침. WISE LeftNav 동작 그대로.
|
||||||
const [openGroup, setOpenGroup] = useState<string | null>(() => activeGroupId(location.pathname));
|
const [openGroup, setOpenGroup] = useState<string | null>(() => activeGroupId(location.pathname));
|
||||||
|
|
||||||
|
// 메뉴 검색(WISE NavSearch 패턴) — 검색어는 셸 로컬 state(신규 store 없음).
|
||||||
|
const [menuQuery, setMenuQuery] = useState('');
|
||||||
|
const searching = menuQuery.trim().length > 0;
|
||||||
|
const clearMenuQuery = () => setMenuQuery('');
|
||||||
|
|
||||||
// 설정 아이콘 → 우측 레이아웃 커스터마이저(#43) offcanvas 열림.
|
// 설정 아이콘 → 우측 레이아웃 커스터마이저(#43) offcanvas 열림.
|
||||||
const [customizerOpen, setCustomizerOpen] = useState(false);
|
const [customizerOpen, setCustomizerOpen] = useState(false);
|
||||||
|
|
||||||
@ -300,11 +354,71 @@ export function AppShell() {
|
|||||||
closeOnMobile();
|
closeOnMobile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리프 항목 렌더(링크 또는 스코프 버튼). highlight=true 시 검색어 매칭 구간을 <mark>로 강조.
|
||||||
|
* 검색 결과 클릭 시 검색어를 지워 아코디언으로 복귀(WISE onNavigate=clearQuery).
|
||||||
|
*/
|
||||||
|
const renderLeaf = (item: NavItem, highlight: boolean) => {
|
||||||
|
const label = navLabel(item.labelKey, item.label);
|
||||||
|
const body = highlight
|
||||||
|
? highlightParts(label, menuQuery).map((p, i) =>
|
||||||
|
p.hit ? (
|
||||||
|
<mark key={i} className="kx-shell__nav-hl">{p.text}</mark>
|
||||||
|
) : (
|
||||||
|
<span key={i}>{p.text}</span>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: label;
|
||||||
|
const onNav = () => {
|
||||||
|
if (highlight) clearMenuQuery();
|
||||||
|
closeOnMobile();
|
||||||
|
};
|
||||||
|
return item.to ? (
|
||||||
|
<NavLink
|
||||||
|
to={item.to}
|
||||||
|
className={({ isActive }) => `kx-shell__nav-link ${isActive ? 'is-active' : ''}`}
|
||||||
|
onClick={onNav}
|
||||||
|
>
|
||||||
|
<span className="kx-shell__nav-dot" aria-hidden="true" />
|
||||||
|
<span>{body}</span>
|
||||||
|
</NavLink>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="kx-shell__nav-link kx-shell__nav-link--btn"
|
||||||
|
onClick={() => {
|
||||||
|
if (highlight) clearMenuQuery();
|
||||||
|
goScoped(item.key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="kx-shell__nav-dot" aria-hidden="true" />
|
||||||
|
<span>{body}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 관리자 그룹은 isAdmin 게이트 유지 + 트랙 노출 매트릭스(plan §2-3). 미지정 그룹=전 트랙.
|
// 관리자 그룹은 isAdmin 게이트 유지 + 트랙 노출 매트릭스(plan §2-3). 미지정 그룹=전 트랙.
|
||||||
const visibleGroups = GROUPS.filter(
|
const visibleGroups = GROUPS.filter(
|
||||||
(g) => (!g.admin || isAdmin) && trackAllows(g.tracks, activeTrack),
|
(g) => (!g.admin || isAdmin) && trackAllows(g.tracks, activeTrack),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검색 결과 — 전체 대분류 횡단(global), 항목 라벨(i18n) 부분일치.
|
||||||
|
* 항목 단위 트랙 게이트(trackAllows) 유지 → 노출 규칙 회귀 없음.
|
||||||
|
* (visibleGroups·menuQuery·activeTrack·언어 변경 시 재계산)
|
||||||
|
*/
|
||||||
|
const searchResults = useMemo(
|
||||||
|
() =>
|
||||||
|
filterGroups(visibleGroups, menuQuery, (item) => navLabel(item.labelKey, item.label)).map(
|
||||||
|
({ group, items }) => ({
|
||||||
|
group,
|
||||||
|
items: items.filter((item) => trackAllows(item.tracks, activeTrack)),
|
||||||
|
}),
|
||||||
|
).filter(({ items }) => items.length > 0),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[visibleGroups, menuQuery, activeTrack, t],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`kx-shell ${navCollapsed ? 'is-nav-collapsed' : ''}`}>
|
<div className={`kx-shell ${navCollapsed ? 'is-nav-collapsed' : ''}`}>
|
||||||
<a className="kx-skip-link" href="#kx-main-content">
|
<a className="kx-skip-link" href="#kx-main-content">
|
||||||
@ -335,7 +449,59 @@ export function AppShell() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 메뉴 검색(WISE NavSearch 이식) — 브랜드/행사 아래 고정(스크롤 영역 위, flex:none). */}
|
||||||
|
<div className="kx-shell__nav-search" role="search">
|
||||||
|
<IconSearch size={16} className="kx-shell__nav-search-icon" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="kx-shell__nav-search-input"
|
||||||
|
value={menuQuery}
|
||||||
|
placeholder={t('shell.menu.search.placeholder', { defaultValue: '메뉴 검색' })}
|
||||||
|
aria-label={t('shell.menu.search.placeholder', { defaultValue: '메뉴 검색' })}
|
||||||
|
onChange={(e) => setMenuQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape' && menuQuery) {
|
||||||
|
e.preventDefault();
|
||||||
|
clearMenuQuery();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{menuQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="kx-shell__nav-search-clear"
|
||||||
|
aria-label={t('shell.menu.search.clear', { defaultValue: '검색어 지우기' })}
|
||||||
|
onClick={clearMenuQuery}
|
||||||
|
>
|
||||||
|
<IconClose size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="kx-shell__nav-scroll">
|
<div className="kx-shell__nav-scroll">
|
||||||
|
{searching ? (
|
||||||
|
/* 검색 — 전체 대분류 횡단(global) 매칭 항목만, 매칭 그룹 force-open(WISE 패턴). 하이라이트. */
|
||||||
|
searchResults.length === 0 ? (
|
||||||
|
<div className="kx-shell__nav-empty">
|
||||||
|
{t('shell.menu.search.noResult', { defaultValue: '일치하는 메뉴가 없습니다' })}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
searchResults.map(({ group, items }) => (
|
||||||
|
<div className="kx-shell__group" key={group.id}>
|
||||||
|
<div className="kx-shell__group-title kx-shell__group-title--static">
|
||||||
|
<span className="kx-shell__nav-icon"><group.Icon size={18} /></span>
|
||||||
|
<span className="kx-shell__group-label">{navLabel(group.labelKey, group.label)}</span>
|
||||||
|
</div>
|
||||||
|
<ul className="kx-shell__nav kx-shell__nav--sub">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.key}>{renderLeaf(item, true)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* 홈 — 최상위 단일 링크 */}
|
{/* 홈 — 최상위 단일 링크 */}
|
||||||
<ul className="kx-shell__nav">
|
<ul className="kx-shell__nav">
|
||||||
<li>
|
<li>
|
||||||
@ -381,29 +547,7 @@ export function AppShell() {
|
|||||||
{navLabel(item.labelKey, item.label)}
|
{navLabel(item.labelKey, item.label)}
|
||||||
</li>
|
</li>
|
||||||
) : (
|
) : (
|
||||||
<li key={item.key}>
|
<li key={item.key}>{renderLeaf(item, false)}</li>
|
||||||
{item.to ? (
|
|
||||||
<NavLink
|
|
||||||
to={item.to}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`kx-shell__nav-link ${isActive ? 'is-active' : ''}`
|
|
||||||
}
|
|
||||||
onClick={closeOnMobile}
|
|
||||||
>
|
|
||||||
<span className="kx-shell__nav-dot" aria-hidden="true" />
|
|
||||||
{navLabel(item.labelKey, item.label)}
|
|
||||||
</NavLink>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="kx-shell__nav-link kx-shell__nav-link--btn"
|
|
||||||
onClick={() => goScoped(item.key)}
|
|
||||||
>
|
|
||||||
<span className="kx-shell__nav-dot" aria-hidden="true" />
|
|
||||||
{navLabel(item.labelKey, item.label)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
@ -411,6 +555,8 @@ export function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 하단 사용자 영역 — 반응형 재정렬(소유자 지적 3): 프로필 행 + 액션 아이콘 행. */}
|
{/* 하단 사용자 영역 — 반응형 재정렬(소유자 지적 3): 프로필 행 + 액션 아이콘 행. */}
|
||||||
|
|||||||
@ -98,6 +98,81 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 메뉴 검색(WISE NavSearch 이식) — 브랜드/행사 아래 고정(스크롤 영역 위). ── */
|
||||||
|
.kx-shell__nav-search {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: none;
|
||||||
|
padding: var(--space-3) var(--space-4) 0;
|
||||||
|
}
|
||||||
|
.kx-shell__nav-search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(var(--space-4) + 11px);
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.kx-shell__nav-search-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 32px 0 34px;
|
||||||
|
border: 1px solid var(--color-neutral-200);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-neutral-050);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
font-size: var(--fs-body);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.kx-shell__nav-search-input::placeholder {
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
}
|
||||||
|
.kx-shell__nav-search-input:focus {
|
||||||
|
border-color: var(--color-primary-600);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-primary-100);
|
||||||
|
}
|
||||||
|
.kx-shell__nav-search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: calc(var(--space-4) + 6px);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
.kx-shell__nav-search-clear:hover {
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
}
|
||||||
|
/* 검색 결과 없음/빈 메뉴 */
|
||||||
|
.kx-shell__nav-empty {
|
||||||
|
padding: var(--space-4);
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
/* 검색 결과 그룹 헤더(클릭 불가, 아코디언 토글 없이 그룹 라벨만). */
|
||||||
|
.kx-shell__group-title--static {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.kx-shell__group-title--static:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
/* 검색어 매칭 하이라이트 */
|
||||||
|
.kx-shell__nav-hl {
|
||||||
|
background: var(--color-warning-100, var(--color-primary-100));
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 메뉴 스크롤 영역 — 사이드바 중앙(하단 사용자 고정). */
|
/* 메뉴 스크롤 영역 — 사이드바 중앙(하단 사용자 고정). */
|
||||||
.kx-shell__nav-scroll {
|
.kx-shell__nav-scroll {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|||||||
@ -33,6 +33,11 @@
|
|||||||
"shell": {
|
"shell": {
|
||||||
"menu": {
|
"menu": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Search menu",
|
||||||
|
"clear": "Clear search",
|
||||||
|
"noResult": "No matching menu"
|
||||||
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
"ops": "Exhibition Ops",
|
"ops": "Exhibition Ops",
|
||||||
"design": "Design & Build",
|
"design": "Design & Build",
|
||||||
@ -2817,4 +2822,4 @@
|
|||||||
"renderResult": "Result",
|
"renderResult": "Result",
|
||||||
"noParams": "This template has no parameters"
|
"noParams": "This template has no parameters"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -33,6 +33,11 @@
|
|||||||
"shell": {
|
"shell": {
|
||||||
"menu": {
|
"menu": {
|
||||||
"home": "ホーム",
|
"home": "ホーム",
|
||||||
|
"search": {
|
||||||
|
"placeholder": "メニュー検索",
|
||||||
|
"clear": "検索をクリア",
|
||||||
|
"noResult": "一致するメニューがありません"
|
||||||
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
"ops": "展示運営",
|
"ops": "展示運営",
|
||||||
"design": "設計・施工",
|
"design": "設計・施工",
|
||||||
@ -2817,4 +2822,4 @@
|
|||||||
"renderResult": "レンダリング結果",
|
"renderResult": "レンダリング結果",
|
||||||
"noParams": "置換パラメータのないテンプレートです"
|
"noParams": "置換パラメータのないテンプレートです"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -33,6 +33,11 @@
|
|||||||
"shell": {
|
"shell": {
|
||||||
"menu": {
|
"menu": {
|
||||||
"home": "홈",
|
"home": "홈",
|
||||||
|
"search": {
|
||||||
|
"placeholder": "메뉴 검색",
|
||||||
|
"clear": "검색어 지우기",
|
||||||
|
"noResult": "일치하는 메뉴가 없습니다"
|
||||||
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
"ops": "전시 운영",
|
"ops": "전시 운영",
|
||||||
"design": "설계·시공",
|
"design": "설계·시공",
|
||||||
@ -2817,4 +2822,4 @@
|
|||||||
"renderResult": "렌더 결과",
|
"renderResult": "렌더 결과",
|
||||||
"noParams": "치환할 파라미터가 없는 템플릿입니다"
|
"noParams": "치환할 파라미터가 없는 템플릿입니다"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -33,6 +33,11 @@
|
|||||||
"shell": {
|
"shell": {
|
||||||
"menu": {
|
"menu": {
|
||||||
"home": "首页",
|
"home": "首页",
|
||||||
|
"search": {
|
||||||
|
"placeholder": "搜索菜单",
|
||||||
|
"clear": "清除搜索",
|
||||||
|
"noResult": "没有匹配的菜单"
|
||||||
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
"ops": "展会运营",
|
"ops": "展会运营",
|
||||||
"design": "设计·施工",
|
"design": "设计·施工",
|
||||||
@ -2817,4 +2822,4 @@
|
|||||||
"renderResult": "渲染结果",
|
"renderResult": "渲染结果",
|
||||||
"noParams": "此模板没有可替换参数"
|
"noParams": "此模板没有可替换参数"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -523,7 +523,8 @@
|
|||||||
/* ── 권한 매트릭스(역할 × 권한) ── */
|
/* ── 권한 매트릭스(역할 × 권한) ── */
|
||||||
.kx-adm-matrix { display: grid; grid-template-columns: 280px 1fr; gap: var(--space-4); align-items: start; }
|
.kx-adm-matrix { display: grid; grid-template-columns: 280px 1fr; gap: var(--space-4); align-items: start; }
|
||||||
.kx-adm-matrix__roles { padding: 0; overflow: hidden; }
|
.kx-adm-matrix__roles { padding: 0; overflow: hidden; }
|
||||||
.kx-adm-matrix__perms { padding: var(--space-4); }
|
/* 1fr 트랙 min-width:0 — 넓은 매트릭스 표가 트랙을 밀어 그리드를 터뜨리지 않도록(내부 스크롤 활성). */
|
||||||
|
.kx-adm-matrix__perms { padding: var(--space-4); min-width: 0; }
|
||||||
.kx-adm-permgrid {
|
.kx-adm-permgrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
|||||||
@ -182,7 +182,9 @@
|
|||||||
border: var(--border-card);
|
border: var(--border-card);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: var(--color-white);
|
background: var(--color-white);
|
||||||
overflow: hidden;
|
/* overflow:hidden 은 넓은 표를 잘라냈다 → 가로 스크롤로 전환(모서리 클립 유지). */
|
||||||
|
overflow-x: auto;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.kx-dash__section-head {
|
.kx-dash__section-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -174,6 +174,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
/* 견적 결과 표가 넓을 때 래퍼 없이도 잘리지 않도록 가로 스크롤. */
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
.kx-hall__num {
|
.kx-hall__num {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
|||||||
@ -228,6 +228,9 @@
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
/* 라인아이템 표(.kx-settle__lines)가 모달 폭(max 720)을 넘으면 자기 안에서 가로 스크롤. */
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
.kx-settle__meta {
|
.kx-settle__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -457,6 +460,11 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
.kx-settle__report-group {
|
||||||
|
/* 카테고리/상태별 보고 표(6열)가 넓을 때 래퍼 없이도 잘리지 않도록 가로 스크롤. */
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
.kx-settle__report-group h3 {
|
.kx-settle__report-group h3 {
|
||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@ -264,6 +264,10 @@
|
|||||||
}
|
}
|
||||||
.kx-table-scroll {
|
.kx-table-scroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
/* flex/grid 자식 함정 방지: 부모 폭에 맞춰 축소되고, 넓은 표는 자기 안에서 가로 스크롤. */
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
.kx-table--zebra tbody tr:nth-child(even) {
|
.kx-table--zebra tbody tr:nth-child(even) {
|
||||||
background: var(--zebra);
|
background: var(--zebra);
|
||||||
|
|||||||
@ -13,6 +13,8 @@ interface AuthState {
|
|||||||
currentEventId: string | null;
|
currentEventId: string | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
applyLogin: (res: LoginResponse) => void;
|
applyLogin: (res: LoginResponse) => void;
|
||||||
|
/** 새로고침 세션 재수화 — 토큰은 살아있지만 스토어가 빈 상태(user=null)일 때 /me·/workspaces 응답으로 복원. */
|
||||||
|
applySession: (user: AuthUser, workspaces: WorkspaceDto[]) => void;
|
||||||
selectEvent: (eventId: string) => void;
|
selectEvent: (eventId: string) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
currentWorkspace: () => WorkspaceDto | null;
|
currentWorkspace: () => WorkspaceDto | null;
|
||||||
@ -38,6 +40,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
applySession: (user, workspaces) => set({ user, workspaces, isAuthenticated: true }),
|
||||||
|
|
||||||
selectEvent: (eventId) => set({ currentEventId: eventId }),
|
selectEvent: (eventId) => set({ currentEventId: eventId }),
|
||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user