권한 그룹 회사 필터 제거(COMPANY_16 단독) + 메신저 사용자명 옵셔널 체이닝
- rolesList/RoleFormModal/[id] 페이지에서 회사 선택 필터·라벨·SUPER_ADMIN 회사 컬럼 제거 - MessageInput/NewRoomModal에서 user.userName/deptName.toLowerCase 호출 시 옵셔널 체이닝 적용으로 null 사용자 방어 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -388,9 +388,7 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{roleGroup.authName}</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{roleGroup.authCode} • {roleGroup.companyCode}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">{roleGroup.authCode}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-sm font-medium ${
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Edit, Trash2, Users, Menu, Filter, X } from "lucide-react";
|
||||
import { Plus, Edit, Trash2, Users, Menu } from "lucide-react";
|
||||
import { roleAPI, RoleGroup } from "@/lib/api/role";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
@@ -10,8 +10,6 @@ import { RoleFormModal } from "@/components/admin/RoleFormModal";
|
||||
import { RoleDeleteModal } from "@/components/admin/RoleDeleteModal";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { companyAPI } from "@/lib/api/company";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { usePageMultiLang } from "@/hooks/usePageMultiLang";
|
||||
|
||||
@@ -24,15 +22,12 @@ const LANG_KEYS = [
|
||||
"error.occurred",
|
||||
"error.close.aria",
|
||||
"roles.list.title",
|
||||
"filter.company.placeholder",
|
||||
"filter.company.all",
|
||||
"button.create",
|
||||
"loading",
|
||||
"empty.message",
|
||||
"empty.hint",
|
||||
"status.active",
|
||||
"status.inactive",
|
||||
"label.company",
|
||||
"label.memberCount",
|
||||
"label.menuPermissions",
|
||||
"count.members",
|
||||
@@ -45,22 +40,19 @@ const LANG_KEYS = [
|
||||
|
||||
const DEFAULT_TEXTS: Record<string, string> = {
|
||||
"roles.title": "권한 그룹 관리",
|
||||
"roles.description": "회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)",
|
||||
"roles.description": "권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)",
|
||||
"access.denied": "접근 권한 없음",
|
||||
"access.denied.msg": "권한 그룹 관리는 회사 관리자 이상만 접근할 수 있습니다.",
|
||||
"button.back": "뒤로 가기",
|
||||
"error.occurred": "오류가 발생했습니다",
|
||||
"error.close.aria": "에러 메시지 닫기",
|
||||
"roles.list.title": "권한 그룹 목록",
|
||||
"filter.company.placeholder": "회사 선택",
|
||||
"filter.company.all": "전체 회사",
|
||||
"button.create": "권한 그룹 생성",
|
||||
"loading": "권한 그룹 목록을 불러오는 중...",
|
||||
"empty.message": "등록된 권한 그룹이 없습니다.",
|
||||
"empty.hint": "권한 그룹을 생성하여 멤버를 관리해보세요.",
|
||||
"status.active": "활성",
|
||||
"status.inactive": "비활성",
|
||||
"label.company": "회사",
|
||||
"label.memberCount": "멤버 수",
|
||||
"label.menuPermissions": "메뉴 권한",
|
||||
"count.members": "{count}명",
|
||||
@@ -94,17 +86,12 @@ export default function RolesPage() {
|
||||
const isAdmin =
|
||||
(currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN") ||
|
||||
currentUser?.userType === "COMPANY_ADMIN";
|
||||
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
||||
|
||||
// 상태 관리
|
||||
const [roleGroups, setRoleGroups] = useState<RoleGroup[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 회사 필터 (최고 관리자 전용)
|
||||
const [companies, setCompanies] = useState<Array<{ company_code: string; company_name: string }>>([]);
|
||||
const [selectedCompany, setSelectedCompany] = useState<string>("all");
|
||||
|
||||
// 모달 상태
|
||||
const [formModal, setFormModal] = useState({
|
||||
isOpen: false,
|
||||
@@ -116,42 +103,18 @@ export default function RolesPage() {
|
||||
role: null as RoleGroup | null,
|
||||
});
|
||||
|
||||
// 회사 목록 로드 (최고 관리자만)
|
||||
const loadCompanies = useCallback(async () => {
|
||||
if (!isSuperAdmin) return;
|
||||
|
||||
try {
|
||||
const companies = await companyAPI.getList();
|
||||
setCompanies(companies);
|
||||
} catch (error) {
|
||||
console.error("회사 목록 로드 오류:", error);
|
||||
}
|
||||
}, [isSuperAdmin]);
|
||||
|
||||
// 데이터 로드
|
||||
// 데이터 로드 (단일 테넌시: 현재 사용자 회사 기준)
|
||||
const loadRoleGroups = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 최고 관리자: selectedCompany에 따라 필터링 (all이면 전체 조회)
|
||||
// 회사 관리자: 자기 회사만 조회
|
||||
const companyFilter =
|
||||
isSuperAdmin && selectedCompany !== "all"
|
||||
? selectedCompany
|
||||
: isSuperAdmin
|
||||
? undefined
|
||||
: currentUser?.companyCode;
|
||||
|
||||
console.log("권한 그룹 목록 조회:", { isSuperAdmin, selectedCompany, companyFilter });
|
||||
|
||||
const response = await roleAPI.getList({
|
||||
companyCode: companyFilter,
|
||||
companyCode: currentUser?.companyCode,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
setRoleGroups(response.data);
|
||||
console.log("권한 그룹 조회 성공:", response.data.length, "개");
|
||||
} else {
|
||||
setError(response.message || t("error.load.list"));
|
||||
}
|
||||
@@ -161,18 +124,15 @@ export default function RolesPage() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isSuperAdmin, selectedCompany, currentUser?.companyCode]);
|
||||
}, [currentUser?.companyCode, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
if (isSuperAdmin) {
|
||||
loadCompanies(); // 최고 관리자는 회사 목록 먼저 로드
|
||||
}
|
||||
loadRoleGroups();
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isAdmin, isSuperAdmin, loadRoleGroups, loadCompanies]);
|
||||
}, [isAdmin, loadRoleGroups]);
|
||||
|
||||
// 권한 그룹 생성 핸들러
|
||||
const handleCreateRole = useCallback(() => {
|
||||
@@ -271,34 +231,7 @@ export default function RolesPage() {
|
||||
|
||||
{/* 액션 버튼 영역 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-xl font-semibold">{t("roles.list.title")} ({roleGroups.length})</h2>
|
||||
|
||||
{/* 최고 관리자 전용: 회사 필터 */}
|
||||
{isSuperAdmin && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="text-muted-foreground h-4 w-4" />
|
||||
<Select value={selectedCompany} onValueChange={(value) => setSelectedCompany(value)}>
|
||||
<SelectTrigger className="h-10 w-[200px]">
|
||||
<SelectValue placeholder={t("filter.company.placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("filter.company.all")}</SelectItem>
|
||||
{companies.map((company) => (
|
||||
<SelectItem key={company.company_code} value={company.company_code}>
|
||||
{company.company_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedCompany !== "all" && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedCompany("all")} className="h-8 w-8 p-0">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">{t("roles.list.title")} ({roleGroups.length})</h2>
|
||||
|
||||
<Button onClick={handleCreateRole} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -346,15 +279,6 @@ export default function RolesPage() {
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
{/* 최고 관리자는 회사명 표시 */}
|
||||
{isSuperAdmin && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t("label.company")}</span>
|
||||
<span className="font-medium">
|
||||
{companies.find((c) => c.company_code === role.companyCode)?.company_name || role.companyCode}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
|
||||
@@ -13,13 +13,9 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { roleAPI, RoleGroup } from "@/lib/api/role";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { AlertCircle, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { companyAPI } from "@/lib/api/company";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
interface RoleFormModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -42,14 +38,10 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
|
||||
const { user: currentUser } = useAuth();
|
||||
const isEditMode = !!editingRole;
|
||||
|
||||
// 최고 관리자 여부
|
||||
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState({
|
||||
authName: "",
|
||||
authCode: "",
|
||||
companyCode: currentUser?.companyCode || "",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
@@ -59,14 +51,9 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
|
||||
const [alertMessage, setAlertMessage] = useState("");
|
||||
const [alertType, setAlertType] = useState<"success" | "error" | "info">("info");
|
||||
|
||||
// 회사 목록 (최고 관리자용)
|
||||
const [companies, setCompanies] = useState<Array<{ company_code: string; company_name: string }>>([]);
|
||||
const [isLoadingCompanies, setIsLoadingCompanies] = useState(false);
|
||||
const [companyComboOpen, setCompanyComboOpen] = useState(false);
|
||||
|
||||
// 폼 유효성 검사
|
||||
const isFormValid = useMemo(() => {
|
||||
return formData.authName.trim() !== "" && formData.authCode.trim() !== "" && formData.companyCode.trim() !== "";
|
||||
return formData.authName.trim() !== "" && formData.authCode.trim() !== "";
|
||||
}, [formData]);
|
||||
|
||||
// 알림 표시
|
||||
@@ -77,38 +64,14 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
|
||||
setTimeout(() => setShowAlert(false), 3000);
|
||||
}, []);
|
||||
|
||||
// 회사 목록 로드 (최고 관리자만)
|
||||
const loadCompanies = useCallback(async () => {
|
||||
if (!isSuperAdmin) return;
|
||||
|
||||
setIsLoadingCompanies(true);
|
||||
try {
|
||||
// companyAPI.getList()는 Promise<Company[]>를 반환하므로 직접 사용
|
||||
const companies = await companyAPI.getList();
|
||||
console.log("📋 회사 목록 로드 성공:", companies);
|
||||
setCompanies(companies);
|
||||
} catch (error) {
|
||||
console.error("❌ 회사 목록 로드 오류:", error);
|
||||
displayAlert("회사 목록을 불러오는데 실패했습니다.", "error");
|
||||
} finally {
|
||||
setIsLoadingCompanies(false);
|
||||
}
|
||||
}, [isSuperAdmin, displayAlert]);
|
||||
|
||||
// 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// 최고 관리자이고 생성 모드일 때만 회사 목록 로드
|
||||
if (isSuperAdmin && !isEditMode) {
|
||||
loadCompanies();
|
||||
}
|
||||
|
||||
if (isEditMode && editingRole) {
|
||||
// 수정 모드: 기존 데이터 로드
|
||||
setFormData({
|
||||
authName: editingRole.authName || "",
|
||||
authCode: editingRole.authCode || "",
|
||||
companyCode: editingRole.companyCode || "",
|
||||
status: editingRole.status || "active",
|
||||
});
|
||||
} else {
|
||||
@@ -116,13 +79,12 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
|
||||
setFormData({
|
||||
authName: "",
|
||||
authCode: "",
|
||||
companyCode: currentUser?.companyCode || "",
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
setShowAlert(false);
|
||||
}
|
||||
}, [isOpen, isEditMode, editingRole, currentUser?.companyCode, isSuperAdmin, loadCompanies]);
|
||||
}, [isOpen, isEditMode, editingRole]);
|
||||
|
||||
// 입력 핸들러
|
||||
const handleInputChange = useCallback((field: string, value: string) => {
|
||||
@@ -148,11 +110,11 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
|
||||
status: formData.status,
|
||||
});
|
||||
} else {
|
||||
// 생성
|
||||
// 생성 (단일 테넌시: 현재 사용자 회사 코드 사용)
|
||||
response = await roleAPI.create({
|
||||
authName: formData.authName,
|
||||
authCode: formData.authCode,
|
||||
companyCode: formData.companyCode,
|
||||
companyCode: currentUser?.companyCode || "",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -226,102 +188,6 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 회사 (수정 모드에서는 비활성화) */}
|
||||
{isEditMode ? (
|
||||
<div>
|
||||
<Label htmlFor="companyCode" className="text-xs sm:text-sm">
|
||||
회사
|
||||
</Label>
|
||||
<Input
|
||||
id="companyCode"
|
||||
value={formData.companyCode}
|
||||
disabled
|
||||
className="bg-muted h-8 cursor-not-allowed text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">회사 코드는 수정할 수 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Label htmlFor="companyCode" className="text-xs sm:text-sm">
|
||||
회사 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
{isSuperAdmin ? (
|
||||
<>
|
||||
<Popover open={companyComboOpen} onOpenChange={setCompanyComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={companyComboOpen}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={isLoading || isLoadingCompanies}
|
||||
>
|
||||
{formData.companyCode
|
||||
? companies.find((company) => company.company_code === formData.companyCode)?.company_name ||
|
||||
formData.companyCode
|
||||
: "회사 선택..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="회사 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">
|
||||
{isLoadingCompanies ? "로딩 중..." : "회사를 찾을 수 없습니다."}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{companies.map((company) => (
|
||||
<CommandItem
|
||||
key={company.company_code}
|
||||
value={`${company.company_code} ${company.company_name}`}
|
||||
onSelect={() => {
|
||||
handleInputChange("companyCode", company.company_code);
|
||||
setCompanyComboOpen(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.companyCode === company.company_code ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{company.company_name}</span>
|
||||
<span className="text-muted-foreground text-[10px]">{company.company_code}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||
모든 회사에 권한 그룹을 생성할 수 있습니다.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
id="companyCode"
|
||||
value={formData.companyCode}
|
||||
disabled
|
||||
className="bg-muted h-8 cursor-not-allowed text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||
자신의 회사에만 권한 그룹을 생성할 수 있습니다.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 상태 (수정 모드에서만 표시) */}
|
||||
{isEditMode && (
|
||||
<div>
|
||||
|
||||
@@ -39,7 +39,7 @@ function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInputProps
|
||||
const { data: users } = useCompanyUsers();
|
||||
|
||||
const filteredMentionUsers = mentionQuery !== null && users
|
||||
? users.filter((u) => u.userName.toLowerCase().includes(mentionQuery.toLowerCase())).slice(0, 5)
|
||||
? users.filter((u) => u.userName?.toLowerCase().includes(mentionQuery.toLowerCase())).slice(0, 5)
|
||||
: [];
|
||||
|
||||
// Revoke object URLs when pending files change or component unmounts
|
||||
|
||||
@@ -35,10 +35,11 @@ export function NewRoomModal({ open, onOpenChange, userStatuses }: NewRoomModalP
|
||||
const createRoom = useCreateRoom();
|
||||
const { selectRoom } = useMessengerContext();
|
||||
|
||||
const q = search.toLowerCase();
|
||||
const filtered = users.filter(
|
||||
(u) =>
|
||||
u.userName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(u.deptName && u.deptName.toLowerCase().includes(search.toLowerCase()))
|
||||
u.userName?.toLowerCase().includes(q) ||
|
||||
u.deptName?.toLowerCase().includes(q)
|
||||
);
|
||||
|
||||
const toggleUser = (userId: string) => {
|
||||
|
||||
Reference in New Issue
Block a user