forked from baron/baron-sso
feat: implement multi-tenant member management and UI improvements
- Add multi-tenant support (isAddTenant, isRemoveTenant) to backend UpdateUser API. - Update UserRepository to support searching in company_codes array. - Implement table sorting and align search bar layout in adminfront. - Add 'Assign Existing Member' and 'Exclude from Organization' features to TenantUsersPage. - Auto-populate tenantSlug in UserCreatePage via query parameters. - Add necessary localization keys for new UI elements. Resolves #644, #639, #642, #641
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Download,
|
||||
FileSpreadsheet,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -66,6 +69,8 @@ function TenantListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [sortKey, setSortKey] = React.useState<string>("name");
|
||||
const [sortOrder, setSortOrder] = React.useState<"asc" | "desc">("asc");
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [importMessage, setImportMessage] = React.useState("");
|
||||
const [previewRows, setPreviewRows] = React.useState<
|
||||
@@ -113,6 +118,15 @@ function TenantListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
if (sortKey === key) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteBulkMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => deleteTenantsBulk(ids),
|
||||
onSuccess: () => {
|
||||
@@ -198,14 +212,27 @@ function TenantListPage() {
|
||||
|
||||
const allTenants = query.data?.items ?? [];
|
||||
const tenants = React.useMemo(() => {
|
||||
if (!search.trim()) return allTenants;
|
||||
const term = search.toLowerCase();
|
||||
return allTenants.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(term) ||
|
||||
t.slug.toLowerCase().includes(term),
|
||||
);
|
||||
}, [allTenants, search]);
|
||||
let filtered = allTenants;
|
||||
if (search.trim()) {
|
||||
const term = search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(term) ||
|
||||
t.slug.toLowerCase().includes(term),
|
||||
);
|
||||
}
|
||||
return [...filtered].sort((a, b) => {
|
||||
const valA = (a[sortKey as keyof typeof a] || "")
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
const valB = (b[sortKey as keyof typeof b] || "")
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
if (valA < valB) return sortOrder === "asc" ? -1 : 1;
|
||||
if (valA > valB) return sortOrder === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [allTenants, search, sortKey, sortOrder]);
|
||||
|
||||
const deletableTenants = React.useMemo(
|
||||
() => tenants.filter((tenant) => !isSeedTenant(tenant)),
|
||||
@@ -460,18 +487,23 @@ function TenantListPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="mb-6 flex items-center gap-4 flex-shrink-0">
|
||||
<div className="mb-6 flex flex-wrap items-end gap-4 flex-shrink-0">
|
||||
<div className="relative flex-1 min-w-[240px] max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.list.search_placeholder",
|
||||
"테넌트 이름 또는 슬러그 검색...",
|
||||
)}
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
|
||||
{t("ui.admin.tenants.list.search_label", "테넌트 검색")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.list.search_placeholder",
|
||||
"테넌트 이름 또는 슬러그 검색...",
|
||||
)}
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -498,26 +530,90 @@ function TenantListPage() {
|
||||
}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[220px]">
|
||||
{t("ui.admin.tenants.table.id", "ID")}
|
||||
<TableHead
|
||||
className="min-w-[220px] cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("id")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{t("ui.admin.tenants.table.id", "ID")}
|
||||
{sortKey === "id" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("name")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
{sortKey === "name" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.type", "TYPE")}
|
||||
<TableHead>{t("ui.admin.tenants.table.type", "TYPE")}</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("slug")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||
{sortKey === "slug" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("status")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||
{sortKey === "status" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("memberCount")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||
{sortKey === "memberCount" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("updatedAt")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
||||
{sortKey === "updatedAt" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.tenants.table.actions", "ACTIONS")}
|
||||
@@ -527,8 +623,11 @@ function TenantListPage() {
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
<TableCell colSpan={9} className="h-24 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Mail, User } from "lucide-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Mail, MoreHorizontal, Plus, User, UserPlus, UserMinus, Loader2 } from "lucide-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../../../components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -16,12 +23,14 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { fetchTenant, fetchUsers } from "../../../lib/adminApi";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import { fetchTenant, fetchUsers, updateUser } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
function TenantUsersPage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
const tenantId = params.tenantId ?? "";
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 테넌트의 슬러그(tenantSlug)를 먼저 가져옴
|
||||
const tenantQuery = useQuery({
|
||||
@@ -39,17 +48,51 @@ function TenantUsersPage() {
|
||||
enabled: !!tenantSlug,
|
||||
});
|
||||
|
||||
const removeTenantMutation = useMutation({
|
||||
mutationFn: ({ userId, slug }: { userId: string; slug: string }) =>
|
||||
updateUser(userId, { tenantSlug: slug, isRemoveTenant: true }),
|
||||
onSuccess: () => {
|
||||
toast.success(t("msg.admin.tenants.members.remove_success", "조직에서 제외되었습니다."));
|
||||
usersQuery.refetch();
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || t("msg.admin.tenants.members.remove_error", "제외 실패"));
|
||||
},
|
||||
});
|
||||
|
||||
const handleRemoveMember = (userId: string, userName: string) => {
|
||||
if (!tenantSlug) return;
|
||||
if (window.confirm(t("msg.admin.tenants.members.remove_confirm", "'{{name}}'님을 이 조직에서 제외하시겠습니까?", { name: userName }))) {
|
||||
removeTenantMutation.mutate({ userId, slug: tenantSlug });
|
||||
}
|
||||
};
|
||||
|
||||
const users = usersQuery.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<CardHeader className="flex-shrink-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User size={18} className="text-primary" />
|
||||
{t("ui.admin.tenants.members.title", "Tenant Members ({{count}})", {
|
||||
count: users.length,
|
||||
})}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" asChild className="gap-2">
|
||||
<Link to={`/users?addTenant=${tenantSlug}`}>
|
||||
<UserPlus size={16} />
|
||||
{t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="sm" asChild className="gap-2">
|
||||
<Link to={`/users/new?tenantSlug=${tenantSlug}`}>
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.tenants.members.create_new", "신규 멤버 생성")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
@@ -69,13 +112,25 @@ function TenantUsersPage() {
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px] text-right">
|
||||
{t("ui.admin.tenants.members.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 && (
|
||||
{usersQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-20">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="animate-spin text-muted-foreground" size={24} />
|
||||
<span className="text-sm text-muted-foreground">{t("ui.common.loading", "Loading...")}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
colSpan={5}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
@@ -84,33 +139,59 @@ function TenantUsersPage() {
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-semibold">{user.name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<Mail size={12} className="text-muted-foreground" />
|
||||
{user.email}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{t(
|
||||
`ui.common.role.${user.role}`,
|
||||
user.role.replace("_", " "),
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={user.status === "active" ? "default" : "muted"}
|
||||
>
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal size={16} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/users/${user.id}`}>
|
||||
<User size={14} className="mr-2" />
|
||||
{t("ui.admin.tenants.members.view_profile", "상세 정보")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => handleRemoveMember(user.id, user.name)}
|
||||
disabled={removeTenantMutation.isPending}
|
||||
>
|
||||
<UserMinus size={14} className="mr-2" />
|
||||
{t("ui.admin.tenants.members.remove", "조직에서 제외")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-semibold">{user.name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<Mail size={12} className="text-muted-foreground" />
|
||||
{user.email}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{t(
|
||||
`ui.common.role.${user.role}`,
|
||||
user.role.replace("_", " "),
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={user.status === "active" ? "default" : "muted"}
|
||||
>
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -104,6 +104,7 @@ function createEmptyAppointment(): AppointmentDraft {
|
||||
|
||||
function UserCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [generatedPassword, setGeneratedPassword] = React.useState<
|
||||
@@ -144,7 +145,7 @@ function UserCreatePage() {
|
||||
password: "",
|
||||
name: "",
|
||||
phone: "",
|
||||
tenantSlug: "",
|
||||
tenantSlug: searchParams.get("tenantSlug") ?? "",
|
||||
department: "",
|
||||
position: "",
|
||||
jobTitle: "",
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
FileDown,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -11,9 +14,10 @@ import {
|
||||
Settings2,
|
||||
Trash2,
|
||||
User,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -71,6 +75,8 @@ type UserSchemaField = {
|
||||
|
||||
function UserListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const addTenantSlug = searchParams.get("addTenant");
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [searchDraft, setSearchDraft] = React.useState("");
|
||||
@@ -79,6 +85,8 @@ function UserListPage() {
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
||||
const [sortKey, setSortKey] = React.useState<string>("name");
|
||||
const [sortOrder, setSortOrder] = React.useState<"asc" | "desc">("asc");
|
||||
const limit = 1000;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
@@ -151,6 +159,15 @@ function UserListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
if (sortKey === key) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: (includeIds: boolean) =>
|
||||
exportUsersCSV(search, selectedCompany, includeIds),
|
||||
@@ -184,6 +201,41 @@ function UserListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const addTenantMutation = useMutation({
|
||||
mutationFn: ({ userId, slug }: { userId: string; slug: string }) =>
|
||||
updateUser(userId, { tenantSlug: slug, isAddTenant: true }),
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.users.add_tenant_success",
|
||||
"해당 테넌트에 추가되었습니다.",
|
||||
),
|
||||
);
|
||||
query.refetch();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
t("msg.admin.users.add_tenant_error", "추가 실패"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddTenant = (userId: string, userName: string) => {
|
||||
if (!addTenantSlug) return;
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.admin.users.add_tenant_confirm",
|
||||
"'{{name}}'님을 '{{tenant}}' 테넌트에 추가하시겠습니까?",
|
||||
{ name: userName, tenant: addTenantSlug },
|
||||
),
|
||||
)
|
||||
) {
|
||||
addTenantMutation.mutate({ userId, slug: addTenantSlug });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setSearch(searchDraft);
|
||||
setPage(1);
|
||||
@@ -210,6 +262,20 @@ function UserListPage() {
|
||||
: null;
|
||||
|
||||
const items = query.data?.items ?? [];
|
||||
const sortedItems = React.useMemo(() => {
|
||||
return [...items].sort((a, b) => {
|
||||
const valA = (a[sortKey as keyof typeof a] || "")
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
const valB = (b[sortKey as keyof typeof b] || "")
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
if (valA < valB) return sortOrder === "asc" ? -1 : 1;
|
||||
if (valA > valB) return sortOrder === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [items, sortKey, sortOrder]);
|
||||
|
||||
const total = query.data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
@@ -414,24 +480,29 @@ function UserListPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="mb-6 flex flex-wrap items-center gap-4 flex-shrink-0">
|
||||
<div className="mb-6 flex flex-wrap items-end gap-4 flex-shrink-0">
|
||||
<div className="relative flex-1 min-w-[240px] max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.users.list.search_placeholder",
|
||||
"이름 또는 이메일 검색...",
|
||||
)}
|
||||
className="pl-9"
|
||||
value={searchDraft}
|
||||
onChange={(e) => setSearchDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
|
||||
{t("ui.admin.users.list.search_label", "사용자 검색")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.users.list.search_placeholder",
|
||||
"이름 또는 이메일 검색...",
|
||||
)}
|
||||
className="pl-9"
|
||||
value={searchDraft}
|
||||
onChange={(e) => setSearchDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground whitespace-nowrap">
|
||||
{t("ui.admin.users.list.filter.tenant", "테넌트 필터:")}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground whitespace-nowrap">
|
||||
{t("ui.admin.users.list.filter.tenant", "테넌트 필터")}
|
||||
</span>
|
||||
<select
|
||||
className="flex h-9 w-[200px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
@@ -451,7 +522,11 @@ function UserListPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button variant="secondary" onClick={handleSearch}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleSearch}
|
||||
className="mb-0.5"
|
||||
>
|
||||
{t("ui.common.search", "검색")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -478,17 +553,50 @@ function UserListPage() {
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[220px]">
|
||||
{t("ui.admin.users.list.table.id", "ID")}
|
||||
<TableHead
|
||||
className="min-w-[220px] cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("id")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{t("ui.admin.users.list.table.id", "ID")}
|
||||
{sortKey === "id" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[200px]">
|
||||
{t(
|
||||
"ui.admin.users.list.table.name_email",
|
||||
"이름 / 이메일 / 전화번호",
|
||||
)}
|
||||
<TableHead
|
||||
className="min-w-[200px] cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("name")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{t(
|
||||
"ui.admin.users.list.table.name_email",
|
||||
"이름 / 이메일 / 전화번호",
|
||||
)}
|
||||
{sortKey === "name" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.users.list.table.status", "STATUS")}
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("status")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{t("ui.admin.users.list.table.status", "STATUS")}
|
||||
{sortKey === "status" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
</div>
|
||||
</TableHead>
|
||||
{/* Dynamic Columns from Schema */}
|
||||
{userSchema.map(
|
||||
@@ -499,8 +607,19 @@ function UserListPage() {
|
||||
</TableHead>
|
||||
),
|
||||
)}
|
||||
<TableHead>
|
||||
{t("ui.admin.users.list.table.created", "CREATED")}
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("createdAt")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{t("ui.admin.users.list.table.created", "CREATED")}
|
||||
{sortKey === "createdAt" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.users.list.table.actions", "ACTIONS")}
|
||||
@@ -514,11 +633,14 @@ function UserListPage() {
|
||||
colSpan={6 + userSchema.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
{!query.isLoading && sortedItems.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6 + userSchema.length}
|
||||
@@ -531,7 +653,7 @@ function UserListPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{items.map((user) => (
|
||||
{sortedItems.map((user) => (
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className={
|
||||
@@ -636,6 +758,23 @@ function UserListPage() {
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{addTenantSlug && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-primary"
|
||||
onClick={() =>
|
||||
handleAddTenant(user.id, user.name)
|
||||
}
|
||||
title={t(
|
||||
"ui.admin.users.list.add_to_tenant",
|
||||
"테넌트에 추가",
|
||||
)}
|
||||
disabled={addTenantMutation.isPending}
|
||||
>
|
||||
<UserPlus size={16} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -433,6 +433,8 @@ export type UserUpdateRequest = {
|
||||
role?: string;
|
||||
status?: string;
|
||||
tenantSlug?: string;
|
||||
isAddTenant?: boolean;
|
||||
isRemoveTenant?: boolean;
|
||||
department?: string;
|
||||
position?: string;
|
||||
jobTitle?: string;
|
||||
|
||||
@@ -1176,14 +1176,49 @@ primary = "Representative Affiliated Tenant"
|
||||
title = "Affiliation & Organization Info"
|
||||
|
||||
[ui.admin.users.list]
|
||||
add = "User Add"
|
||||
add = "Add User"
|
||||
add_to_tenant = "Add to Tenant"
|
||||
bulk_import = "Bulk Import"
|
||||
empty = "Empty"
|
||||
fetch_error = "Fetch Error"
|
||||
search_placeholder = "Search Placeholder"
|
||||
subtitle = "Subtitle"
|
||||
empty = "No users found."
|
||||
fetch_error = "Failed to fetch user list."
|
||||
search_label = "Search Users"
|
||||
search_placeholder = "Search by name or email..."
|
||||
subtitle = "View and manage system users."
|
||||
toggle_status = "{{name}} active status"
|
||||
title = "User Manage"
|
||||
title = "User Management"
|
||||
|
||||
[msg.admin.users]
|
||||
add_tenant_confirm = "Are you sure you want to add '{{name}}' to '{{tenant}}' tenant?"
|
||||
add_tenant_error = "An error occurred while adding the tenant."
|
||||
add_tenant_success = "Successfully added to the tenant."
|
||||
export_error = "Failed to export users."
|
||||
status_error = "Failed to change user status."
|
||||
self_delete_blocked = "You cannot delete your own account."
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
add_existing = "Assign Existing Member"
|
||||
create_new = "Create New Member"
|
||||
remove = "Exclude from Organization"
|
||||
title = "Tenant Members ({{count}})"
|
||||
view_profile = "View Profile"
|
||||
|
||||
[ui.admin.tenants.members.table]
|
||||
actions = "ACTIONS"
|
||||
email = "EMAIL"
|
||||
name = "NAME"
|
||||
role = "ROLE"
|
||||
status = "STATUS"
|
||||
|
||||
[msg.admin.tenants.members]
|
||||
empty = "No members in this organization."
|
||||
remove_confirm = "Are you sure you want to exclude '{{name}}' from this organization?"
|
||||
remove_error = "An error occurred while excluding from organization."
|
||||
remove_success = "Successfully excluded from organization."
|
||||
|
||||
[ui.admin.tenants.list]
|
||||
search_label = "Search Tenants"
|
||||
search_placeholder = "Search by name or slug..."
|
||||
title = "Tenant List"
|
||||
|
||||
[ui.admin.users.list.breadcrumb]
|
||||
list = "List"
|
||||
|
||||
@@ -1179,14 +1179,49 @@ title = "소속 및 조직 정보"
|
||||
|
||||
[ui.admin.users.list]
|
||||
add = "사용자 추가"
|
||||
add_to_tenant = "테넌트에 추가"
|
||||
bulk_import = "일괄 임포트"
|
||||
empty = "검색 결과가 없습니다."
|
||||
fetch_error = "사용자 목록 조회에 실패했습니다."
|
||||
search_label = "사용자 검색"
|
||||
search_placeholder = "이름 또는 이메일 검색..."
|
||||
subtitle = "시스템 사용자를 조회하고 관리합니다."
|
||||
toggle_status = "{{name}} 활성 상태"
|
||||
title = "사용자 관리"
|
||||
|
||||
[msg.admin.users]
|
||||
add_tenant_confirm = "'{{name}}'님을 '{{tenant}}' 테넌트에 추가하시겠습니까?"
|
||||
add_tenant_error = "테넌트 추가 중 오류가 발생했습니다."
|
||||
add_tenant_success = "해당 테넌트에 추가되었습니다."
|
||||
export_error = "사용자 내보내기에 실패했습니다."
|
||||
status_error = "사용자 상태 변경에 실패했습니다."
|
||||
self_delete_blocked = "본인 계정은 삭제할 수 없습니다."
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
add_existing = "기존 멤버 배정"
|
||||
create_new = "신규 멤버 생성"
|
||||
remove = "조직에서 제외"
|
||||
title = "테넌트 멤버 ({{count}})"
|
||||
view_profile = "상세 정보"
|
||||
|
||||
[ui.admin.tenants.members.table]
|
||||
actions = "ACTIONS"
|
||||
email = "EMAIL"
|
||||
name = "NAME"
|
||||
role = "ROLE"
|
||||
status = "STATUS"
|
||||
|
||||
[msg.admin.tenants.members]
|
||||
empty = "소속된 사용자가 없습니다."
|
||||
remove_confirm = "'{{name}}'님을 이 조직에서 제외하시겠습니까?"
|
||||
remove_error = "조직에서 제외하는 중 오류가 발생했습니다."
|
||||
remove_success = "조직에서 제외되었습니다."
|
||||
|
||||
[ui.admin.tenants.list]
|
||||
search_label = "테넌트 검색"
|
||||
search_placeholder = "테넌트 이름 또는 슬러그 검색..."
|
||||
title = "테넌트 목록"
|
||||
|
||||
[ui.admin.users.list.breadcrumb]
|
||||
list = "List"
|
||||
section = "Users"
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -54,6 +55,7 @@ type User struct {
|
||||
Role string `gorm:"column:role;default:'user';not null" json:"role"` // super_admin, tenant_admin, rp_admin, user
|
||||
AffiliationType string `gorm:"column:affiliation_type" json:"affiliationType"`
|
||||
CompanyCode string `gorm:"column:company_code;index" json:"companyCode"`
|
||||
CompanyCodes pq.StringArray `gorm:"column:company_codes;type:text[]" json:"companyCodes"`
|
||||
TenantID *string `gorm:"column:tenant_id;type:uuid;index" json:"tenantId,omitempty"`
|
||||
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
|
||||
RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용
|
||||
|
||||
@@ -1432,6 +1432,8 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
Role *string `json:"role"`
|
||||
Status *string `json:"status"`
|
||||
CompanyCode *string `json:"companyCode"`
|
||||
IsAddTenant bool `json:"isAddTenant"`
|
||||
IsRemoveTenant bool `json:"isRemoveTenant"`
|
||||
Department *string `json:"department"`
|
||||
Position *string `json:"position"`
|
||||
JobTitle *string `json:"jobTitle"`
|
||||
@@ -1446,9 +1448,9 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
req.Metadata = mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner)
|
||||
|
||||
// [New] Tenant Admin restriction: Cannot change companyCode
|
||||
// [New] Tenant Admin restriction: Cannot change companyCode (except when adding/removing secondary membership)
|
||||
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
|
||||
if req.CompanyCode != nil && *req.CompanyCode != requester.CompanyCode {
|
||||
if !req.IsAddTenant && !req.IsRemoveTenant && req.CompanyCode != nil && *req.CompanyCode != requester.CompanyCode {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant admins cannot change user's tenant")
|
||||
}
|
||||
}
|
||||
@@ -1531,38 +1533,80 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
if req.CompanyCode != nil {
|
||||
code := strings.TrimSpace(*req.CompanyCode)
|
||||
traits["companyCode"] = code
|
||||
// Resolve TenantID for Kratos Trait
|
||||
if h.TenantService != nil && code != "" {
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
|
||||
traits["tenant_id"] = tenant.ID
|
||||
}
|
||||
}
|
||||
|
||||
// Add to existingCodes if not present
|
||||
found := false
|
||||
for _, existing := range existingCodes {
|
||||
if existing == code {
|
||||
found = true
|
||||
break
|
||||
if req.IsAddTenant {
|
||||
// Add to existingCodes if not present
|
||||
found := false
|
||||
for _, existing := range existingCodes {
|
||||
if existing == code {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && code != "" {
|
||||
existingCodes = append(existingCodes, code)
|
||||
}
|
||||
} else if req.IsRemoveTenant {
|
||||
// Remove from existingCodes
|
||||
var newCodes []string
|
||||
for _, existing := range existingCodes {
|
||||
if existing != code {
|
||||
newCodes = append(newCodes, existing)
|
||||
}
|
||||
}
|
||||
existingCodes = newCodes
|
||||
|
||||
// If removing the primary company code, pick another one as primary if available
|
||||
currentPrimary := extractTraitString(traits, "companyCode")
|
||||
if currentPrimary == code {
|
||||
if len(existingCodes) > 0 {
|
||||
traits["companyCode"] = existingCodes[0]
|
||||
if h.TenantService != nil {
|
||||
if t, err := h.TenantService.GetTenantBySlug(c.Context(), existingCodes[0]); err == nil && t != nil {
|
||||
traits["tenant_id"] = t.ID
|
||||
}
|
||||
}
|
||||
} else {
|
||||
traits["companyCode"] = ""
|
||||
traits["tenant_id"] = ""
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal update: replace primary company code and ensure it's in existingCodes
|
||||
traits["companyCode"] = code
|
||||
// Resolve TenantID for Kratos Trait
|
||||
if h.TenantService != nil && code != "" {
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
|
||||
traits["tenant_id"] = tenant.ID
|
||||
}
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, existing := range existingCodes {
|
||||
if existing == code {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && code != "" {
|
||||
existingCodes = append(existingCodes, code)
|
||||
}
|
||||
}
|
||||
if !found && code != "" {
|
||||
existingCodes = append(existingCodes, code)
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate and save back companyCodes
|
||||
var uniqueCodes []string
|
||||
var codesToSave []string
|
||||
seenCodes := map[string]bool{}
|
||||
for _, c := range existingCodes {
|
||||
if !seenCodes[c] && c != "" {
|
||||
seenCodes[c] = true
|
||||
uniqueCodes = append(uniqueCodes, c)
|
||||
codesToSave = append(codesToSave, c)
|
||||
}
|
||||
}
|
||||
if len(uniqueCodes) > 0 {
|
||||
traits["companyCodes"] = uniqueCodes
|
||||
if len(codesToSave) > 0 {
|
||||
traits["companyCodes"] = codesToSave
|
||||
} else {
|
||||
delete(traits, "companyCodes")
|
||||
}
|
||||
|
||||
if req.Department != nil {
|
||||
@@ -1927,6 +1971,17 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
|
||||
UpdatedAt: identity.UpdatedAt,
|
||||
}
|
||||
|
||||
// [New] Sync multi-tenant codes
|
||||
if codes, ok := traits["companyCodes"].([]interface{}); ok {
|
||||
for _, v := range codes {
|
||||
if str, ok := v.(string); ok && str != "" {
|
||||
user.CompanyCodes = append(user.CompanyCodes, str)
|
||||
}
|
||||
}
|
||||
} else if codes, ok := traits["companyCodes"].([]string); ok {
|
||||
user.CompanyCodes = codes
|
||||
}
|
||||
|
||||
// 1. Try to get tenant_id directly from Kratos traits first (Fastest & most reliable)
|
||||
tID := extractTraitString(traits, "tenant_id")
|
||||
if tID != "" {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/lib/pq"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
@@ -156,11 +157,12 @@ func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string
|
||||
}
|
||||
var results []result
|
||||
|
||||
// Search by company_code directly. Normalize inputs using LOWER for robust matching.
|
||||
err := r.db.WithContext(ctx).Model(&domain.User{}).
|
||||
Select("LOWER(company_code) as company_code, count(*) as count").
|
||||
Where("LOWER(company_code) IN ?", lowerStrings(codes)).
|
||||
Group("LOWER(company_code)").
|
||||
// Search by company_codes array using unnest and overlap.
|
||||
// This ensures users with multiple memberships are counted in each tenant they belong to.
|
||||
err := r.db.WithContext(ctx).Table("users").
|
||||
Select("unnest(company_codes) as company_code, count(*) as count").
|
||||
Where("company_codes && ?", pq.Array(lowerStrings(codes))).
|
||||
Group("company_code").
|
||||
Scan(&results).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -168,7 +170,7 @@ func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string
|
||||
|
||||
counts := make(map[string]int64)
|
||||
for _, res := range results {
|
||||
counts[res.CompanyCode] = res.Count
|
||||
counts[strings.ToLower(res.CompanyCode)] = res.Count
|
||||
}
|
||||
|
||||
// Ensure all requested codes are present in results (even if count is 0)
|
||||
@@ -196,15 +198,15 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
|
||||
db := r.db.WithContext(ctx).Model(&domain.User{})
|
||||
|
||||
if companyCode != "" {
|
||||
// [Matrix Fix] Match users either by their primary company code OR by the slug of the department they are attached to
|
||||
// [Matrix Fix] Match users either by their primary company code OR by being in the company_codes array OR by tenant slug
|
||||
db = db.Joins("LEFT JOIN tenants ON users.tenant_id = tenants.id").
|
||||
Where("users.company_code = ? OR tenants.slug = ?", companyCode, companyCode)
|
||||
Where("users.company_code = ? OR ? = ANY(users.company_codes) OR tenants.slug = ?", companyCode, companyCode, companyCode)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
searchTerm := "%" + search + "%"
|
||||
db = db.Where("(users.email LIKE ? OR users.name LIKE ? OR users.company_code LIKE ? OR users.metadata::text LIKE ?)",
|
||||
searchTerm, searchTerm, searchTerm, searchTerm)
|
||||
db = db.Where("(users.email LIKE ? OR users.name LIKE ? OR users.company_code LIKE ? OR ? = ANY(users.company_codes) OR users.metadata::text LIKE ?)",
|
||||
searchTerm, searchTerm, searchTerm, search, searchTerm)
|
||||
}
|
||||
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
|
||||
Reference in New Issue
Block a user