- {/* Селектор точки (только для self_owner/net_manager/admin) */}
- {shouldLoadPoints && (
-
-
+
+ {/* Заголовок: кол-во + кнопка */}
+
+
+ {!isLoading && services.length > 0 && (
+
+ {t("serviceCount", { count: services.length })}
+
+ )}
- )}
-
- {/* Таблица */}
-
-
- {t("tableTitle")}
-
-
-
- {/* Состояние загрузки */}
- {error ? (
- /* Ошибка загрузки */
-
+
+ {/* Кнопка добавления скрыта для staff */}
+ {!isStaff &&
+ (isMobile ? (
+
) : (
- <>
- {/* Таблица */}
-
-
-
-
-
-
- {isLoading ? (
-
- {Array.from({ length: tableColumns.length }).map(
- (_, i) => (
-
-
-
- )
- )}
-
- ) : !data?.services || data.services.length === 0 ? (
-
-
- {t("noData")}
-
-
- ) : (
- data.services.map(service => (
-
- ))
- )}
-
-
-
- >
- )}
-
-
+
+ ))}
+
+
+ {/* Список услуг */}
+
- {/* Диалог добавления услуги */}
+ {/* Диалог добавления */}
);
diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts
deleted file mode 100644
index c5e3841..0000000
--- a/src/features/settings/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { SettingsContent } from "./settings-content";
-export { SettingsHeader } from "./settings-header";
diff --git a/src/features/settings/settings-content.tsx b/src/features/settings/settings-content.tsx
deleted file mode 100644
index ffbb76f..0000000
--- a/src/features/settings/settings-content.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-"use client";
-
-import React from "react";
-import { useTranslations } from "next-intl";
-import { LocaleSwitcher, ThemeToggle } from "@/src/widgets";
-
-const SettingsContent: React.FC = () => {
- const t = useTranslations("Settings");
-
- return (
-
-
-
-
-
-
-
{t("language")}
-
-
-
-
-
-
-
{t("notifications")}
-
-
-
-
- );
-};
-
-export { SettingsContent };
diff --git a/src/features/settings/settings-header.tsx b/src/features/settings/settings-header.tsx
deleted file mode 100644
index 2214488..0000000
--- a/src/features/settings/settings-header.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from "react";
-import { SidebarTrigger } from "@/src/entities/sidebar";
-import { Separator } from "@/src/entities/separator";
-import { HeaderTitle } from "@/src/entities/header-title";
-
-export const SettingsHeader: React.FC = () => {
- return (
-
- );
-};
-
-export default SettingsHeader;
diff --git a/src/features/staff/components/drawer/employee-drawer.tsx b/src/features/staff/components/drawer/employee-drawer.tsx
new file mode 100644
index 0000000..df4e2e3
--- /dev/null
+++ b/src/features/staff/components/drawer/employee-drawer.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+} from "@/src/entities/sheet";
+import {
+ Drawer,
+ DrawerContent,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/src/entities/drawer";
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/src/entities/tabs";
+import { Avatar, AvatarImage, AvatarFallback, Badge } from "@/src/entities";
+import type { IEmployeeDto } from "@/src/shared/types/user";
+import { useIsMobile } from "@/src/shared/hooks/use-mobile";
+import { getRoleKey } from "../../utils/format-role";
+import { ProfileTab } from "./profile-tab";
+import { ServicesTab } from "./services-tab";
+import { PortfolioTab } from "./portfolio-tab";
+import { getInitials } from "@/src/shared/utils/avatar";
+
+interface EmployeeDrawerProps {
+ employee: IEmployeeDto | null;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+/** Общий tab trigger стиль */
+const tabTriggerClass =
+ "flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-foreground data-[state=active]:bg-transparent data-[state=active]:shadow-none py-2.5 text-sm";
+
+/**
+ * Боковой drawer сотрудника
+ * Мобилка: Vaul bottom-sheet (свайп вниз)
+ * Десктоп: Sheet справа
+ */
+export function EmployeeDrawer({
+ employee,
+ open,
+ onOpenChange,
+}: EmployeeDrawerProps) {
+ const isMobile = useIsMobile();
+ const t = useTranslations("Staff");
+ const td = useTranslations("Staff.drawer");
+
+ if (!employee) return null;
+
+ const initials = getInitials(employee.first_name, employee.last_name);
+
+ // Общий контент (header + tabs) — переиспользуется в обоих режимах
+ const headerContent = (
+
+
+ {employee.avatar_url && (
+
+ )}
+ {initials}
+
+
+
+ {employee.first_name}
+
+
+ {employee.last_name}
+
+
+ {t(`roles.${getRoleKey(employee.role)}`)}
+
+
+
+ );
+
+ const tabsContent = (
+
+
+
+ {td("profile")}
+
+
+ {td("services")}
+
+
+ {td("portfolio")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ // Мобилка — bottom sheet (Vaul)
+ if (isMobile) {
+ return (
+
+
+
+
+ {employee.first_name} {employee.last_name}
+
+ {headerContent}
+
+ {tabsContent}
+
+
+ );
+ }
+
+ // Десктоп — side sheet
+ return (
+
+
+
+
+ {employee.first_name} {employee.last_name}
+
+ {headerContent}
+
+ {tabsContent}
+
+
+ );
+}
diff --git a/src/features/staff/components/drawer/index.ts b/src/features/staff/components/drawer/index.ts
new file mode 100644
index 0000000..fbc6b9c
--- /dev/null
+++ b/src/features/staff/components/drawer/index.ts
@@ -0,0 +1 @@
+export { EmployeeDrawer } from "./employee-drawer";
diff --git a/src/features/staff/components/drawer/portfolio-tab.tsx b/src/features/staff/components/drawer/portfolio-tab.tsx
new file mode 100644
index 0000000..d6e3689
--- /dev/null
+++ b/src/features/staff/components/drawer/portfolio-tab.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+
+/**
+ * Таб «Портфолио» — плейсхолдер (будет реализовано позже)
+ */
+export function PortfolioTab() {
+ const td = useTranslations("Staff.drawer");
+
+ return (
+
+ {/* Сетка плейсхолдеров работ */}
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+
+
+ {td("portfolioPlaceholder")}
+
+
+ );
+}
diff --git a/src/features/staff/components/drawer/profile-tab.tsx b/src/features/staff/components/drawer/profile-tab.tsx
new file mode 100644
index 0000000..9f3d143
--- /dev/null
+++ b/src/features/staff/components/drawer/profile-tab.tsx
@@ -0,0 +1,237 @@
+"use client";
+
+import { useState } from "react";
+import { useTranslations, useLocale } from "next-intl";
+import { Badge, Button, Skeleton } from "@/src/entities";
+import { Switch } from "@/src/entities/switch";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/src/entities/dialog";
+import { ESubscriptionPlan, type IEmployeeDto } from "@/src/shared/types/user";
+import { useLocation } from "@/src/shared/hooks/use-network-locations";
+import {
+ useChangeEmployeePermissions,
+ useActivateEmployee,
+ useDeactivateEmployee,
+} from "@/src/shared/hooks/user-staff";
+import { useCurrentUser } from "@/src/shared/hooks/use-users";
+import { formatDate } from "@/src/shared/utils/formater";
+
+interface ProfileTabProps {
+ employee: IEmployeeDto;
+}
+
+/**
+ * Таб «Профиль» — инфо, права, быстрая статистика, действия
+ */
+export function ProfileTab({ employee }: ProfileTabProps) {
+ const td = useTranslations("Staff.drawer");
+ const locale = useLocale();
+ const { data: currentUser } = useCurrentUser();
+ const isSoloPlan =
+ currentUser?.organization?.subscription?.plan === ESubscriptionPlan.SOLO;
+ const dateLocale = locale === "kz" ? "kk-KZ" : "ru-RU";
+
+ // Resolve location name
+ const { data: location, isLoading: locationLoading } = useLocation(
+ employee.location_id
+ );
+
+ // Мутации
+ const permissionsMutation = useChangeEmployeePermissions();
+ const activateMutation = useActivateEmployee();
+ const deactivateMutation = useDeactivateEmployee();
+
+ // Toggle permission
+ const handlePermissionToggle = (
+ key: "can_provide_services" | "can_manage_location_schedule",
+ checked: boolean
+ ) => {
+ permissionsMutation.mutate({
+ id: employee.id,
+ permissions: {
+ ...employee.permissions,
+ [key]: checked,
+ },
+ });
+ };
+
+ // Confirm dialog state
+ const [confirmOpen, setConfirmOpen] = useState(false);
+
+ // Activate/Deactivate с закрытием модалки
+ const handleConfirmedToggle = () => {
+ if (employee.active) {
+ deactivateMutation.mutate(employee.id, {
+ onSuccess: () => setConfirmOpen(false),
+ });
+ } else {
+ activateMutation.mutate(employee.id, {
+ onSuccess: () => setConfirmOpen(false),
+ });
+ }
+ };
+
+ return (
+
+ {/* Информация */}
+
+
+ {td("info")}
+
+
+
+
+ ) : (
+ location?.name || employee.location_id
+ )
+ }
+ />
+ setConfirmOpen(true) : undefined}
+ >
+ {employee.active ? td("activate") : td("deactivate")}
+
+ }
+ />
+
+
+
+
+ {/* Права доступа */}
+
+
+ {td("permissions")}
+
+
+
+ handlePermissionToggle("can_provide_services", v)
+ }
+ disabled={permissionsMutation.isPending}
+ />
+
+ handlePermissionToggle("can_manage_location_schedule", v)
+ }
+ disabled={permissionsMutation.isPending}
+ />
+
+
+
+ {/* Быстрая статистика — плейсхолдер */}
+
+
+ {td("quickStats")}
+
+
+
+
+
+
+
+
+ {/* Модалка подтверждения активации/деактивации */}
+
+
+ );
+}
+
+/** Строка информации — label: value */
+function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+/** Строка permission с toggle */
+function PermissionRow({
+ label,
+ description,
+ checked,
+ onCheckedChange,
+ disabled,
+}: {
+ label: string;
+ description: string;
+ checked: boolean;
+ onCheckedChange: (v: boolean) => void;
+ disabled?: boolean;
+}) {
+ return (
+
+
+
{label}
+
{description}
+
+
+
+ );
+}
+
+/** Карточка статистики */
+function StatCard({ value, label }: { value: string; label: string }) {
+ return (
+
+ );
+}
diff --git a/src/features/staff/components/drawer/services-tab.tsx b/src/features/staff/components/drawer/services-tab.tsx
new file mode 100644
index 0000000..8ddca9a
--- /dev/null
+++ b/src/features/staff/components/drawer/services-tab.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { Skeleton } from "@/src/entities";
+import type { IEmployeeDto } from "@/src/shared/types/user";
+import { useGetEmployeeServices } from "@/src/shared/hooks/user-staff";
+
+interface ServicesTabProps {
+ employee: IEmployeeDto;
+}
+
+/**
+ * Таб «Услуги» — список услуг сотрудника с ценами
+ */
+export function ServicesTab({ employee }: ServicesTabProps) {
+ const td = useTranslations("Staff.drawer");
+ const { data, isLoading } = useGetEmployeeServices(employee.id);
+
+ const services = data?.services || [];
+
+ // Статистика по ценам
+ const prices = services.map(s => s.price);
+ const minPrice = prices.length ? Math.min(...prices) : 0;
+ const maxPrice = prices.length ? Math.max(...prices) : 0;
+
+ return (
+
+ {/* Скелетон загрузки */}
+ {isLoading ? (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ ) : services.length === 0 ? (
+
+ {td("noServices")}
+
+ ) : (
+ <>
+ {/* Список услуг */}
+
+ {services.map(service => (
+
+
+
+
+
{service.name}
+
+ {service.duration_minutes} {td("min")}
+
+
+
+
+ {service.price.toLocaleString()} ₸
+
+
+ ))}
+
+
+ {/* Footer — статистика */}
+
+
+ {td("totalServices")}
+ {services.length}
+
+
+ {td("priceRange")}
+
+ {minPrice.toLocaleString()} — {maxPrice.toLocaleString()} ₸
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/features/staff/components/employee-row-actions.tsx b/src/features/staff/components/employee-row-actions.tsx
new file mode 100644
index 0000000..103a362
--- /dev/null
+++ b/src/features/staff/components/employee-row-actions.tsx
@@ -0,0 +1,359 @@
+"use client";
+
+import { useState } from "react";
+import { useTranslations } from "next-intl";
+import {
+ MoreHorizontal,
+ User,
+ Shield,
+ ArrowRightLeft,
+ UserCheck,
+ UserX,
+ Unlink,
+} from "lucide-react";
+import { Button } from "@/src/entities/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/src/entities/dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/src/entities";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/src/widgets/forms/dropdown-menu";
+import type { IEmployeeDto, TUserRole } from "@/src/shared/types/user";
+import { ESubscriptionPlan, EUserRole } from "@/src/shared/types/user";
+import { useCurrentUser } from "@/src/shared/hooks/use-users";
+import { useLocations } from "@/src/shared/hooks/use-network-locations";
+import {
+ useChangeEmployeeRole,
+ useTransferEmployee,
+ useActivateEmployee,
+ useDeactivateEmployee,
+ useRemoveEmployeeFromLocation,
+} from "@/src/shared/hooks/user-staff";
+
+interface EmployeeRowActionsProps {
+ employee: IEmployeeDto;
+ onViewProfile: () => void;
+}
+
+/**
+ * DropdownMenu для строки сотрудника:
+ * View profile, Change role, Transfer, Separator, Activate/Deactivate
+ */
+export function EmployeeRowActions({
+ employee,
+ onViewProfile,
+}: EmployeeRowActionsProps) {
+ const td = useTranslations("Staff.drawer");
+ const tRoles = useTranslations("Staff.roles");
+ const { data: currentUser } = useCurrentUser();
+
+ // Dialogs state
+ const [roleDialogOpen, setRoleDialogOpen] = useState(false);
+ const [transferDialogOpen, setTransferDialogOpen] = useState(false);
+ const [deactivateDialogOpen, setDeactivateDialogOpen] = useState(false);
+ const [detachDialogOpen, setDetachDialogOpen] = useState(false);
+
+ // Selected values
+ const [selectedRole, setSelectedRole] = useState
(employee.role);
+ const [selectedLocation, setSelectedLocation] = useState("");
+
+ // Mutations
+ const changeRole = useChangeEmployeeRole();
+ const transfer = useTransferEmployee();
+ const activate = useActivateEmployee();
+ const deactivate = useDeactivateEmployee();
+ const detach = useRemoveEmployeeFromLocation();
+
+ // Locations для transfer (только owner видит список)
+ const { data: locationsData } = useLocations();
+ const locations = locationsData?.locations || [];
+
+ // Проверки доступа
+ const isOwner = currentUser?.role === EUserRole.OWNER;
+ const isSelf = currentUser?.id === employee.id;
+ const plan = currentUser?.organization?.subscription?.plan;
+ const isSoloPlan = plan === ESubscriptionPlan.SOLO;
+ const isNetworkPlan = plan === ESubscriptionPlan.NETWORK;
+
+ const isManager = currentUser?.role === EUserRole.MANAGER;
+ const canChangeRole = isOwner && !isSelf;
+ const canTransfer = isOwner && isNetworkPlan && !isSelf;
+ // Detach: только network план. Owner — любого (кроме себя), Manager — только staff своей локации
+ const canDetach =
+ isNetworkPlan &&
+ !isSelf &&
+ (isOwner ||
+ (isManager &&
+ employee.role === EUserRole.STAFF &&
+ employee.location_id === currentUser?.location_id));
+
+ // Handlers
+ const handleChangeRole = () => {
+ changeRole.mutate(
+ { id: employee.id, role: selectedRole },
+ { onSuccess: () => setRoleDialogOpen(false) }
+ );
+ };
+
+ const handleTransfer = () => {
+ if (!selectedLocation) return;
+ transfer.mutate(
+ { id: employee.id, location_id: selectedLocation },
+ { onSuccess: () => setTransferDialogOpen(false) }
+ );
+ };
+
+ const handleDeactivate = () => {
+ deactivate.mutate(employee.id, {
+ onSuccess: () => setDeactivateDialogOpen(false),
+ });
+ };
+
+ const handleActivate = () => {
+ activate.mutate(employee.id);
+ };
+
+ const handleDetach = () => {
+ detach.mutate(employee.id, {
+ onSuccess: () => setDetachDialogOpen(false),
+ });
+ };
+
+ return (
+ <>
+
+
+
+
+
+ {/* View profile */}
+
+
+ {td("viewProfile")}
+
+
+ {/* Change role — только owner, не себе */}
+ {canChangeRole && (
+ {
+ setSelectedRole(employee.role);
+ setRoleDialogOpen(true);
+ }}
+ >
+
+ {td("changeRole")}
+
+ )}
+
+ {/* Transfer — только owner + network */}
+ {canTransfer && (
+ {
+ setSelectedLocation("");
+ setTransferDialogOpen(true);
+ }}
+ >
+
+ {td("transfer")}
+
+ )}
+
+ {/* Detach from location — только owner, не себе */}
+ {canDetach && (
+ setDetachDialogOpen(true)}
+ className="text-destructive focus:text-destructive"
+ >
+
+ {td("detach")}
+
+ )}
+
+ {/* Activate / Deactivate — скрыт на solo */}
+ {!isSoloPlan && (
+ <>
+
+ {employee.active ? (
+ setDeactivateDialogOpen(true)}
+ className="text-destructive focus:text-destructive"
+ >
+
+ {td("deactivate")}
+
+ ) : (
+
+
+ {td("activate")}
+
+ )}
+ >
+ )}
+
+
+
+ {/* Dialog: Change Role */}
+
+
+ {/* Dialog: Transfer */}
+
+
+ {/* Dialog: Deactivate confirmation */}
+
+
+ {/* Dialog: Detach from location */}
+
+ >
+ );
+}
diff --git a/src/features/staff/components/index.ts b/src/features/staff/components/index.ts
index c076a35..f3c4165 100644
--- a/src/features/staff/components/index.ts
+++ b/src/features/staff/components/index.ts
@@ -5,5 +5,4 @@
export { SortIcon } from "./sort-icon";
export { ErrorMessage } from "./error-message";
export { Pagination, type PaginationInfo } from "./pagination";
-export { StaffTableHeader } from "./table-header";
export { StaffTableRow } from "./table-row";
diff --git a/src/features/staff/components/pagination.tsx b/src/features/staff/components/pagination.tsx
index a058cfe..a5f60f6 100644
--- a/src/features/staff/components/pagination.tsx
+++ b/src/features/staff/components/pagination.tsx
@@ -16,50 +16,69 @@ export interface PaginationInfo {
offset: number;
}
+import type { ISubscriptionLimit } from "@/src/shared/types/user";
+
/**
* Компонент пагинации для таблицы сотрудников
*/
interface PaginationProps {
paginationInfo: PaginationInfo;
onPageChange: (newOffset: number) => void;
+ employeeLimits?: ISubscriptionLimit;
}
-export function Pagination({ paginationInfo, onPageChange }: PaginationProps) {
+export function Pagination({
+ paginationInfo,
+ onPageChange,
+ employeeLimits,
+}: PaginationProps) {
const t = useTranslations("Staff.pagination");
+ const tStaff = useTranslations("Staff");
const isMobile = useIsMobile();
const { offset, limit, total, currentPage, totalPages, hasNext, hasPrev } =
paginationInfo;
return (
-
-
- {t("showing")} {offset + 1} - {Math.min(offset + limit, total)}{" "}
- {t("of")} {total}
-
-
-
-
- {!isMobile && t("page")} {currentPage} {t("of")} {totalPages}
+
+ {/* Пагинация: инфо + кнопки */}
+
+
+ {t("showing")} {offset + 1} - {Math.min(offset + limit, total)}{" "}
+ {t("of")} {total}
+
+
+
+
+ {!isMobile && t("page")} {currentPage} {t("of")} {totalPages}
+
+
-
+
+ {/* Лимит сотрудников */}
+ {employeeLimits && (
+
+ {employeeLimits.used} / {employeeLimits.max ?? "∞"}{" "}
+ {tStaff("staffLimit")}
+
+ )}
);
}
diff --git a/src/features/staff/components/sort-icon.tsx b/src/features/staff/components/sort-icon.tsx
index ad356bd..211f183 100644
--- a/src/features/staff/components/sort-icon.tsx
+++ b/src/features/staff/components/sort-icon.tsx
@@ -1,12 +1,12 @@
import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
-import { GetStaffParams } from "@/src/shared/services/staff-service";
+import type { GetEmployeesParams } from "@/src/shared/services/employee-service";
/**
* Компонент иконки сортировки для заголовков таблицы
*/
interface SortIconProps {
- field: GetStaffParams["order_by"];
- currentField?: GetStaffParams["order_by"];
+ field: GetEmployeesParams["order_by"];
+ currentField?: GetEmployeesParams["order_by"];
sortOrder?: "asc" | "desc";
}
diff --git a/src/features/staff/components/table-header.tsx b/src/features/staff/components/table-header.tsx
index 25a3b2d..a56e0fc 100644
--- a/src/features/staff/components/table-header.tsx
+++ b/src/features/staff/components/table-header.tsx
@@ -1,27 +1,26 @@
import { TableHead, TableRow } from "@/src/entities";
-import { GetStaffParams } from "@/src/shared/services/staff-service";
+import type { GetEmployeesParams } from "@/src/shared/services/employee-service";
import { SortIcon } from "./sort-icon";
import { useTranslations } from "next-intl";
-/**
- * Интерфейс для колонки таблицы
- */
interface TableColumn {
- field: GetStaffParams["order_by"];
+ field: GetEmployeesParams["order_by"];
labelKey: string;
sortable?: boolean;
+ /** Скрыть колонку на мобилке (hidden md:table-cell) */
+ hiddenMobile?: boolean;
}
-/**
- * Компонент заголовка таблицы сотрудников
- */
interface TableHeaderProps {
columns: TableColumn[];
- orderBy?: GetStaffParams["order_by"];
+ orderBy?: GetEmployeesParams["order_by"];
sortOrder?: "asc" | "desc";
- onSort: (field: GetStaffParams["order_by"]) => void;
+ onSort: (field: GetEmployeesParams["order_by"]) => void;
}
+/**
+ * Заголовок таблицы сотрудников — адаптивный (скрывает колонки на мобилке)
+ */
export function StaffTableHeader({
columns,
orderBy,
@@ -34,24 +33,29 @@ export function StaffTableHeader({
{columns.map(column => (
column.sortable && column.field && onSort(column.field)
}
>
- {column.sortable ? (
-
- {t(column.labelKey)}
-
-
- ) : (
- t(column.labelKey)
- )}
+ {column.labelKey ? (
+ column.sortable ? (
+
+ {t(column.labelKey)}
+
+
+ ) : (
+ t(column.labelKey)
+ )
+ ) : null}
))}
diff --git a/src/features/staff/components/table-row.tsx b/src/features/staff/components/table-row.tsx
index 7403cae..f6390c5 100644
--- a/src/features/staff/components/table-row.tsx
+++ b/src/features/staff/components/table-row.tsx
@@ -1,41 +1,89 @@
-import { TableCell, TableRow, Badge } from "@/src/entities";
-import { IUserDto } from "@/src/shared/types/user";
-import { formatDate } from "@/src/shared/utils/formater";
-import { useTranslations, useLocale } from "next-intl";
+"use client";
+
+import { Badge, Avatar, AvatarImage, AvatarFallback } from "@/src/entities";
+import type { IEmployeeDto } from "@/src/shared/types/user";
+import { useTranslations } from "next-intl";
import { getRoleKey } from "../utils/format-role";
+import { EmployeeRowActions } from "./employee-row-actions";
+import { cn } from "@/src/shared/utils/styles";
+import { getInitials } from "@/src/shared/utils/avatar";
-/**
- * Компонент строки таблицы сотрудников
- */
interface StaffTableRowProps {
- user: IUserDto;
+ user: IEmployeeDto;
+ onClick: () => void;
+ isSelected: boolean;
}
-export function StaffTableRow({ user }: StaffTableRowProps) {
+/**
+ * Строка сотрудника в grid-таблице (стиль как в услугах)
+ * Мобилка: аватар + имя (+ роль под именем) + DropdownMenu
+ * Десктоп: аватар + имя, телефон, роль, статус, DropdownMenu
+ */
+export function StaffTableRow({
+ user,
+ onClick,
+ isSelected,
+}: StaffTableRowProps) {
const t = useTranslations("Staff");
- const locale = useLocale();
- // Маппинг локали для форматирования даты
- const dateLocale = locale === "kz" ? "kk-KZ" : "ru-RU";
+ const initials = getInitials(user.first_name, user.last_name);
return (
-
- {user.name}
- {user.surname}
- {user.phone}
-
- {t(`roles.${getRoleKey(user.role)}`)}
-
- {user.point_code}
- {user.network_code}
-
-
+
+ {/* Имя с аватаром — на мобилке роль показывается здесь */}
+
+
+ {user.avatar_url && (
+
+ )}
+
+ {initials}
+
+
+
+
+ {user.first_name} {user.last_name}
+
+ {/* Роль под именем — только мобилка */}
+
+ {t(`roles.${getRoleKey(user.role)}`)}
+
+
+
+
+ {/* Телефон — скрыт на мобилке */}
+
+ {user.phone}
+
+
+ {/* Роль — скрыта на мобилке (показана под именем) */}
+
+
+ {t(`roles.${getRoleKey(user.role)}`)}
+
+
+
+ {/* Статус — скрыт на мобилке */}
+
+
{user.active ? t("active") : t("inactive")}
-
-
- {formatDate(user.created_at, dateLocale)}
-
-
+
+
+ {/* DropdownMenu действий */}
+
e.stopPropagation()}>
+
+
+
);
}
diff --git a/src/features/staff/constants.ts b/src/features/staff/constants.ts
index f5f9cce..e01f4b1 100644
--- a/src/features/staff/constants.ts
+++ b/src/features/staff/constants.ts
@@ -1,11 +1,11 @@
-import { GetStaffParams } from "@/src/shared/services/staff-service";
+import type { GetEmployeesParams } from "@/src/shared/services/employee-service";
/**
* Дефолтные значения фильтров для таблицы сотрудников
*/
-export const DEFAULT_STAFF_FILTERS: GetStaffParams = {
- limit: 5,
+export const DEFAULT_STAFF_FILTERS: GetEmployeesParams = {
+ limit: 10,
offset: 0,
order_by: "created_at",
- sort_order: "asc",
+ sort_order: "desc",
};
diff --git a/src/features/staff/register-staff-form.tsx b/src/features/staff/register-staff-form.tsx
index 440f57b..4423ed3 100644
--- a/src/features/staff/register-staff-form.tsx
+++ b/src/features/staff/register-staff-form.tsx
@@ -23,24 +23,27 @@ import {
SelectValue,
} from "@/src/entities/select";
import { PhoneInput, PhoneInputValue } from "@/src/widgets/forms";
-import { useRegisterStaff } from "@/src/shared/hooks/user-staff";
+import { useInviteEmployee } from "@/src/shared/hooks/user-staff";
import { Loader } from "lucide-react";
import { toast } from "sonner";
-import { EUserRole, TUserRole } from "@/src/shared/types/user";
+import { EUserRole } from "@/src/shared/types/user";
import { useCurrentUser } from "@/src/shared/hooks/use-users";
-import { useNetworkPoints } from "@/src/shared/hooks/use-network-points";
+import { useLocations } from "@/src/shared/hooks/use-network-locations";
/**
- * Схема валидации для регистрации сотрудника
+ * Схема валидации для приглашения сотрудника
* Принимает список доступных ролей в зависимости от прав текущего пользователя
*/
-const createRegisterStaffSchema = (
+const createInviteStaffSchema = (
t: (key: string) => string,
availableRoles: EUserRole[]
) =>
z.object({
- name: z.string().min(2, t("errors.nameMin")).max(50, t("errors.nameMax")),
- surname: z
+ first_name: z
+ .string()
+ .min(2, t("errors.nameMin"))
+ .max(50, t("errors.nameMax")),
+ last_name: z
.string()
.min(2, t("errors.surnameMin"))
.max(50, t("errors.surnameMax")),
@@ -51,15 +54,11 @@ const createRegisterStaffSchema = (
role: z.enum(availableRoles as [string, ...string[]], {
message: t("errors.roleRequired"),
}),
- point_code: z
- .string()
- .min(1, t("errors.pointCodeRequired"))
- .max(50, t("errors.pointCodeMax")),
+ // location_id — UUID локации, к которой привязывается сотрудник
+ location_id: z.string().min(1, t("errors.pointCodeRequired")),
});
-type RegisterStaffFormData = z.infer<
- ReturnType
->;
+type InviteStaffFormData = z.infer>;
interface RegisterStaffFormProps {
onSuccess?: () => void;
@@ -67,71 +66,65 @@ interface RegisterStaffFormProps {
}
/**
- * Форма регистрации нового сотрудника
- * Использует двухэтапный процесс: создает неактивного пользователя
- * и отправляет ссылку для завершения регистрации
+ * Форма приглашения нового сотрудника
+ * Отправляет инвайт через POST /api/v1/employees/invite
*/
export function RegisterStaffForm({
onSuccess,
onCancel,
}: RegisterStaffFormProps) {
const t = useTranslations("Staff.RegisterForm");
- const registerStaffMutation = useRegisterStaff();
+ const inviteEmployeeMutation = useInviteEmployee();
const { data: currentUser } = useCurrentUser();
- const { data: networkPoints } = useNetworkPoints(currentUser?.network_code);
+ const { data: locationsData } = useLocations();
- // Для manager — только его точка, для ролей выше — список из API
+ // Для manager — только его точка, для Owner — список из API
const pointOptions = useMemo(() => {
- if (currentUser?.role === EUserRole.MANAGER && currentUser.point_code) {
- return [{ code: currentUser.point_code, name: currentUser.point_code }];
+ const userLocationId = currentUser?.location_id;
+ if (currentUser?.role === EUserRole.MANAGER && userLocationId) {
+ return [{ id: userLocationId, name: userLocationId }];
}
return (
- networkPoints?.points.map(p => ({
- code: p.code,
- name: p.name || p.code,
+ locationsData?.locations.map(l => ({
+ id: l.id,
+ name: l.name || l.id,
})) ?? []
);
- }, [currentUser?.role, currentUser?.point_code, networkPoints?.points]);
+ }, [currentUser?.role, currentUser?.location_id, locationsData?.locations]);
- // Определяем доступные роли в зависимости от прав текущего пользователя
+ // Доступные роли: Manager и Staff
const availableRoles = useMemo(() => {
- const baseRoles = [EUserRole.MANAGER, EUserRole.STAFF];
- // Админ может создавать net_manager
- if (currentUser?.role === EUserRole.ADMIN) {
- return [...baseRoles, EUserRole.NET_MANAGER];
- }
- return baseRoles;
- }, [currentUser?.role]);
+ return [EUserRole.MANAGER, EUserRole.STAFF];
+ }, []);
// Создаем схему с переводами и доступными ролями
- const schema = createRegisterStaffSchema(t, availableRoles);
+ const schema = createInviteStaffSchema(t, availableRoles);
- const form = useForm({
+ const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
- name: "",
- surname: "",
+ first_name: "",
+ last_name: "",
phone: "",
role: undefined,
- point_code: "",
+ location_id: "",
},
});
- const onSubmit = async (data: RegisterStaffFormData) => {
+ const onSubmit = async (data: InviteStaffFormData) => {
try {
- // Явно указываем тип role для совместимости с RegisterStaffRequest
- await registerStaffMutation.mutateAsync({
- ...data,
- role: data.role as Exclude<
- TUserRole,
- EUserRole.ADMIN | EUserRole.SELF_OWNER
- >,
+ await inviteEmployeeMutation.mutateAsync({
+ first_name: data.first_name,
+ last_name: data.last_name,
+ phone: data.phone,
+ role: data.role as "manager" | "staff",
+ location_id: data.location_id,
});
toast.success(t("success"));
form.reset();
onSuccess?.();
} catch (error) {
- console.error("Ошибка регистрации сотрудника:", error);
+ console.error("Ошибка приглашения сотрудника:", error);
toast.error(t("errors.submitError"));
}
};
@@ -143,14 +136,14 @@ export function RegisterStaffForm({
{/* Имя */}
(
{t("name")}
@@ -162,14 +155,14 @@ export function RegisterStaffForm({
{/* Фамилия */}
(
{t("surname")}
@@ -192,7 +185,7 @@ export function RegisterStaffForm({
value={field.value as PhoneInputValue}
onChange={field.onChange}
defaultCountryCode="KZ"
- disabled={registerStaffMutation.isPending}
+ disabled={inviteEmployeeMutation.isPending}
placeholder={t("phonePlaceholder")}
/>
@@ -211,7 +204,7 @@ export function RegisterStaffForm({
@@ -238,26 +226,26 @@ export function RegisterStaffForm({
/>
- {/* Код точки */}
+ {/* Точка (location) */}
(
- {t("pointCode")}
+ {t("locationId")}