Merge origin/main into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m45s

부서관리 V1 슬림 스코프 + UX 리디자인, 25개 버그 일괄 수정, admin/부서관리
탭 라벨 fallback, Windows dev HMR 복원 흡수.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 21:51:34 +09:00
31 changed files with 3942 additions and 718 deletions
+3 -3
View File
@@ -17,9 +17,9 @@ interface CompanyTableProps {
export function CompanyTable({ companies, isLoading, onEdit, onDelete }: CompanyTableProps) {
const router = useRouter();
// 부서 관리 페이지로 이동
const handleManageDepartments = (company: Company) => {
router.push(`/admin/userMng/companyList/${company.company_code}/departments`);
// 부서 관리 페이지로 이동 (legacy deptMngList 가 캐노니컬 페이지)
const handleManageDepartments = (_company: Company) => {
router.push(`/admin/userMng/deptMngList`);
};
// 디스크 사용량 포맷팅 함수
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react";
import { Plus, ChevronDown, ChevronRight, Users, Trash2, Eye, EyeOff, Undo2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
@@ -31,6 +31,9 @@ export function DepartmentStructure({
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = useState(false);
// V1: soft-delete 된 부서 표시 토글
const [showDeleted, setShowDeleted] = useState(false);
// 부서 추가 모달
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [parentDeptForAdd, setParentDeptForAdd] = useState<string | null>(null);
@@ -42,15 +45,15 @@ export function DepartmentStructure({
const [deptToDelete, setDeptToDelete] = useState<{ code: string; name: string } | null>(null);
const [deleteErrorMessage, setDeleteErrorMessage] = useState<string | null>(null);
// 부서 목록 로드
// 부서 목록 로드 — showDeleted 도 의존성에 포함
useEffect(() => {
loadDepartments();
}, [companyCode, refreshTrigger]);
}, [companyCode, refreshTrigger, showDeleted]);
const loadDepartments = async () => {
setIsLoading(true);
try {
const response = await departmentAPI.getDepartments(companyCode);
const response = await departmentAPI.getDepartments(companyCode, { includeDeleted: showDeleted });
if (response.success && (response as any).data) {
setDepartments((response as any).data);
} else {
@@ -65,6 +68,34 @@ export function DepartmentStructure({
}
};
// V1: 부서 복구 핸들러 (soft-delete 된 부서 되살리기)
const handleRestoreDepartment = async (deptCode: string, deptName: string) => {
try {
const response = await departmentAPI.restoreDepartment(deptCode);
if (response.success) {
loadDepartments();
toast({
title: "부서 복구 완료",
description: `"${deptName}" 부서가 복구되었습니다.`,
variant: "default",
});
} else {
toast({
title: "복구 불가",
description: (response as any).error || "부서 복구에 실패했습니다.",
variant: "destructive",
});
}
} catch (error) {
console.error("부서 복구 실패:", error);
toast({
title: "부서 복구 실패",
description: "복구 중 오류가 발생했습니다.",
variant: "destructive",
});
}
};
// 부서 트리 구조 생성
const buildTree = (parentCode: string | null): Department[] => {
return departments
@@ -145,10 +176,13 @@ export function DepartmentStructure({
setDeptToDelete(null);
loadDepartments();
// 성공 메시지 Toast로 표시 (부서원 수 포함)
// V1 soft-delete: 복구 가능 안내 추가
const isSoft = (response as any)?.data?.soft_deleted === true;
toast({
title: "부서 삭제 완료",
description: (response as any).message || "부서가 삭제되었습니다.",
title: isSoft ? "부서 삭제됨 (복구 가능)" : "부서 삭제 완료",
description: isSoft
? `"${deptToDelete.name}" 부서를 휴지통으로 보냈습니다. '삭제 부서 보기' 토글로 복구할 수 있습니다.`
: (response as any).message || "부서가 삭제되었습니다.",
variant: "default",
});
} else {
@@ -184,17 +218,18 @@ export function DepartmentStructure({
const hasChildren = departments.some((d) => d.parent_dept_code === dept.dept_code);
const isExpanded = expandedDepts.has(dept.dept_code);
const isSelected = selectedDepartment?.dept_code === dept.dept_code;
const isDeleted = !!(dept as any).deleted_at;
return (
<div key={dept.dept_code}>
{/* 부서 항목 */}
{/* 부서 항목 — soft-delete 시 회색+취소선 */}
<div
className={`hover:bg-muted flex cursor-pointer items-center justify-between rounded-lg p-2 text-sm transition-colors ${
isSelected ? "bg-primary/10 text-primary" : ""
}`}
} ${isDeleted ? "bg-muted/40 text-muted-foreground line-through opacity-60" : ""}`}
style={{ marginLeft: `${level * 16}px` }}
>
<div className="flex flex-1 items-center gap-2" onClick={() => onSelectDepartment(dept)}>
<div className="flex flex-1 items-center gap-2" onClick={() => !isDeleted && onSelectDepartment(dept)}>
{/* 확장/축소 아이콘 */}
{hasChildren ? (
<button
@@ -218,32 +253,54 @@ export function DepartmentStructure({
<Users className="h-3 w-3" />
<span>{dept.member_count || 0}</span>
</div>
{/* deleted 배지 */}
{isDeleted && <span className="text-muted-foreground text-[10px] uppercase tracking-wider"></span>}
</div>
{/* 액션 버튼 */}
{/* 액션 버튼 — deleted 면 복구 버튼만, 아니면 추가/삭제 버튼 */}
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleAddDepartment(dept.dept_code);
}}
>
<Plus className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
{isDeleted ? (
<Button
variant="ghost"
size="icon"
className="text-primary h-6 w-6"
title="복구"
onClick={(e) => {
e.stopPropagation();
handleRestoreDepartment(dept.dept_code, dept.dept_name);
}}
>
<Undo2 className="h-3 w-3" />
</Button>
) : (
<>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title="하위 부서 추가"
onClick={(e) => {
e.stopPropagation();
handleAddDepartment(dept.dept_code);
}}
>
<Plus className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive h-6 w-6"
title="삭제"
onClick={(e) => {
e.stopPropagation();
handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</>
)}
</div>
</div>
@@ -259,10 +316,23 @@ export function DepartmentStructure({
{/* 헤더 */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold"> </h3>
<Button size="sm" className="h-9 gap-2 text-sm" onClick={() => handleAddDepartment(null)}>
<Plus className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2">
{/* V1: soft-delete 부서 표시 토글 */}
<Button
variant={showDeleted ? "secondary" : "ghost"}
size="sm"
className="h-9 gap-2 text-sm"
onClick={() => setShowDeleted((v) => !v)}
title={showDeleted ? "삭제된 부서 숨기기" : "삭제된 부서 보기"}
>
{showDeleted ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
{showDeleted ? "삭제 부서 숨기기" : "삭제 부서 보기"}
</Button>
<Button size="sm" className="h-9 gap-2 text-sm" onClick={() => handleAddDepartment(null)}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{/* 부서 트리 */}
@@ -338,7 +408,9 @@ export function DepartmentStructure({
<p className="text-sm">
<span className="font-semibold">{deptToDelete?.name}</span> ?
</p>
<p className="text-muted-foreground mt-2 text-xs"> .</p>
<p className="text-muted-foreground mt-2 text-xs">
. ( '삭제 부서 보기' ) .
</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
@@ -71,8 +71,19 @@ export default function Step1Basic({
const r: any = await checkAvailability(payload);
if (payload.subdomain === state.subdomain) {
const sub = r?.subdomain;
// 우선순위: reserved > valid_format > available
// 백엔드 isValidSubdomain 이 reserved 도 false 로 잡아내므로 reserved 를 먼저 검사해야
// "예약어" 케이스가 "형식 오류" 로 묻히지 않는다.
setSubStatus(
!sub ? "idle" : !sub.valid_format || sub.reserved ? "invalid" : sub.available ? "available" : "taken",
!sub
? "idle"
: sub.reserved
? "reserved"
: !sub.valid_format
? "invalid"
: sub.available
? "available"
: "taken",
);
}
if (payload.dbPrefix === state.db_prefix) {
@@ -9,7 +9,7 @@ import { Loader2, CheckCircle2, XCircle } from "lucide-react";
* - CheckAvailBadge: subdomain/db_prefix 실시간 검증 인디케이터
*/
export type AvailStatus = "idle" | "checking" | "available" | "taken" | "invalid";
export type AvailStatus = "idle" | "checking" | "available" | "taken" | "reserved" | "invalid";
export function Field({
label,
@@ -228,6 +228,21 @@ export function CheckAvailBadge({ status, value }: { status: AvailStatus; value?
<XCircle size={13} />
</span>
);
if (status === "reserved")
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
fontSize: "0.72rem",
color: "var(--v5-red)",
fontWeight: 600,
}}
>
<XCircle size={13} /> ( )
</span>
);
return (
<span
style={{
@@ -247,7 +262,7 @@ export function CheckAvailBadge({ status, value }: { status: AvailStatus; value?
/** TextInput 의 status prop 과 매핑 */
export function availToInputStatus(a: AvailStatus): TextInputStatus | undefined {
if (a === "available") return "ok";
if (a === "taken" || a === "invalid") return "err";
if (a === "taken" || a === "reserved" || a === "invalid") return "err";
return undefined;
}
@@ -0,0 +1,352 @@
"use client";
/**
* DepartmentPicker — 부서 선택 재사용 컴포넌트 (V1 신규).
*
* 다른 화면에서 부서를 선택해야 할 때 사용. 단일 / 다중 선택 모드 지원.
*
* 사용 예:
* <DepartmentPicker
* companyCode="INVYONE"
* mode="single"
* value={parentDeptCode}
* open={isOpen}
* onSelect={(code) => setParentDeptCode(code as string)}
* onClose={() => setIsOpen(false)}
* excludeCodes={[currentDeptCode]}
* />
*
* 동작:
* - shadcn Dialog 안에 검색박스 + 트리뷰
* - 부모 클릭 시 자식 cascade 펼침
* - 클라이언트측 검색 (debounce 200ms, 이름/코드 부분일치)
* - single: 클릭 즉시 onSelect → close
* - multi: 체크박스 + 부모 체크 시 자식 자동 cascade + 확인 버튼으로 onSelect
* - excludeCodes 에 포함된 dept 는 disabled
* - 사이클 데이터 (잘못된 PARENT_DEPT_CODE) 는 visited Set 으로 차단
*
* V1 한계: 검색은 클라이언트측 필터. 1000+ 부서는 V2 에서 backend search 도입 예정.
*/
import { useEffect, useMemo, useState } from "react";
import { Check, ChevronDown, ChevronRight, Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import type { Department } from "@/types/department";
import { getDepartments } from "@/lib/api/department";
export interface DepartmentPickerProps {
companyCode: string;
mode: "single" | "multi";
/** 현재 선택값. single 이면 string, multi 면 string[] */
value?: string | string[];
open: boolean;
onSelect: (code: string | string[]) => void;
onClose: () => void;
/** 선택 불가로 disable 처리할 dept_code 들 (자기 자신 부모 등록 방지 등) */
excludeCodes?: string[];
/** soft-delete 된 부서도 보여줄지 (default false) */
includeDeleted?: boolean;
/** 모달 헤더 타이틀 (default: "부서 선택") */
title?: string;
/** single 모드에서 "최상위로" (부모 없음) 옵션 표시 (default false) */
allowRoot?: boolean;
}
export function DepartmentPicker({
companyCode,
mode,
value,
open,
onSelect,
onClose,
excludeCodes,
includeDeleted = false,
title = "부서 선택",
allowRoot = false,
}: DepartmentPickerProps) {
const [departments, setDepartments] = useState<Department[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [searchInput, setSearchInput] = useState("");
const [searchTerm, setSearchTerm] = useState(""); // debounced
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<Set<string>>(new Set());
// value -> selected 동기화
useEffect(() => {
if (!open) return;
if (mode === "single") {
setSelected(new Set(typeof value === "string" && value ? [value] : []));
} else {
setSelected(new Set(Array.isArray(value) ? value : []));
}
}, [value, mode, open]);
// 검색어 debounce 200ms
useEffect(() => {
const t = setTimeout(() => setSearchTerm(searchInput.trim().toLowerCase()), 200);
return () => clearTimeout(t);
}, [searchInput]);
// 부서 목록 로드
useEffect(() => {
if (!open) return;
let cancelled = false;
setIsLoading(true);
getDepartments(companyCode, { includeDeleted })
.then((res: any) => {
if (cancelled) return;
if (res?.success && Array.isArray(res?.data)) {
setDepartments(res.data);
} else {
setDepartments([]);
}
})
.catch(() => !cancelled && setDepartments([]))
.finally(() => !cancelled && setIsLoading(false));
return () => {
cancelled = true;
};
}, [open, companyCode, includeDeleted]);
// 코드 -> 부서 맵 (검색·자식 조회용)
const byCode = useMemo(() => {
const m = new Map<string, Department>();
for (const d of departments) m.set(d.dept_code, d);
return m;
}, [departments]);
// 검색 매칭 (이름·코드 부분일치)
const isMatch = (d: Department): boolean => {
if (!searchTerm) return true;
return (
d.dept_name.toLowerCase().includes(searchTerm) ||
d.dept_code.toLowerCase().includes(searchTerm)
);
};
// 검색 매칭 부서 + 그 조상들도 포함해서 트리에서 visible
const visibleCodes = useMemo(() => {
if (!searchTerm) return null; // null = 전체 visible
const visible = new Set<string>();
for (const d of departments) {
if (isMatch(d)) {
visible.add(d.dept_code);
// 조상 visible (부모-부모-...) — 사이클 차단
const visited = new Set<string>([d.dept_code]);
let parentCode = d.parent_dept_code;
while (parentCode && !visited.has(parentCode)) {
visited.add(parentCode);
visible.add(parentCode);
parentCode = byCode.get(parentCode)?.parent_dept_code ?? null;
}
}
}
return visible;
}, [searchTerm, departments, byCode]);
// 부모 코드 → 자식 정렬 리스트
const childrenOf = (parentCode: string | null): Department[] => {
return departments
.filter((d) => (d.parent_dept_code ?? null) === parentCode)
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
};
// 부모 + 자식 (재귀) cascade 코드들
const collectDescendants = (rootCode: string): string[] => {
const result: string[] = [];
const visited = new Set<string>();
const dfs = (code: string) => {
if (visited.has(code)) return;
visited.add(code);
result.push(code);
for (const child of childrenOf(code)) {
dfs(child.dept_code);
}
};
dfs(rootCode);
return result;
};
const toggleExpand = (code: string) => {
const next = new Set(expanded);
if (next.has(code)) next.delete(code);
else next.add(code);
setExpanded(next);
};
const isExcluded = (code: string) => Boolean(excludeCodes?.includes(code));
const isDeleted = (d: Department) => Boolean((d as any).deleted_at);
const handleNodeClick = (d: Department) => {
if (isExcluded(d.dept_code)) return;
if (mode === "single") {
onSelect(d.dept_code);
onClose();
return;
}
// multi: 자기 + 자손 모두 토글
const next = new Set(selected);
const codes = collectDescendants(d.dept_code).filter((c) => !isExcluded(c));
const allSelected = codes.every((c) => next.has(c));
if (allSelected) {
for (const c of codes) next.delete(c);
} else {
for (const c of codes) next.add(c);
}
setSelected(next);
};
const handleConfirmMulti = () => {
onSelect(Array.from(selected));
onClose();
};
// 트리 렌더 (재귀, visited Set 사이클 차단)
const renderTree = (parentCode: string | null, level: number, visited: Set<string>): React.ReactNode => {
const list = childrenOf(parentCode);
return list.map((d) => {
if (visited.has(d.dept_code)) return null; // 사이클 차단
const nextVisited = new Set(visited);
nextVisited.add(d.dept_code);
const hasChildren = childrenOf(d.dept_code).length > 0;
const isOpen = expanded.has(d.dept_code) || (searchTerm.length > 0 && hasChildren);
const isSel = selected.has(d.dept_code);
const excluded = isExcluded(d.dept_code);
const deleted = isDeleted(d);
// 검색 시: visible 아닌 노드는 숨김
if (visibleCodes && !visibleCodes.has(d.dept_code)) return null;
return (
<div key={d.dept_code}>
<div
className={`flex items-center gap-2 rounded p-1.5 text-sm transition-colors ${
excluded
? "cursor-not-allowed opacity-40"
: "hover:bg-muted cursor-pointer"
} ${isSel ? "bg-primary/10 text-primary" : ""} ${deleted ? "text-muted-foreground line-through" : ""}`}
style={{ paddingLeft: `${level * 16 + 6}px` }}
onClick={() => handleNodeClick(d)}
>
{/* expand/collapse */}
{hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation();
toggleExpand(d.dept_code);
}}
className="flex h-4 w-4 items-center justify-center"
>
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : (
<div className="h-4 w-4" />
)}
{/* multi: 체크 표시 */}
{mode === "multi" && (
<div
className={`flex h-4 w-4 items-center justify-center rounded border ${
isSel ? "bg-primary border-primary text-primary-foreground" : "border-input"
}`}
>
{isSel && <Check className="h-3 w-3" />}
</div>
)}
{/* 부서명 + 코드 */}
<div className="flex flex-1 flex-col leading-tight">
<span className="font-medium">{d.dept_name}</span>
<span className="text-muted-foreground text-[10px] uppercase">{d.dept_code}</span>
</div>
{deleted && (
<span className="text-muted-foreground text-[10px] uppercase tracking-wider"></span>
)}
</div>
{hasChildren && isOpen && renderTree(d.dept_code, level + 1, nextVisited)}
</div>
);
});
};
// 루트 — parent_dept_code 가 null/빈문자열인 부서들
const rootList = useMemo(() => childrenOf(null), [departments]);
const hasAny = rootList.length > 0 || departments.some((d) => !d.parent_dept_code);
const noResults = searchTerm.length > 0 && (visibleCodes?.size ?? 0) === 0;
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-[95vw] sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
{/* 검색 */}
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="부서명 또는 코드로 검색"
className="pl-8 pr-8"
autoFocus
/>
{searchInput && (
<button
onClick={() => setSearchInput("")}
className="text-muted-foreground absolute right-2 top-1/2 -translate-y-1/2"
title="검색어 지우기"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* 트리 */}
<div className="bg-card max-h-[50vh] overflow-y-auto rounded border p-2">
{mode === "single" && allowRoot && !isLoading && (
<button
type="button"
onClick={() => {
onSelect("");
onClose();
}}
className="bg-muted/30 hover:bg-muted mb-1 w-full rounded p-1.5 text-left text-sm font-medium text-muted-foreground"
>
📂 ( )
</button>
)}
{isLoading ? (
<div className="text-muted-foreground py-8 text-center text-sm"> ...</div>
) : !hasAny ? (
<div className="text-muted-foreground py-8 text-center text-sm"> .</div>
) : noResults ? (
<div className="text-muted-foreground py-8 text-center text-sm"> </div>
) : (
renderTree(null, 0, new Set())
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
</Button>
{mode === "multi" && (
<Button onClick={handleConfirmMulti} disabled={selected.size === 0}>
({selected.size})
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default DepartmentPicker;
@@ -196,11 +196,6 @@ const DYNAMIC_ADMIN_PATTERNS: Array<{
getImport: () => import("@/app/(main)/admin/systemMng/dataflow/edit/[diagramId]/page"),
extractParams: (m) => ({ diagramId: m[1] }),
},
{
pattern: /^\/admin\/userMng\/companyList\/([^/]+)\/departments$/,
getImport: () => import("@/app/(main)/admin/userMng/companyList/[companyCode]/departments/page"),
extractParams: (m) => ({ companyCode: m[1] }),
},
{
pattern: /^\/admin\/standards\/([^/]+)\/edit$/,
getImport: () => import("@/app/(main)/admin/standards/[webType]/edit/page"),
+25 -1
View File
@@ -409,7 +409,12 @@ function AppLayoutInner({ children }: AppLayoutProps) {
if (pathname.startsWith("/admin") && pathname !== "/admin") {
store.setMode("admin");
store.openTab({ type: "admin", title: pathname.split("/").pop() || "관리자", admin_url: pathname });
// menu API 가 실패하는 환경 (SUPER_ADMIN cross-tenant 등) 에서도 한글 라벨 유지
const ADMIN_PATH_LABELS: Record<string, string> = {
"/admin/userMng/deptMngList": "부서관리",
};
const fallbackTitle = ADMIN_PATH_LABELS[pathname] || pathname.split("/").pop() || "관리자";
store.openTab({ type: "admin", title: fallbackTitle, admin_url: pathname });
}
}, []);
@@ -902,6 +907,25 @@ function AppLayoutInner({ children }: AppLayoutProps) {
}
}, [activeTab, uiMenus, isMenuActive]);
// URL 직접 진입 / sessionStorage 복원 시 admin 탭의 영어 path-segment title 을
// menu_name_kor (uiMenus 의 tabTitle/label/name) 로 갱신.
// menu API 가 실패한 환경 (SUPER_ADMIN cross-tenant) 에서도 동작하도록 hardcoded map 도 같이 검사.
useEffect(() => {
const ADMIN_PATH_LABELS: Record<string, string> = {
"/admin/userMng/deptMngList": "부서관리",
};
const store = useTabStore.getState();
for (const tab of store.admin.tabs) {
if (tab.type !== "admin" || !tab.admin_url) continue;
const matched = uiMenus.find((m: any) => m.url === tab.admin_url);
const koreanTitle: string | undefined =
matched?.tabTitle || matched?.label || matched?.name || ADMIN_PATH_LABELS[tab.admin_url];
if (koreanTitle && tab.title !== koreanTitle) {
store.updateTabTitle(tab.id, koreanTitle);
}
}
}, [uiMenus]);
if (!user) {
return (
<div className="flex h-screen items-center justify-center">