- {/* Селектор точки (только для self_owner/net_manager/admin) */}
- {shouldLoadPoints && (
-
-
+
+ {/* Заголовок: кол-во + кнопка */}
+
+
+ {!isLoading && services.length > 0 && (
+
+ {t("serviceCount", { count: services.length })}
+
+ )}
- )}
-
- {/* Таблица */}
-
-
- {t("tableTitle")}
- setIsDialogOpen(true)}
- size="sm"
- className="flex items-center gap-2"
- disabled={!pointCodeForQuery}
- >
-
- {t("addService")}
-
-
-
- {/* Состояние загрузки */}
- {error ? (
- /* Ошибка загрузки */
-
+
+ {/* Кнопка добавления скрыта для staff */}
+ {!isStaff &&
+ (isMobile ? (
+ setIsDialogOpen(true)}
+ disabled={!locationId}
+ >
+
+
) : (
- <>
- {/* Таблица */}
-
-
-
-
-
-
- {isLoading ? (
-
- {Array.from({ length: tableColumns.length }).map(
- (_, i) => (
-
-
-
- )
- )}
-
- ) : !data?.services || data.services.length === 0 ? (
-
-
- {t("noData")}
-
-
- ) : (
- data.services.map(service => (
-
- ))
- )}
-
-
-
- >
- )}
-
-
+
setIsDialogOpen(true)}
+ disabled={!locationId}
+ >
+
+ {t("addService")}
+
+ ))}
+
+
+ {/* Список услуг */}
+
- {/* Диалог добавления услуги */}
+ {/* Диалог добавления */}
);
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")}
+
+
+
+
+
+
+
+
+ {/* Модалка подтверждения активации/деактивации */}
+
+
+
+
+ {employee.active ? td("deactivateTitle") : td("activateTitle")}
+
+
+ {employee.active ? td("deactivateDesc") : td("activateDesc")}
+
+
+
+ setConfirmOpen(false)}>
+ {td("cancel")}
+
+
+ {td("confirm")}
+
+
+
+
+
+ );
+}
+
+/** Строка информации — 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 (
+ <>
+
+
+ e.stopPropagation()}
+ >
+
+
+
+
+ {/* 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 */}
+
+ e.stopPropagation()}
+ >
+
+ {td("changeRoleTitle")}
+ {td("changeRoleDesc")}
+
+ setSelectedRole(v as TUserRole)}
+ >
+
+
+
+
+ {tRoles("manager")}
+ {tRoles("staff")}
+
+
+
+ setRoleDialogOpen(false)}>
+ {td("cancel")}
+
+
+ {td("save")}
+
+
+
+
+
+ {/* Dialog: Transfer */}
+
+ e.stopPropagation()}
+ >
+
+ {td("transferTitle")}
+ {td("transferDesc")}
+
+
+
+
+
+
+ {locations
+ .filter(loc => loc.id !== employee.location_id)
+ .map(loc => (
+
+ {loc.name}
+
+ ))}
+
+
+
+ setTransferDialogOpen(false)}
+ >
+ {td("cancel")}
+
+
+ {td("save")}
+
+
+
+
+
+ {/* Dialog: Deactivate confirmation */}
+
+ e.stopPropagation()}
+ >
+
+ {td("deactivateTitle")}
+ {td("deactivateDesc")}
+
+
+ setDeactivateDialogOpen(false)}
+ >
+ {td("cancel")}
+
+
+ {td("confirm")}
+
+
+
+
+
+ {/* Dialog: Detach from location */}
+
+ e.stopPropagation()}
+ >
+
+ {td("detachTitle")}
+
+ {td("detachDesc", {
+ name: `${employee.first_name} ${employee.last_name}`,
+ })}
+
+
+
+ setDetachDialogOpen(false)}
+ >
+ {td("cancel")}
+
+
+ {td("confirm")}
+
+
+
+
+ >
+ );
+}
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}
-
-
-
onPageChange(offset - limit)}
- disabled={!hasPrev}
- >
-
- {!isMobile && t("back")}
-
-
- {!isMobile && t("page")} {currentPage} {t("of")} {totalPages}
+
+ {/* Пагинация: инфо + кнопки */}
+
+
+ {t("showing")} {offset + 1} - {Math.min(offset + limit, total)}{" "}
+ {t("of")} {total}
+
+
+
onPageChange(offset - limit)}
+ disabled={!hasPrev}
+ >
+
+ {!isMobile && t("back")}
+
+
+ {!isMobile && t("page")} {currentPage} {t("of")} {totalPages}
+
+
onPageChange(offset + limit)}
+ disabled={!hasNext}
+ >
+ {!isMobile && t("forward")}
+
+
-
onPageChange(offset + limit)}
- disabled={!hasNext}
- >
- {!isMobile && t("forward")}
-
-
+
+ {/* Лимит сотрудников */}
+ {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({
@@ -225,11 +218,6 @@ export function RegisterStaffForm({
{t("roles.manager")}
- {availableRoles.includes(EUserRole.NET_MANAGER) && (
-
- {t("roles.net_manager")}
-
- )}
@@ -238,26 +226,26 @@ export function RegisterStaffForm({
/>
- {/* Код точки */}
+ {/* Точка (location) */}
(
- {t("pointCode")}
+ {t("locationId")}
-
+
{pointOptions.map(point => (
-
+
{point.name}
))}
@@ -275,13 +263,13 @@ export function RegisterStaffForm({
type="button"
variant="outline"
onClick={onCancel}
- disabled={registerStaffMutation.isPending}
+ disabled={inviteEmployeeMutation.isPending}
>
{t("cancel")}
)}
-
- {registerStaffMutation.isPending ? (
+
+ {inviteEmployeeMutation.isPending ? (
<>
{t("submitting")}
diff --git a/src/features/staff/staff-content.tsx b/src/features/staff/staff-content.tsx
index 2502cac..7c0eebf 100644
--- a/src/features/staff/staff-content.tsx
+++ b/src/features/staff/staff-content.tsx
@@ -1,185 +1,190 @@
"use client";
import { useState, useMemo } from "react";
-import { useGetStaff } from "@/src/shared/hooks/user-staff";
-import { GetStaffParams } from "@/src/shared/services/staff-service";
-import {
- Table,
- TableBody,
- TableCell,
- TableHeader,
- TableRow,
- Card,
- CardContent,
- CardHeader,
- CardTitle,
- Skeleton,
- Button,
-} from "@/src/entities";
+import { useGetEmployees } from "@/src/shared/hooks/user-staff";
+import { useCurrentUser } from "@/src/shared/hooks/use-users";
+import type { GetEmployeesParams } from "@/src/shared/services/employee-service";
+import type { IEmployeeDto } from "@/src/shared/types/user";
+import { Skeleton } from "@/src/entities";
import { StaffFilters } from "./staff-filters";
import { useTranslations } from "next-intl";
import { ErrorMessage } from "./components/error-message";
import { Pagination, type PaginationInfo } from "./components/pagination";
-import { StaffTableHeader } from "./components/table-header";
import { StaffTableRow } from "./components/table-row";
+import { SortIcon } from "./components/sort-icon";
import { DEFAULT_STAFF_FILTERS } from "./constants";
-import { Plus } from "lucide-react";
import { AddStaffDialog } from "./add-staff-dialog";
+import { EmployeeDrawer } from "./components/drawer";
import { useDisclosure } from "@/src/shared/hooks";
/**
- * Компонент таблицы сотрудников с фильтрацией, сортировкой и пагинацией
+ * Компонент таблицы сотрудников с фильтрацией, сортировкой, пагинацией и drawer
*/
export function StaffContent() {
const t = useTranslations("Staff");
- // Состояние фильтров
- const [filters, setFilters] = useState(DEFAULT_STAFF_FILTERS);
+ // Фильтры
+ const [filters, setFilters] = useState(
+ DEFAULT_STAFF_FILTERS
+ );
- // Управление диалогом добавления сотрудника
+ // Диалог добавления + drawer сотрудника
const addStaffDialog = useDisclosure();
+ const [selectedEmployeeId, setSelectedEmployeeId] = useState(
+ null
+ );
+ const [drawerOpen, setDrawerOpen] = useState(false);
- // Получение данных
- const { data, isLoading, error } = useGetStaff(filters);
+ // Данные
+ const { data, isLoading, error } = useGetEmployees(filters);
+ const { data: currentUser } = useCurrentUser();
- // Обработка изменения сортировки
- const handleSort = (field: GetStaffParams["order_by"]) => {
- if (!field) return;
+ // Лимиты подписки
+ const employeeLimits =
+ currentUser?.organization?.subscription?.limits?.employees;
+ // Сортировка
+ const handleSort = (field: GetEmployeesParams["order_by"]) => {
+ if (!field) return;
setFilters(prev => ({
...prev,
order_by: field,
sort_order:
prev.order_by === field && prev.sort_order === "asc" ? "desc" : "asc",
- offset: 0, // Сбрасываем пагинацию при изменении сортировки
+ offset: 0,
}));
};
- // Обработка изменения страницы
+ // Пагинация
const handlePageChange = (newOffset: number) => {
- setFilters(prev => ({
- ...prev,
- offset: newOffset,
- }));
+ setFilters(prev => ({ ...prev, offset: newOffset }));
};
- // Вычисление информации о пагинации
const paginationInfo = useMemo(() => {
const limit = filters.limit || DEFAULT_STAFF_FILTERS.limit!;
const offset = filters.offset || DEFAULT_STAFF_FILTERS.offset!;
const total = data?.total || 0;
const currentPage = Math.floor(offset / limit) + 1;
const totalPages = Math.ceil(total / limit);
- const hasNext = offset + limit < total;
- const hasPrev = offset > 0;
-
return {
currentPage,
totalPages,
- hasNext,
- hasPrev,
+ hasNext: offset + limit < total,
+ hasPrev: offset > 0,
total,
limit,
offset,
};
}, [data?.total, filters.limit, filters.offset]);
- // Определение колонок таблицы
- const tableColumns = [
- { field: "name" as const, labelKey: "name", sortable: true },
- { field: "surname" as const, labelKey: "surname", sortable: true },
- { field: "phone" as const, labelKey: "phone", sortable: true },
- { field: undefined, labelKey: "role", sortable: false },
- { field: "point_code" as const, labelKey: "pointCode", sortable: true },
- { field: "network_code" as const, labelKey: "networkCode", sortable: true },
- { field: undefined, labelKey: "status", sortable: false },
- { field: "created_at" as const, labelKey: "createdAt", sortable: true },
- ];
+ // Свежий объект сотрудника из кэша (обновляется при optimistic update)
+ const selectedEmployee = useMemo(
+ () => data?.employees?.find(e => e.id === selectedEmployeeId) ?? null,
+ [data?.employees, selectedEmployeeId]
+ );
- // Открытие диалога добавления сотрудника
- const handleAddStaff = () => {
- addStaffDialog.onOpen();
+ // Клик по строке — открыть drawer
+ const handleRowClick = (employee: IEmployeeDto) => {
+ setSelectedEmployeeId(employee.id);
+ setDrawerOpen(true);
};
+ // Колонки для сортировки
+ const sortableColumns = [
+ { field: "first_name" as const, labelKey: "name" },
+ { field: "phone" as const, labelKey: "phone", hiddenMobile: true },
+ { field: "role" as const, labelKey: "role", hiddenMobile: true },
+ ];
+
return (
- {/* Фильтры */}
-
+ {/* Фильтры + кнопка приглашения */}
+
+
+ {/* Ошибка загрузки */}
+ {error &&
}
{/* Таблица */}
-
-
- {t("tableTitle")}
-
-
-
- {t("addStaff")}
-
-
-
- {/* Ошибка загрузки */}
- {error && }
-
- {/* Состояние загрузки */}
- {isLoading ? (
-
- {Array.from({ length: 5 }).map((_, i) => (
-
+ {isLoading ? (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+
+
+ ))}
+
+ ) : (
+ <>
+
+ {/* Заголовки колонок — только десктоп */}
+
+ {sortableColumns.map(col => (
+ handleSort(col.field)}
+ >
+ {t(col.labelKey)}
+
+
))}
+ {/* Статус — не сортируемый */}
+
+ {t("status")}
+
+
- ) : (
- <>
- {/* Таблица */}
-
-
-
-
-
-
- {!data?.users || data.users.length === 0 ? (
-
-
- {t("noData")}
-
-
- ) : (
- data.users.map(user => (
-
- ))
- )}
-
-
-
- {/* Пагинация */}
- {data && data.total > 0 && (
-
+ {t("noData")}
+
+ ) : (
+ data.employees.map(employee => (
+
handleRowClick(employee)}
+ isSelected={selectedEmployeeId === employee.id}
/>
- )}
- >
+ ))
+ )}
+
+
+ {/* Пагинация + лимит сотрудников */}
+ {data && data.total > 0 && (
+
)}
-
-
+ >
+ )}
+
+ {/* Drawer сотрудника */}
+
{/* Диалог добавления сотрудника */}
{
- if (open) {
- addStaffDialog.onOpen();
- } else {
- addStaffDialog.onClose();
- }
+ if (open) addStaffDialog.onOpen();
+ else addStaffDialog.onClose();
}}
/>
diff --git a/src/features/staff/staff-filters.tsx b/src/features/staff/staff-filters.tsx
index 4a3e9c6..f7ef352 100644
--- a/src/features/staff/staff-filters.tsx
+++ b/src/features/staff/staff-filters.tsx
@@ -10,177 +10,166 @@ import {
SelectValue,
Button,
} from "@/src/entities";
-import { GetStaffParams } from "@/src/shared/services/staff-service";
-import { EUserRole } from "@/src/shared/types/user";
+import type { GetEmployeesParams } from "@/src/shared/services/employee-service";
+import type { ISubscriptionLimit, TUserRole } from "@/src/shared/types/user";
import { useCurrentUser } from "@/src/shared/hooks/use-users";
+import { useLocations } from "@/src/shared/hooks/use-network-locations";
import { useDebounceCallback } from "@/src/shared/hooks/use-debounce";
import { useTranslations } from "next-intl";
-import { DEFAULT_STAFF_FILTERS } from "./constants";
+import { useRouter } from "next/navigation";
+import { Plus, Search } from "lucide-react";
-/**
- * Интерфейс для фильтров сотрудников
- */
export interface StaffFiltersProps {
- filters: GetStaffParams;
- onFiltersChange: (filters: GetStaffParams) => void;
+ filters: GetEmployeesParams;
+ onFiltersChange: (filters: GetEmployeesParams) => void;
+ onInviteClick: () => void;
+ /** Лимиты подписки на сотрудников */
+ employeeLimits?: ISubscriptionLimit;
}
/**
- * Компонент фильтров для таблицы сотрудников
+ * Панель фильтров: поиск, [точка (network)], роль, статус, кнопка приглашения
*/
-export function StaffFilters({ filters, onFiltersChange }: StaffFiltersProps) {
- const t = useTranslations("Staff.filters");
+export function StaffFilters({
+ filters,
+ onFiltersChange,
+ onInviteClick,
+ employeeLimits,
+}: StaffFiltersProps) {
+ const t = useTranslations("Staff");
+ const tFilters = useTranslations("Staff.filters");
const tRoles = useTranslations("Staff.roles");
- const { data: user } = useCurrentUser();
+ const router = useRouter();
- const [localPointCode, setLocalPointCode] = useState(
- filters.point_code || ""
- );
- const [localNetworkCode, setLocalNetworkCode] = useState(
- filters.network_code || ""
- );
- const [localPhone, setLocalPhone] = useState(filters.phone || "");
+ const { data: currentUser } = useCurrentUser();
+ const isNetwork = currentUser?.organization?.subscription?.plan === "network";
- // Проверка наличия активных фильтров
- const hasActiveFilters = Boolean(
- filters.point_code ||
- filters.network_code ||
- (filters.role && filters.role.length > 0) ||
- (filters.phone && filters.phone.trim())
- );
+ // Достигнут лимит сотрудников (max: null = безлимит)
+ const isLimitReached =
+ employeeLimits?.max != null
+ ? employeeLimits.used >= employeeLimits.max
+ : false;
- // Обработка изменения фильтров
- const handleFilterChange = (key: keyof GetStaffParams, value: any) => {
- onFiltersChange({
- ...filters,
- [key]: value,
- offset: 0, // Сбрасываем пагинацию при изменении фильтров
- });
- };
+ // Список локаций (только owner + network)
+ const { data: locationsData } = useLocations();
+ const locations = locationsData?.locations || [];
- // Очистка всех фильтров
- const handleClearFilters = () => {
- onFiltersChange({
- ...DEFAULT_STAFF_FILTERS,
- point_code: undefined,
- network_code: undefined,
- role: undefined,
- phone: undefined,
- });
- setLocalPointCode("");
- setLocalNetworkCode("");
- setLocalPhone("");
- };
+ const [localSearch, setLocalSearch] = useState(filters.search || "");
- // Debounce для кода точки (применяется через 500мс после остановки ввода)
- useDebounceCallback(
- localPointCode,
- debouncedPointCode => {
- const pointCodeValue = debouncedPointCode.trim();
- handleFilterChange("point_code", pointCodeValue || undefined);
- },
- 500
- );
-
- // Debounce для телефона (применяется через 500мс после остановки ввода)
- useDebounceCallback(
- localPhone,
- debouncedPhone => {
- const phoneValue = debouncedPhone.trim();
- handleFilterChange("phone", phoneValue || undefined);
- },
- 500
- );
+ // Обновление фильтра с сбросом пагинации
+ const updateFilter = (
+ key: K,
+ value: GetEmployeesParams[K]
+ ) => {
+ onFiltersChange({ ...filters, [key]: value, offset: 0 });
+ };
- // Debounce для кода сети (применяется через 500мс после остановки ввода)
+ // Debounce поиска (500мс)
useDebounceCallback(
- localNetworkCode,
- debouncedNetworkCode => {
- const networkCodeValue = debouncedNetworkCode.trim();
- handleFilterChange("network_code", networkCodeValue || undefined);
- },
+ localSearch,
+ val => updateFilter("search", val.trim() || undefined),
500
);
return (
-
-
- {/* Фильтр по коду точки */}
-
-
- {t("pointCode")}
-
- setLocalPointCode(e.target.value)}
- />
-
-
- {/* Фильтр по коду сети */}
- {user?.role === EUserRole.ADMIN && (
-
-
- {t("networkCode")}
-
- setLocalNetworkCode(e.target.value)}
- />
-
- )}
+
+ {/* Поиск — на мобилке полная ширина */}
+
+
+ setLocalSearch(e.target.value)}
+ className="pl-9 h-9 text-sm"
+ />
+
- {/* Фильтр по ролям */}
-
- {t("role")}
- {
- // Если выбрано "all" или пустое значение - убираем фильтр по роли
- handleFilterChange(
- "role",
- value && value !== "all" ? [value] : undefined
- );
- }}
- >
-
-
-
-
- {t("allRoles")}
- {tRoles("staff")}
- {tRoles("manager")}
-
- {tRoles("net_manager")}
+ {/* Фильтр по точке — только network план */}
+ {isNetwork && locations.length > 0 && (
+
+ updateFilter(
+ "location_id",
+ value && value !== "all" ? value : undefined
+ )
+ }
+ >
+
+
+
+
+ {tFilters("allLocations")}
+ {locations.map(loc => (
+
+ {loc.name}
- {tRoles("self_owner")}
-
-
-
+ ))}
+
+
+ )}
- {/* Фильтр по телефону */}
-
- {t("phone")}
- setLocalPhone(e.target.value)}
- />
-
+ {/* Фильтр по роли */}
+
+ updateFilter(
+ "role",
+ value && value !== "all" ? [value as TUserRole] : undefined
+ )
+ }
+ >
+
+
+
+
+ {tFilters("allRoles")}
+ {tRoles("owner")}
+ {tRoles("manager")}
+ {tRoles("staff")}
+
+
- {/* Кнопка очистки фильтров */}
- {hasActiveFilters && (
-
- {t("clear")}
-
- )}
-
+ {/* Фильтр по статусу */}
+
+ updateFilter(
+ "active",
+ value === "active" ? true : value === "inactive" ? false : undefined
+ )
+ }
+ >
+
+
+
+
+ {tFilters("allStatuses")}
+ {tFilters("active")}
+ {tFilters("inactive")}
+
+
+
+ {/* Кнопка приглашения — при лимите → на страницу подписки */}
+
router.push("/account?tab=subscription")
+ : onInviteClick
+ }
+ size="sm"
+ variant={isLimitReached ? "outline" : "default"}
+ className="shrink-0"
+ >
+
+ {t("invite")}
+
);
}
diff --git a/src/features/staff/utils/format-role.ts b/src/features/staff/utils/format-role.ts
index fae68bf..11b55a6 100644
--- a/src/features/staff/utils/format-role.ts
+++ b/src/features/staff/utils/format-role.ts
@@ -6,11 +6,9 @@ import { TUserRole } from "@/src/shared/types/user";
*/
export function getRoleKey(role: TUserRole): string {
const roleMap: Record
= {
- staff: "staff",
+ owner: "owner",
manager: "manager",
- net_manager: "net_manager",
- self_owner: "self_owner",
- admin: "admin",
+ staff: "staff",
};
return roleMap[role] || role;
}
diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts
index 708ec96..57d0d0f 100644
--- a/src/shared/api/client.ts
+++ b/src/shared/api/client.ts
@@ -71,7 +71,22 @@ export class HttpClient {
});
// Получаем токен (приоритет: переданный колбэк, затем из cookies)
- const token = this.getAuthToken?.() || (await getAccessToken());
+ let token = this.getAuthToken?.() || (await getAccessToken());
+
+ // Если access_token отсутствует, но refresh_token есть — проактивно обновляем
+ // Без этого запрос уйдёт без Authorization, бэк вернёт 400 (не 401) и refresh не сработает
+ if (!token && !isRetry) {
+ const refreshToken = await getRefreshToken();
+ if (refreshToken) {
+ try {
+ await this.refreshTokenIfNeeded();
+ token = this.getAuthToken?.() || (await getAccessToken());
+ } catch {
+ // Если refresh не удался — продолжаем без токена, пусть бэк вернёт ошибку
+ }
+ }
+ }
+
if (token && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${token}`);
}
@@ -159,11 +174,12 @@ export class HttpClient {
throw new Error("Refresh token not found");
}
- const url = this.buildUrl("v1/auth/refresh");
+ const url = this.buildUrl("api/v1/auth/refresh");
const response = await fetch(url, {
method: "POST",
headers: {
Accept: "application/json",
+ "Content-Type": "application/json",
},
body: JSON.stringify({
refresh_token: refreshToken,
diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts
index 569a98b..0093d9d 100644
--- a/src/shared/hooks/index.ts
+++ b/src/shared/hooks/index.ts
@@ -23,14 +23,23 @@ export * from "./use-media-upload";
// Хуки для календаря
export * from "./use-calendar";
-// Хуки для записей (bagsies)
-export * from "./use-bagsies";
+// Хуки для записей (appointments)
+export * from "./use-appointments";
// Хуки для точек сети
-export * from "./use-network-points";
+export * from "./use-network-locations";
// Хуки для услуг
export * from "./use-services";
// Хуки для debounce
export * from "./use-debounce";
+
+// Права на редактирование графика (точка/мастер)
+export * from "./use-schedule-permissions";
+
+// Аналитика
+export * from "./use-analytics";
+
+// Анимация чисел (для KPI)
+export * from "./use-count-up";
diff --git a/src/shared/hooks/use-analytics.ts b/src/shared/hooks/use-analytics.ts
new file mode 100644
index 0000000..a23ce52
--- /dev/null
+++ b/src/shared/hooks/use-analytics.ts
@@ -0,0 +1,90 @@
+"use client";
+import { useQuery } from "@tanstack/react-query";
+import { AnalyticsService } from "../services/analytics-service";
+import type { AnalyticsParams } from "../types/analytics";
+
+/** Общий staleTime для аналитики — 5 минут (как в QueryProvider по умолчанию). */
+const ANALYTICS_STALE_TIME = 5 * 60 * 1000;
+
+/** Проверка что период задан — иначе query disabled. */
+const isPeriodReady = (p: AnalyticsParams) => !!p.from && !!p.to;
+
+/** Сводка для главной /analytics. */
+export function useOverviewAnalytics(params: AnalyticsParams) {
+ return useQuery({
+ queryKey: ["analytics", "overview", params],
+ queryFn: () => AnalyticsService.getOverview(params),
+ enabled: isPeriodReady(params),
+ staleTime: ANALYTICS_STALE_TIME,
+ });
+}
+
+/**
+ * Личная аналитика /analytics/me.
+ * employeeId не уходит в query (бэк берёт из токена), но остаётся в queryKey —
+ * иначе при логауте + логине под другого сотрудника кэш бы показал чужие данные.
+ */
+export function useMyAnalytics(params: AnalyticsParams, employeeId?: string) {
+ return useQuery({
+ queryKey: ["analytics", "me", employeeId, params],
+ queryFn: () => AnalyticsService.getMyAnalytics(params),
+ enabled: isPeriodReady(params) && !!employeeId,
+ staleTime: ANALYTICS_STALE_TIME,
+ });
+}
+
+/** Список мастеров /analytics/staff. */
+export function useStaffReport(params: AnalyticsParams) {
+ return useQuery({
+ queryKey: ["analytics", "staff", params],
+ queryFn: () => AnalyticsService.getStaffReport(params),
+ enabled: isPeriodReady(params),
+ staleTime: ANALYTICS_STALE_TIME,
+ });
+}
+
+/** Drill-down по мастеру /analytics/staff/[id]. */
+export function useEmployeeAnalytics(
+ employeeId: string | undefined,
+ params: AnalyticsParams
+) {
+ return useQuery({
+ queryKey: ["analytics", "employee", employeeId, params],
+ queryFn: () => AnalyticsService.getEmployeeAnalytics(employeeId!, params),
+ enabled: isPeriodReady(params) && !!employeeId,
+ staleTime: ANALYTICS_STALE_TIME,
+ });
+}
+
+/** Сводка по локации /analytics/locations/[id] (Network Owner). */
+export function useLocationAnalytics(
+ locationId: string | undefined,
+ params: AnalyticsParams
+) {
+ return useQuery({
+ queryKey: ["analytics", "location", locationId, params],
+ queryFn: () => AnalyticsService.getLocationAnalytics(locationId!, params),
+ enabled: isPeriodReady(params) && !!locationId,
+ staleTime: ANALYTICS_STALE_TIME,
+ });
+}
+
+/** Финансовый отчёт /analytics/finance. */
+export function useFinanceReport(params: AnalyticsParams) {
+ return useQuery({
+ queryKey: ["analytics", "finance", params],
+ queryFn: () => AnalyticsService.getFinanceReport(params),
+ enabled: isPeriodReady(params),
+ staleTime: ANALYTICS_STALE_TIME,
+ });
+}
+
+/** Клиенты /analytics/clients (Beta). */
+export function useClientsAnalytics(params: AnalyticsParams) {
+ return useQuery({
+ queryKey: ["analytics", "clients", params],
+ queryFn: () => AnalyticsService.getClientsAnalytics(params),
+ enabled: isPeriodReady(params),
+ staleTime: ANALYTICS_STALE_TIME,
+ });
+}
diff --git a/src/shared/hooks/use-appointments.ts b/src/shared/hooks/use-appointments.ts
new file mode 100644
index 0000000..1a8a41e
--- /dev/null
+++ b/src/shared/hooks/use-appointments.ts
@@ -0,0 +1,42 @@
+"use client";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import {
+ AppointmentService,
+ type CreateAppointmentRequest,
+ type CreateAppointmentResponse,
+ type CancelAppointmentRequest,
+} from "../services/appointment-service";
+
+/**
+ * Хук для создания записи POST /api/v1/appointments/direct
+ */
+export function useCreateAppointment() {
+ const q = useQueryClient();
+ return useMutation<
+ CreateAppointmentResponse,
+ unknown,
+ CreateAppointmentRequest
+ >({
+ mutationFn: (payload: CreateAppointmentRequest) =>
+ AppointmentService.createAppointment(payload),
+ onSuccess: () => {
+ q.invalidateQueries({ queryKey: ["calendar"] });
+ },
+ });
+}
+
+/**
+ * Хук для отмены записи POST /api/v1/appointments/{id}/cancel
+ */
+export function useCancelAppointment() {
+ const q = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, reason }) =>
+ AppointmentService.cancelAppointment(id, {
+ reason,
+ } as CancelAppointmentRequest),
+ onSuccess: () => {
+ q.invalidateQueries({ queryKey: ["calendar"] });
+ },
+ });
+}
diff --git a/src/shared/hooks/use-auth.ts b/src/shared/hooks/use-auth.ts
index 9e40940..2f4b11c 100644
--- a/src/shared/hooks/use-auth.ts
+++ b/src/shared/hooks/use-auth.ts
@@ -4,21 +4,27 @@ import {
AuthService,
type LoginRequestDto,
type LoginResponseDto,
- type RegisterRequestDto,
- type RegisterResponseDto,
- type PasswordChangeRequestDto,
- type PasswordChangeResponseDto,
- PasswordChangeRequestRequestDto,
- PasswordChangeRequestResponseDto,
+ type PasswordResetRequestDto,
+ type PasswordResetResponseDto,
+ type PasswordResetConfirmRequestDto,
+ type PasswordResetConfirmResponseDto,
} from "../services";
+import {
+ EmployeeService,
+ type ConfirmInviteRequest,
+ type ConfirmInviteResponse,
+} from "../services/employee-service";
import { setAuthTokens, clearAuthTokens } from "../utils/cookies";
-import { useRouter } from "next/navigation";
+import { useCalendarStore } from "@/src/features/calendar/calendar-context/store";
+// Локализованный router из next-intl — автоматически добавляет префикс
+// локали в `push("/login")` и т.п. Без него редирект уходил на /login
+// без локали и middleware зацикливался на /login/login.
+import { useRouter } from "@/i18n/navigation";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
/**
* Мутация для логина пользователя
- * Возвращает статус и метод mutateAsync
*/
export function useLogin() {
const queryClient = useQueryClient();
@@ -39,31 +45,38 @@ export function useLogout() {
mutationKey: ["auth", "logout"],
mutationFn: () => AuthService.logout(),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["me"] });
+ queryClient.clear(); // Полностью очищаем весь кэш при логауте
+ useCalendarStore.persist.clearStorage(); // Очищаем localStorage
+ useCalendarStore.setState({
+ locationId: undefined,
+ badgeVariant: "colored",
+ }); // Сбрасываем in-memory состояние
router.refresh();
},
});
}
/**
- * Мутация для регистрации пароля пользователя
- * Возвращает статус и метод mutateAsync
+ * Мутация для подтверждения инвайта сотрудника (установка пароля)
+ * Заменяет старый useRegisterConfirm
*/
-export function useRegisterConfirm() {
+export function useConfirmInvite() {
const router = useRouter();
- return useMutation({
- mutationKey: ["auth", "register"],
- mutationFn: (payload: RegisterRequestDto) =>
- AuthService.registerConfirm(payload),
+ return useMutation({
+ mutationKey: ["auth", "confirmInvite"],
+ mutationFn: (payload: ConfirmInviteRequest) =>
+ EmployeeService.confirmInvite(payload),
onSuccess: () => {
router.push("/login");
},
});
}
+/** @deprecated Используй useConfirmInvite */
+export const useRegisterConfirm = useConfirmInvite;
+
/**
* Мутация для обновления токена доступа
- * Возвращает статус и метод mutateAsync
*/
export function useRefreshToken() {
return useMutation({
@@ -75,16 +88,17 @@ export function useRefreshToken() {
});
}
-export function usePasswordChangeRequest() {
+/** Запрос на сброс пароля (отправляет ссылку) */
+export function usePasswordReset() {
const t = useTranslations("Auth.PasswordChangeRequest");
return useMutation<
- PasswordChangeRequestResponseDto,
+ PasswordResetResponseDto,
unknown,
- PasswordChangeRequestRequestDto
+ PasswordResetRequestDto
>({
- mutationKey: ["auth", "passwordChangeRequest"],
- mutationFn: (payload: PasswordChangeRequestRequestDto) =>
- AuthService.passwordChangeRequest(payload),
+ mutationKey: ["auth", "passwordReset"],
+ mutationFn: (payload: PasswordResetRequestDto) =>
+ AuthService.passwordReset(payload),
onSuccess: () => {
toast.success(t("passwordChangeRequestSuccess"));
},
@@ -94,18 +108,22 @@ export function usePasswordChangeRequest() {
});
}
-export function usePasswordChange() {
+/** @deprecated Используй usePasswordReset */
+export const usePasswordChangeRequest = usePasswordReset;
+
+/** Подтверждение сброса пароля (установка нового) */
+export function usePasswordResetConfirm() {
const t = useTranslations("Auth.PasswordChange");
const router = useRouter();
const queryClient = useQueryClient();
return useMutation<
- PasswordChangeResponseDto,
+ PasswordResetConfirmResponseDto,
unknown,
- PasswordChangeRequestDto
+ PasswordResetConfirmRequestDto
>({
- mutationKey: ["auth", "passwordChange"],
- mutationFn: (payload: PasswordChangeRequestDto) =>
- AuthService.passwordChange(payload),
+ mutationKey: ["auth", "passwordResetConfirm"],
+ mutationFn: (payload: PasswordResetConfirmRequestDto) =>
+ AuthService.passwordResetConfirm(payload),
onSuccess: async () => {
await clearAuthTokens();
queryClient.invalidateQueries({ queryKey: ["me"] });
@@ -117,3 +135,6 @@ export function usePasswordChange() {
},
});
}
+
+/** @deprecated Используй usePasswordResetConfirm */
+export const usePasswordChange = usePasswordResetConfirm;
diff --git a/src/shared/hooks/use-bagsies.ts b/src/shared/hooks/use-bagsies.ts
deleted file mode 100644
index a8a10f0..0000000
--- a/src/shared/hooks/use-bagsies.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-"use client";
-import { useMutation, useQueryClient } from "@tanstack/react-query";
-import BagsieService, {
- CreateBagsieRequestDto,
-} from "../services/bagsies-service";
-
-/**
- * Хук для создания записи POST /api/v1/bagsies/master
- */
-export function useCreateBagsie() {
- const q = useQueryClient();
- return useMutation({
- mutationFn: (payload: CreateBagsieRequestDto) =>
- BagsieService.createBagsie(payload),
- onSuccess: () => {
- q.invalidateQueries({ queryKey: ["calendar"] });
- },
- });
-}
diff --git a/src/shared/hooks/use-calendar.ts b/src/shared/hooks/use-calendar.ts
index 185208f..cb72065 100644
--- a/src/shared/hooks/use-calendar.ts
+++ b/src/shared/hooks/use-calendar.ts
@@ -6,10 +6,10 @@ import { CalendarService } from "../services/calendar-service";
import { mapCalendarApiResponseToEvents } from "../utils/calendar-api-mapper";
import { getCalendarDateRange } from "../utils/calendar-date-range";
import { useCurrentUser } from "./use-users";
-import { useGetStaff } from "./user-staff";
+import { useGetEmployees } from "./user-staff";
import { EUserRole } from "../types/user";
-import type { IUserDto, TUserRole } from "../types/user";
-import type { GetStaffParams } from "../services/staff-service";
+import type { IEmployeeDto, TUserRole } from "../types/user";
+import type { GetEmployeesParams } from "../services/employee-service";
import type {
GetCalendarParams,
IEvent,
@@ -24,10 +24,10 @@ export interface UseCalendarParams {
selectedDate: Date;
/** Вид календаря */
view: TCalendarView;
- /** Код точки для фильтрации (для SelfOwner/NetManager) */
- pointCode?: string;
- /** Телефон мастера для фильтрации (для Manager и выше) */
- masterPhone?: string;
+ /** UUID локации для фильтрации (для Owner) */
+ locationId?: string;
+ /** UUID сотрудника для фильтрации (для Manager и выше) */
+ employeeId?: string;
}
/**
@@ -37,8 +37,8 @@ export interface UseCalendarParams {
export function useCalendar({
selectedDate,
view,
- pointCode,
- masterPhone,
+ locationId,
+ employeeId,
}: UseCalendarParams) {
// Получаем текущего пользователя для определения роли
const { data: currentUser } = useCurrentUser();
@@ -53,12 +53,8 @@ export function useCalendar({
const calendarParams = useMemo(() => {
if (!currentUser) return null;
- // Для SelfOwner и NetManager точка обязательна - не запрашиваем календарь без точки
- if (
- (currentUser.role === EUserRole.SELF_OWNER ||
- currentUser.role === EUserRole.NET_MANAGER) &&
- !pointCode
- ) {
+ // Для Owner точка обязательна
+ if (currentUser.role === EUserRole.OWNER && !locationId) {
return null;
}
@@ -67,28 +63,29 @@ export function useCalendar({
to: dateRange.to,
};
- // Для SelfOwner и NetManager доступен фильтр по точке
- if (
- (currentUser.role === EUserRole.SELF_OWNER ||
- currentUser.role === EUserRole.NET_MANAGER) &&
- pointCode
- ) {
- params.point_code = pointCode;
+ // Для Owner доступен фильтр по точке
+ if (currentUser.role === EUserRole.OWNER && locationId) {
+ params.location_id = locationId;
+ }
+
+ // Для Manager — фильтр по точке из профиля
+ if (currentUser.role === EUserRole.MANAGER) {
+ if (currentUser.location_id) {
+ params.location_id = currentUser.location_id;
+ }
}
- // Для Manager и выше доступен фильтр по мастеру
+ // Для Manager и Owner доступен фильтр по сотруднику
if (
(currentUser.role === EUserRole.MANAGER ||
- currentUser.role === EUserRole.SELF_OWNER ||
- currentUser.role === EUserRole.NET_MANAGER ||
- currentUser.role === EUserRole.ADMIN) &&
- masterPhone
+ currentUser.role === EUserRole.OWNER) &&
+ employeeId
) {
- params.master_phone = masterPhone;
+ params.employee_id = employeeId;
}
return params;
- }, [currentUser, dateRange, pointCode, masterPhone]);
+ }, [currentUser, dateRange, locationId, employeeId]);
// Загружаем данные календаря
const calendarQuery = useQuery({
@@ -103,70 +100,64 @@ export function useCalendar({
staleTime: 30 * 1000, // 30 секунд
});
- // Загружаем список мастеров для обогащения событий
- // Для Staff - только текущий пользователь, для остальных - все мастера точки/сети
- const staffParams = useMemo(() => {
+ // Загружаем список сотрудников для обогащения событий
+ const employeesParams = useMemo(() => {
if (!currentUser) return undefined;
- // Для Staff - только свои записи, мастера не нужны
+ // Для Staff - только свои записи, сотрудники не нужны
if (currentUser.role === EUserRole.STAFF) {
return undefined;
}
- // Для Manager - мастера точки
+ // Для Manager - сотрудники локации
if (currentUser.role === EUserRole.MANAGER) {
return {
- point_code: currentUser.point_code,
+ location_id: currentUser.location_id,
role: [EUserRole.STAFF, EUserRole.MANAGER] as TUserRole[],
};
}
- // Для SelfOwner/NetManager - мастера выбранной точки или всех точек
- if (
- currentUser.role === EUserRole.SELF_OWNER ||
- currentUser.role === EUserRole.NET_MANAGER
- ) {
+ // Для Owner - сотрудники выбранной локации или все
+ if (currentUser.role === EUserRole.OWNER) {
return {
- ...(pointCode && { point_code: pointCode }),
+ ...(locationId && { location_id: locationId }),
role: [
EUserRole.STAFF,
- EUserRole.SELF_OWNER,
+ EUserRole.OWNER,
EUserRole.MANAGER,
] as TUserRole[],
};
}
return undefined;
- }, [currentUser, pointCode]);
+ }, [currentUser, locationId]);
- // Загружаем список мастеров только для Manager и выше
- // Для Staff запрос не нужен, так как они видят только свои записи
- // useGetStaff автоматически отключит запрос, если staffParams === undefined
- const staffQuery = useGetStaff(staffParams);
+ // Загружаем список сотрудников только для Manager и выше
+ const employeesQuery = useGetEmployees(employeesParams);
// Маппим данные из API в IEvent[]
const events = useMemo(() => {
if (!calendarQuery.data) return [];
return mapCalendarApiResponseToEvents(calendarQuery.data);
- }, [calendarQuery.data, staffQuery.data]);
+ }, [calendarQuery.data, employeesQuery.data]);
- // Список мастеров для UI: берём из staff ручки, а для Staff мастер = текущий пользователь
- const masters = useMemo(() => {
+ // Список мастеров для UI
+ const masters = useMemo(() => {
if (!currentUser) return [];
if (currentUser.role === EUserRole.STAFF) return [currentUser];
- return staffQuery.data?.users ?? [];
- }, [currentUser, staffQuery.data?.users]);
+ return employeesQuery.data?.employees ?? [];
+ }, [currentUser, employeesQuery.data?.employees]);
return {
events,
masters,
- isLoading: calendarQuery.isLoading || staffQuery.isLoading,
- isError: calendarQuery.isError || staffQuery.isError,
- error: calendarQuery.error || staffQuery.error,
+ isLoading: calendarQuery.isLoading || employeesQuery.isLoading,
+ isError: calendarQuery.isError || employeesQuery.isError,
+ error: calendarQuery.error || employeesQuery.error,
refetch: () => {
calendarQuery.refetch();
- staffQuery.refetch();
+ employeesQuery.refetch();
},
};
}
diff --git a/src/shared/hooks/use-count-up.ts b/src/shared/hooks/use-count-up.ts
new file mode 100644
index 0000000..25aea1e
--- /dev/null
+++ b/src/shared/hooks/use-count-up.ts
@@ -0,0 +1,44 @@
+"use client";
+import { useEffect, useState } from "react";
+
+/**
+ * Хук для плавной "счётной" анимации числовых KPI.
+ * При смене target значение анимируется от старого к новому за durationMs.
+ * Уважает prefers-reduced-motion — отключает анимацию и возвращает target сразу.
+ */
+export function useCountUp(target: number, durationMs: number = 600): number {
+ const [value, setValue] = useState(target);
+
+ useEffect(() => {
+ // Если пользователь не любит анимации — мгновенный рендер
+ if (
+ typeof window !== "undefined" &&
+ window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
+ ) {
+ setValue(target);
+ return;
+ }
+
+ const start = value;
+ const diff = target - start;
+ if (diff === 0) return;
+
+ let rafId: number;
+ const startedAt = performance.now();
+
+ const tick = (now: number) => {
+ const progress = Math.min((now - startedAt) / durationMs, 1);
+ // ease-out cubic — быстро в начале, плавно к концу
+ const eased = 1 - Math.pow(1 - progress, 3);
+ setValue(start + diff * eased);
+ if (progress < 1) rafId = requestAnimationFrame(tick);
+ };
+
+ rafId = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(rafId);
+ // value намеренно не в deps — иначе бесконечный цикл
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [target, durationMs]);
+
+ return value;
+}
diff --git a/src/shared/hooks/use-debounce.ts b/src/shared/hooks/use-debounce.ts
index b4b7fad..db0f70a 100644
--- a/src/shared/hooks/use-debounce.ts
+++ b/src/shared/hooks/use-debounce.ts
@@ -76,6 +76,5 @@ export function useDebounceCallback(
return () => {
clearTimeout(timer);
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, delay]);
}
diff --git a/src/shared/hooks/use-location-schedule.ts b/src/shared/hooks/use-location-schedule.ts
new file mode 100644
index 0000000..3fc4921
--- /dev/null
+++ b/src/shared/hooks/use-location-schedule.ts
@@ -0,0 +1,66 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { useMemo } from "react";
+import { startOfWeek, endOfWeek, format, getDay } from "date-fns";
+import { ScheduleService } from "../services/schedule-service";
+import type { ScheduleSlotDto } from "../types/schedule";
+
+/**
+ * Расписание одного дня недели (сгруппированное из date-based слотов)
+ */
+export interface WeekDaySchedule {
+ start_time: string; // "HH:mm"
+ end_time: string; // "HH:mm"
+}
+
+/**
+ * Хук для загрузки расписания локации на текущую неделю
+ * Группирует date-based слоты по дням недели (0=вс, 1=пн, ..., 6=сб)
+ */
+export function useLocationScheduleWeek(locationId: string | undefined) {
+ // Диапазон текущей недели (пн — вс)
+ const { mondayStr, sundayStr } = useMemo(() => {
+ const now = new Date();
+ const monday = startOfWeek(now, { weekStartsOn: 1 });
+ const sunday = endOfWeek(now, { weekStartsOn: 1 });
+ return {
+ mondayStr: format(monday, "yyyy-MM-dd"),
+ sundayStr: format(sunday, "yyyy-MM-dd"),
+ };
+ }, []);
+
+ const query = useQuery({
+ queryKey: ["location-schedule", locationId, mondayStr, sundayStr],
+ queryFn: () =>
+ ScheduleService.getLocationSchedule(locationId!, mondayStr, sundayStr),
+ enabled: !!locationId,
+ staleTime: 5 * 60 * 1000, // 5 минут
+ });
+
+ // Группируем слоты по дню недели
+ const scheduleByDay = useMemo(() => {
+ const result: Record = {};
+ if (!query.data?.slots) return result;
+
+ query.data.slots.forEach((slot: ScheduleSlotDto) => {
+ // Только рабочие слоты
+ if (slot.type !== "work") return;
+
+ const weekDay = getDay(new Date(slot.date + "T00:00:00"));
+ if (!result[weekDay]) result[weekDay] = [];
+ result[weekDay].push({
+ start_time: slot.start_time,
+ end_time: slot.end_time,
+ });
+ });
+
+ return result;
+ }, [query.data]);
+
+ return {
+ scheduleByDay,
+ isLoading: query.isLoading,
+ error: query.error,
+ };
+}
diff --git a/src/shared/hooks/use-master-services.ts b/src/shared/hooks/use-master-services.ts
index c38290f..a117331 100644
--- a/src/shared/hooks/use-master-services.ts
+++ b/src/shared/hooks/use-master-services.ts
@@ -2,29 +2,44 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
- MasterService,
- type CreateMasterServiceRequestDto,
- type CreateMasterServiceResponseDto,
-} from "../services/master-service";
+ EmployeeService,
+ type CreateEmployeeServiceRequest,
+ type CreateEmployeeServiceResponse,
+} from "../services/employee-service";
+
+/** Хук для отвязки сотрудника от услуги DELETE /api/v1/employee-services/{id} */
+export function useRemoveMasterService() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationKey: ["employee-services", "remove"],
+ mutationFn: (id: string) => EmployeeService.removeEmployeeService(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["services", "location"] });
+ queryClient.invalidateQueries({ queryKey: ["employee-services"] });
+ },
+ });
+}
/**
- * Хук для создания связи мастер-услуга
+ * Хук для привязки сотрудника к услуге POST /api/v1/employee-services
* Инвалидирует кэш списка услуг после успешного создания
*/
export function useCreateMasterService() {
const queryClient = useQueryClient();
return useMutation<
- CreateMasterServiceResponseDto,
+ CreateEmployeeServiceResponse,
unknown,
- CreateMasterServiceRequestDto
+ CreateEmployeeServiceRequest
>({
- mutationKey: ["master-services", "create"],
- mutationFn: (data: CreateMasterServiceRequestDto) =>
- MasterService.createMasterService(data),
+ mutationKey: ["employee-services", "create"],
+ mutationFn: (data: CreateEmployeeServiceRequest) =>
+ EmployeeService.createEmployeeService(data),
onSuccess: () => {
- // Инвалидируем кэш услуг для обновления данных в таблице
- queryClient.invalidateQueries({ queryKey: ["services", "point"] });
+ // Обновляем список услуг локации и карту сотрудник-услуга
+ queryClient.invalidateQueries({ queryKey: ["services", "location"] });
+ queryClient.invalidateQueries({ queryKey: ["employee-services"] });
},
});
}
diff --git a/src/shared/hooks/use-media-upload.ts b/src/shared/hooks/use-media-upload.ts
index 88512a0..24ff7b5 100644
--- a/src/shared/hooks/use-media-upload.ts
+++ b/src/shared/hooks/use-media-upload.ts
@@ -2,20 +2,22 @@
import { useMutation } from "@tanstack/react-query";
import { MediaService } from "../services/media-service";
+import type { MediaPurpose } from "../types/media";
/** Допустимые MIME для аватара */
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"] as const;
const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
/**
- * Хук загрузки аватара: POST v1/media/upload → presigned URL, затем PUT в S3.
- * Возвращает media_id для передачи в PUT v1/users/me { avatar_id }.
+ * Хук загрузки медиа: POST /api/v1/media/upload → presigned URL,
+ * затем multipart POST в S3 с upload_fields, затем POST /api/v1/media/{id}/confirm.
+ * Возвращает asset_id для передачи в PUT /api/v1/employees/me { avatar_id }.
* Валидация: image/jpeg, image/png, image/webp, до 5 MB.
*/
export function useMediaUpload() {
- return useMutation({
+ return useMutation({
mutationKey: ["media", "upload"],
- mutationFn: async (file: File) => {
+ mutationFn: async ({ file, purpose = "avatars" }) => {
if (
!ALLOWED_TYPES.includes(file.type as (typeof ALLOWED_TYPES)[number])
) {
@@ -25,23 +27,37 @@ export function useMediaUpload() {
throw new Error("Размер файла не должен превышать 5 MB.");
}
- const { url, media_id } = await MediaService.uploadRequest({
- content_type: file.type,
- filename: file.name,
- purpose: "avatar",
+ // 1. Получаем presigned URL и upload_fields
+ const { upload_url, upload_fields, asset_id } =
+ await MediaService.uploadRequest({
+ mime_type: file.type,
+ filename: file.name,
+ purpose,
+ size_bytes: file.size,
+ });
+
+ // 2. Загружаем файл в S3 через multipart POST
+ const formData = new FormData();
+ // Сначала добавляем все поля из upload_fields
+ Object.entries(upload_fields).forEach(([key, value]) => {
+ formData.append(key, value);
});
+ // Файл должен быть последним полем
+ formData.append("file", file);
- const res = await fetch(url, {
- method: "PUT",
- body: file,
- headers: { "Content-Type": file.type },
+ const res = await fetch(upload_url, {
+ method: "POST",
+ body: formData,
});
if (!res.ok) {
throw new Error(`Ошибка загрузки в хранилище: ${res.status}`);
}
- return media_id;
+ // 3. Подтверждаем загрузку
+ await MediaService.confirmUpload(asset_id);
+
+ return asset_id;
},
});
}
diff --git a/src/shared/hooks/use-network-locations.ts b/src/shared/hooks/use-network-locations.ts
new file mode 100644
index 0000000..e8bd4e7
--- /dev/null
+++ b/src/shared/hooks/use-network-locations.ts
@@ -0,0 +1,134 @@
+"use client";
+
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import {
+ LocationService,
+ type GetLocationsParams,
+ type CreateLocationRequestDto,
+ type CreateLocationResponseDto,
+ type UpdateLocationRequestDto,
+ type ILocationDto,
+} from "../services/location-service";
+import { useCurrentUser } from "./use-users";
+import { EUserRole } from "../types/user";
+
+/**
+ * Хук для загрузки списка локаций организации (GET /api/v1/locations)
+ * Автоматически отключается для ролей, которым не нужен выбор локации
+ */
+export function useLocations(params?: GetLocationsParams) {
+ const { data: currentUser } = useCurrentUser();
+
+ // Только Owner может запрашивать список локаций
+ const shouldFetch = currentUser && currentUser.role === EUserRole.OWNER;
+
+ return useQuery({
+ queryKey: ["locations", params],
+ queryFn: () => LocationService.getLocations(params),
+ enabled: !!shouldFetch,
+ staleTime: 5 * 60 * 1000, // 5 минут — локации не меняются часто
+ });
+}
+
+/**
+ * Хук для загрузки одной локации по ID (GET /api/v1/locations/{id})
+ */
+export function useLocation(id: string | undefined) {
+ return useQuery({
+ queryKey: ["locations", id],
+ queryFn: () => LocationService.getLocation(id!),
+ enabled: !!id,
+ staleTime: 5 * 60 * 1000,
+ });
+}
+
+/**
+ * Хук для загрузки списка локаций для страницы locations
+ * Поддерживает роли Owner и Manager
+ */
+export function useLocationsPage() {
+ const { data: currentUser } = useCurrentUser();
+
+ // Проверяем, есть ли доступ (Manager и выше)
+ const hasAccess =
+ currentUser &&
+ (currentUser.role === EUserRole.MANAGER ||
+ currentUser.role === EUserRole.OWNER);
+
+ return useQuery({
+ queryKey: ["locations", "page"],
+ queryFn: () => LocationService.getLocations(),
+ enabled: !!hasAccess,
+ staleTime: 5 * 60 * 1000,
+ });
+}
+
+/**
+ * Хук для загрузки категорий локаций (GET /api/v1/locations/categories)
+ * Категории кэшируются на 20 минут, так как меняются редко
+ */
+export function useLocationCategories() {
+ return useQuery({
+ queryKey: ["locationCategories"],
+ queryFn: () => LocationService.getLocationCategories(),
+ staleTime: 20 * 60 * 1000,
+ });
+}
+
+/**
+ * Хук для создания новой локации обслуживания (POST /api/v1/locations)
+ * Инвалидирует кэш списка локаций после успешного создания
+ */
+export function useCreateLocation() {
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ CreateLocationResponseDto,
+ unknown,
+ CreateLocationRequestDto
+ >({
+ mutationKey: ["locations", "create"],
+ mutationFn: (data: CreateLocationRequestDto) =>
+ LocationService.createLocation(data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["locations"] });
+ },
+ });
+}
+
+/**
+ * Хук для обновления локации (PUT /api/v1/locations/{id})
+ * Инвалидирует кэш списка и конкретной локации
+ */
+export function useUpdateLocation() {
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ ILocationDto,
+ unknown,
+ { id: string; data: UpdateLocationRequestDto }
+ >({
+ mutationKey: ["locations", "update"],
+ mutationFn: ({ id, data }) => LocationService.updateLocation(id, data),
+ onSuccess: (_, { id }) => {
+ queryClient.invalidateQueries({ queryKey: ["locations"] });
+ queryClient.invalidateQueries({ queryKey: ["locations", id] });
+ },
+ });
+}
+
+/**
+ * Хук для удаления локации (DELETE /api/v1/locations/{id})
+ * Инвалидирует кэш списка локаций
+ */
+export function useDeleteLocation() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationKey: ["locations", "delete"],
+ mutationFn: (id: string) => LocationService.deleteLocation(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["locations"] });
+ },
+ });
+}
diff --git a/src/shared/hooks/use-network-points.ts b/src/shared/hooks/use-network-points.ts
deleted file mode 100644
index 43bca9d..0000000
--- a/src/shared/hooks/use-network-points.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-"use client";
-
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import {
- PointService,
- type CreatePointRequestDto,
- type IPointDto,
-} from "../services/point-service";
-import { useCurrentUser } from "./use-users";
-import { EUserRole } from "../types/user";
-
-/**
- * Хук для загрузки списка точек сети
- * Автоматически отключается для ролей, которым не нужен выбор точки
- */
-export function useNetworkPoints(networkCode: string | undefined) {
- const { data: currentUser } = useCurrentUser();
-
- // Определяем, нужно ли загружать точки
- const shouldFetch =
- networkCode &&
- currentUser &&
- (currentUser.role === EUserRole.NET_MANAGER ||
- currentUser.role === EUserRole.SELF_OWNER);
-
- return useQuery({
- queryKey: ["networkPoints", networkCode],
- queryFn: () => {
- if (!networkCode) {
- throw new Error("Network code is required");
- }
- return PointService.getNetworkPoints(networkCode);
- },
- enabled: !!shouldFetch,
- staleTime: 5 * 60 * 1000, // 5 минут - точки не меняются часто
- });
-}
-
-/**
- * Хук для загрузки списка точек для страницы points
- * Поддерживает роли MANAGER, SELF_OWNER, NET_MANAGER, ADMIN
- */
-export function usePointsPage() {
- const { data: currentUser } = useCurrentUser();
-
- // Определяем network_code на основе роли
- const networkCode = currentUser?.network_code;
-
- // Проверяем, есть ли доступ (MANAGER и выше)
- const hasAccess =
- currentUser &&
- (currentUser.role === EUserRole.MANAGER ||
- currentUser.role === EUserRole.SELF_OWNER ||
- currentUser.role === EUserRole.NET_MANAGER ||
- currentUser.role === EUserRole.ADMIN);
-
- return useQuery({
- queryKey: ["pointsPage", networkCode],
- queryFn: () => {
- if (!networkCode) {
- throw new Error("Network code is required");
- }
- return PointService.getNetworkPoints(networkCode);
- },
- enabled: !!hasAccess && !!networkCode,
- staleTime: 5 * 60 * 1000, // 5 минут - точки не меняются часто
- });
-}
-
-/**
- * Хук для загрузки списка категорий точек
- * Категории кэшируются на 10 минут, так как меняются редко
- */
-export function usePointCategories() {
- return useQuery({
- queryKey: ["pointCategories"],
- queryFn: () => PointService.getPointCategories(),
- staleTime: 20 * 60 * 1000, // 20 минут - категории меняются редко
- });
-}
-
-/**
- * Хук для создания новой точки обслуживания
- * Инвалидирует кэш списка точек после успешного создания
- */
-export function useCreatePoint() {
- const queryClient = useQueryClient();
-
- return useMutation({
- mutationKey: ["points", "create"],
- mutationFn: (data: CreatePointRequestDto) => PointService.createPoint(data),
- onSuccess: () => {
- // Инвалидируем кэш списка точек для обновления данных
- queryClient.invalidateQueries({ queryKey: ["pointsPage"] });
- queryClient.invalidateQueries({ queryKey: ["networkPoints"] });
- },
- });
-}
diff --git a/src/shared/hooks/use-organization.ts b/src/shared/hooks/use-organization.ts
new file mode 100644
index 0000000..8190734
--- /dev/null
+++ b/src/shared/hooks/use-organization.ts
@@ -0,0 +1,30 @@
+"use client";
+
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import {
+ OrganizationService,
+ type UpdateOrganizationRequest,
+ type UpdateOrganizationResponse,
+} from "../services/organization-service";
+
+/**
+ * Хук для обновления профиля организации (PUT /api/v1/organizations/me)
+ * Используется при создании сети — задаёт название и описание
+ * Инвалидирует кэш ["me"], т.к. organization вложена в /employees/me
+ */
+export function useUpdateOrganization() {
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ UpdateOrganizationResponse,
+ unknown,
+ UpdateOrganizationRequest
+ >({
+ mutationKey: ["organization", "update"],
+ mutationFn: (data: UpdateOrganizationRequest) =>
+ OrganizationService.updateOrganization(data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["me"] });
+ },
+ });
+}
diff --git a/src/shared/hooks/use-schedule-permissions.ts b/src/shared/hooks/use-schedule-permissions.ts
new file mode 100644
index 0000000..d68940d
--- /dev/null
+++ b/src/shared/hooks/use-schedule-permissions.ts
@@ -0,0 +1,92 @@
+"use client";
+
+import { useMemo } from "react";
+import type {
+ ScheduleScope,
+ ScheduleType,
+ ScheduleUserFlags,
+ PointScheduleContext,
+} from "@/src/shared/types/schedule";
+
+export interface UseSchedulePermissionsArgs {
+ /** Текущий пользователь: опциональные флаги can_work, can_manage_point_schedule. */
+ userFlags?: ScheduleUserFlags | null;
+ /** Контекст точки: schedule_type (fixed | mixed). */
+ pointContext?: PointScheduleContext | null;
+ /** Solo-план: владелец = единственный сотрудник, табы не нужны. */
+ isSoloPlan?: boolean;
+}
+
+export interface UseSchedulePermissionsResult {
+ /** Можно ли редактировать график точки. */
+ canEditPointSchedule: boolean;
+ /** Можно ли редактировать свой (мастерский) график. */
+ canEditOwnSchedule: boolean;
+ /** Фиксированный график точки: мастер видит график точки read-only. */
+ isPointScheduleFixed: boolean;
+ /** Solo-план: один владелец, табы скрыты. */
+ isSoloPlan: boolean;
+ /** Доступные режимы: point и/или staff. */
+ scopeOptions: ScheduleScope[];
+ /** Режим по умолчанию при наличии обоих. */
+ defaultScope: ScheduleScope;
+}
+
+/**
+ * Определяет права на редактирование графика по типу точки и флагам пользователя.
+ *
+ * Solo plan → только "point" scope, табы скрыты, полный доступ.
+ * Fixed → "point" (если can_manage) + "staff" (read-only просмотр если can_work).
+ * Mixed → "point" (если can_manage) + "staff" (редактируемый если can_work).
+ */
+export function useSchedulePermissions({
+ userFlags,
+ pointContext,
+ isSoloPlan = false,
+}: UseSchedulePermissionsArgs): UseSchedulePermissionsResult {
+ return useMemo(() => {
+ const scheduleType: ScheduleType = pointContext?.schedule_type ?? "mixed";
+ const canWork = userFlags?.can_work ?? false;
+ const canManagePoint = userFlags?.can_manage_point_schedule ?? false;
+
+ /* Solo: владелец управляет расписанием точки напрямую, табов нет. */
+ if (isSoloPlan) {
+ return {
+ canEditPointSchedule: true,
+ canEditOwnSchedule: false,
+ isPointScheduleFixed: false,
+ isSoloPlan: true,
+ scopeOptions: ["point"] as ScheduleScope[],
+ defaultScope: "point" as ScheduleScope,
+ };
+ }
+
+ const isPointScheduleFixed = scheduleType === "fixed";
+ const canEditPointSchedule = canManagePoint;
+ /* Свой график мастер правит только при mixed и наличии can_work. */
+ const canEditOwnSchedule = canWork && !isPointScheduleFixed;
+
+ const scopeOptions: ScheduleScope[] = [];
+ if (canEditPointSchedule) scopeOptions.push("point");
+ /* Staff таб виден если can_work — при fixed он будет read-only. */
+ if (canWork) scopeOptions.push("staff");
+
+ const defaultScope: ScheduleScope = scopeOptions.includes("staff")
+ ? "staff"
+ : "point";
+
+ return {
+ canEditPointSchedule,
+ canEditOwnSchedule,
+ isPointScheduleFixed,
+ isSoloPlan: false,
+ scopeOptions,
+ defaultScope,
+ };
+ }, [
+ userFlags?.can_work,
+ userFlags?.can_manage_point_schedule,
+ pointContext?.schedule_type,
+ isSoloPlan,
+ ]);
+}
diff --git a/src/shared/hooks/use-services.ts b/src/shared/hooks/use-services.ts
index b571f3d..492182e 100644
--- a/src/shared/hooks/use-services.ts
+++ b/src/shared/hooks/use-services.ts
@@ -4,41 +4,42 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
ServiceService,
type CreateServiceRequestDto,
+ type UpdateServiceRequestDto,
type IServiceDto,
} from "../services/service-service";
/**
- * Хук для загрузки списка услуг точки
+ * Хук для загрузки списка услуг локации
*/
-export function usePointServices(pointCode: string | undefined) {
+export function useLocationServices(locationId: string | undefined) {
return useQuery({
- queryKey: ["services", "point", pointCode],
+ queryKey: ["services", "location", locationId],
queryFn: () => {
- if (!pointCode) {
- throw new Error("Point code is required");
+ if (!locationId) {
+ throw new Error("Location ID is required");
}
- return ServiceService.getPointServices(pointCode);
+ return ServiceService.getLocationServices(locationId);
},
- enabled: !!pointCode,
+ enabled: !!locationId,
staleTime: 2 * 60 * 1000, // 2 минуты - услуги могут меняться чаще
});
}
/**
- * Хук для загрузки списка категорий услуг и их подкатегорий
- * Категории кэшируются на 20 минут, так как меняются редко
+ * Хук для загрузки дерева категорий услуг по типу бизнеса
+ * @param locationCategoryId — UUID категории локации
*/
-export function useServiceCategories(pointCode: string | undefined) {
+export function useServiceCategories(locationCategoryId: string | undefined) {
return useQuery({
- queryKey: ["serviceCategories", pointCode],
+ queryKey: ["serviceCategories", locationCategoryId],
queryFn: () => {
- if (!pointCode) {
- throw new Error("Point code is required");
+ if (!locationCategoryId) {
+ throw new Error("Location category ID is required");
}
- return ServiceService.getServiceCategories(pointCode);
+ return ServiceService.getServiceCategories(locationCategoryId);
},
- enabled: !!pointCode,
- staleTime: 20 * 60 * 1000, // 20 минут - категории меняются редко
+ enabled: !!locationCategoryId,
+ staleTime: 20 * 60 * 1000, // 20 минут — категории меняются редко
});
}
@@ -54,12 +55,44 @@ export function useCreateService() {
mutationFn: (data: CreateServiceRequestDto) =>
ServiceService.createService(data),
onSuccess: (_, variables) => {
- // Инвалидируем кэш списка услуг для обновления данных
queryClient.invalidateQueries({
- queryKey: ["services", "point", variables.point_code],
+ queryKey: ["services", "location", variables.location_id],
});
- // Также инвалидируем все запросы услуг точки (на случай если есть другие запросы)
- queryClient.invalidateQueries({ queryKey: ["services", "point"] });
+ queryClient.invalidateQueries({ queryKey: ["services", "location"] });
+ },
+ });
+}
+
+/**
+ * Хук для обновления услуги (PUT /api/v1/services/{id})
+ */
+export function useUpdateService() {
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ void,
+ unknown,
+ { id: string; data: UpdateServiceRequestDto }
+ >({
+ mutationKey: ["services", "update"],
+ mutationFn: ({ id, data }) => ServiceService.updateService(id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["services", "location"] });
+ },
+ });
+}
+
+/**
+ * Хук для удаления услуги (DELETE /api/v1/services/{id})
+ */
+export function useDeleteService() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationKey: ["services", "delete"],
+ mutationFn: id => ServiceService.deleteService(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["services", "location"] });
},
});
}
diff --git a/src/shared/hooks/use-users.ts b/src/shared/hooks/use-users.ts
index 4706ab6..403cb5c 100644
--- a/src/shared/hooks/use-users.ts
+++ b/src/shared/hooks/use-users.ts
@@ -1,30 +1,31 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { EmployeeService } from "../services/employee-service";
import { UserService } from "../services/user-service";
-import { nowTimestampWithTz } from "../utils/formater";
import type {
- UpdateProfileRequest,
+ IEmployeeDto,
+ UpdateEmployeeAccountRequest,
UpdateScheduleRequest,
- IUserDto,
} from "../types/user";
/**
- * Получение текущего пользователя
+ * Получение текущего сотрудника (GET /api/v1/employees/me)
*/
export function useCurrentUser() {
return useQuery({
queryKey: ["me"],
- queryFn: () => UserService.getMe(),
- staleTime: 30 * 60 * 1000, // 30 минут - пользователь не меняется часто
- gcTime: 60 * 60 * 1000, // 1 час в кеше
+ queryFn: () => EmployeeService.getMe(),
+ staleTime: 30 * 60 * 1000, // 30 минут
+ gcTime: 60 * 60 * 1000,
retry: 1,
- refetchOnWindowFocus: false, // не перезапрашивать при фокусе окна
- refetchOnMount: false, // не перезапрашивать при монтировании если есть кеш
+ refetchOnWindowFocus: false,
+ refetchOnMount: false,
});
}
/**
* Получение информации о пользователе по номеру телефона
+ * TODO: ждём новый эндпоинт от бэка
*/
export function useGetUserByPhone(phone: string) {
return useQuery({
@@ -35,54 +36,41 @@ export function useGetUserByPhone(phone: string) {
}
/**
- * Хук для обновления профиля пользователя
+ * Хук для обновления профиля сотрудника (PUT /api/v1/employees/me)
* Включает оптимистичные обновления и инвалидацию кэша
*/
-export function useUpdateProfile() {
+export function useUpdateAccount() {
const queryClient = useQueryClient();
- return useMutation({
+ return useMutation<
+ IEmployeeDto,
+ unknown,
+ UpdateEmployeeAccountRequest,
+ { previousData?: IEmployeeDto }
+ >({
mutationKey: ["me", "update"],
- mutationFn: async (data: UpdateProfileRequest) =>
- UserService.updateMe(data),
+ mutationFn: async (data: UpdateEmployeeAccountRequest) =>
+ EmployeeService.updateMe(data),
onMutate: async newData => {
- // Отменяем исходящие запросы
await queryClient.cancelQueries({ queryKey: ["me"] });
+ const previousData = queryClient.getQueryData(["me"]);
- // Сохраняем предыдущие данные для отката
- const previousData = queryClient.getQueryData(["me"]);
-
- // Оптимистично обновляем данные только если они есть
- if (
- previousData &&
- typeof previousData === "object" &&
- "data" in previousData &&
- (previousData as { data: IUserDto }).data
- ) {
- const prevUser = (previousData as { data: IUserDto }).data;
- queryClient.setQueryData(["me"], {
+ // Оптимистично обновляем данные
+ if (previousData) {
+ queryClient.setQueryData(["me"], {
...previousData,
- data: {
- ...prevUser,
- ...newData,
- updated_at: nowTimestampWithTz(),
- },
+ ...newData,
});
}
return { previousData };
},
- onError: (err, newData, context) => {
- // Откатываем изменения при ошибке
- if (context && typeof context === "object" && "previousData" in context) {
- const prevData = (context as { previousData?: unknown }).previousData;
- if (prevData) {
- queryClient.setQueryData(["me"], prevData);
- }
+ onError: (_err, _newData, context) => {
+ if (context?.previousData) {
+ queryClient.setQueryData(["me"], context.previousData);
}
},
onSettled: () => {
- // Инвалидируем кэш для получения актуальных данных
queryClient.invalidateQueries({ queryKey: ["me"] });
},
});
@@ -90,6 +78,7 @@ export function useUpdateProfile() {
/**
* Хук обновления расписания. PUT v1/users/me/schedule, инвалидация ["me"].
+ * TODO: ждём новый эндпоинт от бэка
*/
export function useUpdateSchedule() {
const queryClient = useQueryClient();
@@ -106,6 +95,7 @@ export function useUpdateSchedule() {
/**
* Хук удаления аватара. DELETE v1/users/me/avatar, инвалидация ["me"].
+ * TODO: ждём новый эндпоинт от бэка
*/
export function useDeleteAvatar() {
const queryClient = useQueryClient();
diff --git a/src/shared/hooks/user-staff.ts b/src/shared/hooks/user-staff.ts
index 6066397..5708c93 100644
--- a/src/shared/hooks/user-staff.ts
+++ b/src/shared/hooks/user-staff.ts
@@ -1,38 +1,263 @@
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+"use client";
+
+import { useMemo } from "react";
+import {
+ useQuery,
+ useQueries,
+ useMutation,
+ useQueryClient,
+} from "@tanstack/react-query";
import {
- StaffService,
- GetStaffParams,
- RegisterStaffRequest,
- RegisterStaffResponse,
-} from "../services/staff-service";
+ EmployeeService,
+ type GetEmployeesParams,
+ type InviteEmployeeRequest,
+ type InviteEmployeeResponse,
+} from "../services/employee-service";
+import { EUserRole } from "../types/user";
+import type {
+ IEmployeeDto,
+ IEmployeePermissions,
+ TUserRole,
+} from "../types/user";
+import { useTranslations } from "next-intl";
+import { toast } from "sonner";
+import { getApiErrorKey } from "../utils/api-error";
/**
- * Хук для получения списка сотрудников с фильтрацией, сортировкой и пагинацией
+ * Хук для получения списка сотрудников (GET /api/v1/employees)
* Запрос выполняется только если params определен
*/
-export function useGetStaff(params?: GetStaffParams) {
+export function useGetEmployees(params?: GetEmployeesParams) {
return useQuery({
- queryKey: ["staff", params],
- queryFn: () => StaffService.getStaff(params),
- enabled: !!params, // Запрос выполняется только если params определен
+ queryKey: ["employees", params],
+ queryFn: () => EmployeeService.getEmployees(params),
+ enabled: !!params,
staleTime: 30 * 1000, // 30 секунд
});
}
/**
- * Хук для регистрации нового сотрудника
- * Инвалидирует кэш списка сотрудников после успешной регистрации
+ * Хук для приглашения нового сотрудника (POST /api/v1/employees/invite)
+ * Инвалидирует кэш списка сотрудников после успешного приглашения
*/
-export function useRegisterStaff() {
+export function useInviteEmployee() {
const queryClient = useQueryClient();
- return useMutation({
- mutationKey: ["staff", "register"],
- mutationFn: (data: RegisterStaffRequest) =>
- StaffService.registerStaff(data),
+ return useMutation({
+ mutationKey: ["employees", "invite"],
+ mutationFn: (data: InviteEmployeeRequest) => EmployeeService.invite(data),
onSuccess: () => {
- // Инвалидируем кэш списка сотрудников для обновления данных
- queryClient.invalidateQueries({ queryKey: ["staff"] });
+ queryClient.invalidateQueries({ queryKey: ["employees"] });
+ },
+ });
+}
+
+/** Хелпер: оптимистично обновить сотрудника во всех кэшах ["employees", ...] */
+function optimisticUpdateEmployee(
+ queryClient: ReturnType,
+ employeeId: string,
+ updater: (emp: IEmployeeDto) => IEmployeeDto
+) {
+ queryClient.setQueriesData<{ employees: IEmployeeDto[]; total: number }>(
+ { queryKey: ["employees"] },
+ old => {
+ if (!old) return old;
+ return {
+ ...old,
+ employees: old.employees.map(e =>
+ e.id === employeeId ? updater(e) : e
+ ),
+ };
+ }
+ );
+}
+
+/** Хук для активации сотрудника (POST /api/v1/employees/{id}/activate) */
+export function useActivateEmployee() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationKey: ["employees", "activate"],
+ mutationFn: (id: string) => EmployeeService.activate(id),
+ onMutate: async id => {
+ await queryClient.cancelQueries({ queryKey: ["employees"] });
+ optimisticUpdateEmployee(queryClient, id, e => ({ ...e, active: true }));
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: ["employees"] });
+ },
+ });
+}
+
+/** Хук для деактивации сотрудника (POST /api/v1/employees/{id}/deactivate) */
+export function useDeactivateEmployee() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationKey: ["employees", "deactivate"],
+ mutationFn: (id: string) => EmployeeService.deactivate(id),
+ onMutate: async id => {
+ await queryClient.cancelQueries({ queryKey: ["employees"] });
+ optimisticUpdateEmployee(queryClient, id, e => ({
+ ...e,
+ active: false,
+ }));
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: ["employees"] });
},
});
}
+
+/** Хук для смены роли сотрудника (PATCH /api/v1/employees/{id}/role) */
+export function useChangeEmployeeRole() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationKey: ["employees", "changeRole"],
+ mutationFn: ({ id, role }) => EmployeeService.changeRole(id, role),
+ onMutate: async ({ id, role }) => {
+ await queryClient.cancelQueries({ queryKey: ["employees"] });
+ optimisticUpdateEmployee(queryClient, id, e => ({ ...e, role }));
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: ["employees"] });
+ queryClient.invalidateQueries({ queryKey: ["me"] });
+ },
+ });
+}
+
+/** Хук для смены прав доступа (PATCH /api/v1/employees/{id}/permissions) */
+export function useChangeEmployeePermissions() {
+ const t = useTranslations();
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ void,
+ unknown,
+ { id: string; permissions: IEmployeePermissions }
+ >({
+ mutationKey: ["employees", "changePermissions"],
+ mutationFn: ({ id, permissions }) =>
+ EmployeeService.changePermissions(id, permissions),
+ onMutate: async ({ id, permissions }) => {
+ await queryClient.cancelQueries({ queryKey: ["employees"] });
+ optimisticUpdateEmployee(queryClient, id, e => ({
+ ...e,
+ permissions,
+ }));
+ },
+ onSuccess: () => {
+ toast.success(t("Staff.permissionsUpdated"));
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: ["employees"] });
+ },
+ onError: error => {
+ toast.error(t(`apiErrors.${getApiErrorKey(error)}`));
+ },
+ });
+}
+
+/** Хук для перевода сотрудника на другую точку (POST /api/v1/employees/{id}/transfer) */
+export function useTransferEmployee() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationKey: ["employees", "transfer"],
+ mutationFn: ({ id, location_id }) =>
+ EmployeeService.transfer(id, location_id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["employees"] });
+ },
+ });
+}
+
+/** Хук для отвязки сотрудника от точки (DELETE /api/v1/employees/{id}/location) */
+export function useRemoveEmployeeFromLocation() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationKey: ["employees", "removeFromLocation"],
+ mutationFn: (id: string) => EmployeeService.removeFromLocation(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["employees"] });
+ },
+ });
+}
+
+/** Хук для получения услуг сотрудника (GET /api/v1/employees/{id}/services) */
+export function useGetEmployeeServices(id: string | undefined) {
+ return useQuery({
+ queryKey: ["employee-services", id],
+ queryFn: () => EmployeeService.getEmployeeServices(id!),
+ enabled: !!id,
+ staleTime: 2 * 60 * 1000,
+ });
+}
+
+/** Сотрудник привязанный к услуге */
+export interface ServiceStaffMember {
+ employee: IEmployeeDto;
+ price: number;
+ /** ID привязки (employee_service) для DELETE */
+ employeeServiceId: string;
+}
+
+/**
+ * Map serviceId → сотрудники с ценами
+ * Загружает сотрудников локации, затем для каждого — его услуги,
+ * и строит обратную map serviceId → [{employee, price}]
+ */
+export function useServiceStaffMap(
+ locationId: string | undefined,
+ roles?: TUserRole[]
+) {
+ const { data: employeesData } = useGetEmployees(
+ locationId
+ ? {
+ location_id: locationId,
+ role: roles ?? [EUserRole.STAFF, EUserRole.MANAGER, EUserRole.OWNER],
+ }
+ : undefined
+ );
+
+ const employees = employeesData?.employees || [];
+
+ // Параллельно загружаем услуги каждого сотрудника
+ const serviceQueries = useQueries({
+ queries: employees.map(emp => ({
+ queryKey: ["employee-services", emp.id],
+ queryFn: () => EmployeeService.getEmployeeServices(emp.id),
+ staleTime: 2 * 60 * 1000,
+ enabled: !!emp.id,
+ })),
+ });
+
+ const isLoading = !employeesData || serviceQueries.some(q => q.isLoading);
+
+ // Стабильный ключ для пересчёта — JSON всех data (размер массива не меняется)
+ const queriesDataKey = JSON.stringify(serviceQueries.map(q => q.data));
+
+ // Мемоизируем map serviceId → [{employee, price}]
+ const staffMap = useMemo(() => {
+ const map = new Map();
+ if (isLoading) return map;
+
+ employees.forEach((emp, i) => {
+ const services = serviceQueries[i]?.data?.services || [];
+ for (const svc of services) {
+ if (!map.has(svc.id)) map.set(svc.id, []);
+ map.get(svc.id)!.push({
+ employee: emp,
+ price: svc.price,
+ employeeServiceId: svc.employee_service_id,
+ });
+ }
+ });
+ return map;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isLoading, employees, queriesDataKey]);
+
+ return { staffMap, isLoading };
+}
diff --git a/src/shared/schemas/calendar.ts b/src/shared/schemas/calendar.ts
index 3a2e8e5..f6ea605 100644
--- a/src/shared/schemas/calendar.ts
+++ b/src/shared/schemas/calendar.ts
@@ -20,19 +20,22 @@ export const eventSchema = z.object({
export type TEventFormData = z.infer;
-/** Схема для создания записи POST /api/v1/bagsies/master */
-export const addBagsieSchema = z.object({
- name: z.string().min(1, "Name is required"),
- surname: z.string().min(1, "Surname is required"),
- client_phone: z.string().min(1, "Phone is required"),
- comment: z.string().optional(),
- /** Для STAFF не в форме — подставляется phone текущего пользователя. Для manager+ — выбор из /staff. */
- master_phone: z.string().optional(),
- service_id: z.string().min(1, "Service is required"),
- startDate: z
- .date()
- .refine(val => !!val, { message: "Start date is required" }),
- startTime: z.object({ hour: z.number(), minute: z.number() }),
-});
+/** Схема для создания записи POST /api/v1/appointments/direct */
+export const createAddAppointmentSchema = (t: (key: string) => string) =>
+ z.object({
+ first_name: z.string().min(1, t("errors.firstNameRequired")),
+ last_name: z.string().min(1, t("errors.lastNameRequired")),
+ phone: z.string().min(1, t("errors.phoneRequired")),
+ comment: z.string().optional(),
+ /** Для STAFF не в форме — подставляется id текущего пользователя. Для manager+ — выбор из employees. */
+ employee_id: z.string().optional(),
+ service_id: z.string().min(1, t("errors.serviceRequired")),
+ startDate: z
+ .date()
+ .refine(val => !!val, { message: t("errors.startDateRequired") }),
+ startTime: z.object({ hour: z.number(), minute: z.number() }),
+ });
-export type TAddBagsieFormData = z.infer;
+export type TAddAppointmentFormData = z.infer<
+ ReturnType
+>;
diff --git a/src/shared/services/analytics-service.ts b/src/shared/services/analytics-service.ts
new file mode 100644
index 0000000..d9c6dea
--- /dev/null
+++ b/src/shared/services/analytics-service.ts
@@ -0,0 +1,97 @@
+/**
+ * Сервис аналитики.
+ *
+ * Контракт см. в docs/ANALYTICS_API_SPEC.md.
+ * Все эндпоинты принимают одинаковые query-параметры (from, to, location_id?).
+ * Период сравнения (prev/delta) бэк вычисляет САМ по правилам пресета.
+ */
+
+import { apiClient } from "../api";
+import type {
+ AnalyticsParams,
+ IClientsAnalyticsDto,
+ IEmployeeAnalyticsDto,
+ IFinanceReportDto,
+ IMyAnalyticsDto,
+ IOverviewDto,
+ IStaffReportDto,
+} from "../types/analytics";
+
+/**
+ * Подготовка query-объекта: отдаём только заданные ключи, чтобы не было
+ * `location_id=undefined` в URL. "all" не передаём — бэк трактует отсутствие
+ * параметра как "все локации".
+ */
+function buildQuery(params: AnalyticsParams) {
+ return {
+ from: params.from,
+ to: params.to,
+ ...(params.location_id && params.location_id !== "all"
+ ? { location_id: params.location_id }
+ : {}),
+ };
+}
+
+export class AnalyticsService {
+ /** GET /api/v1/analytics/overview — сводка для главной (Manager/Owner). */
+ static getOverview(params: AnalyticsParams): Promise {
+ return apiClient.get("api/v1/analytics/overview", {
+ query: buildQuery(params),
+ });
+ }
+
+ /**
+ * GET /api/v1/analytics/me — личная аналитика.
+ * Текущий сотрудник определяется бэком по токену.
+ */
+ static getMyAnalytics(params: AnalyticsParams): Promise {
+ return apiClient.get("api/v1/analytics/me", {
+ query: { from: params.from, to: params.to },
+ });
+ }
+
+ /** GET /api/v1/analytics/staff — таблица мастеров. */
+ static getStaffReport(params: AnalyticsParams): Promise {
+ return apiClient.get("api/v1/analytics/staff", {
+ query: buildQuery(params),
+ });
+ }
+
+ /** GET /api/v1/analytics/staff/{employee_id} — drill-down по мастеру. */
+ static getEmployeeAnalytics(
+ employeeId: string,
+ params: AnalyticsParams
+ ): Promise {
+ return apiClient.get(
+ `api/v1/analytics/staff/${employeeId}`,
+ { query: { from: params.from, to: params.to } }
+ );
+ }
+
+ /** GET /api/v1/analytics/locations/{location_id} — drill-down по локации (Network Owner). */
+ static getLocationAnalytics(
+ locationId: string,
+ params: AnalyticsParams
+ ): Promise {
+ return apiClient.get(
+ `api/v1/analytics/locations/${locationId}`,
+ { query: { from: params.from, to: params.to } }
+ );
+ }
+
+ /** GET /api/v1/analytics/finance — финансовый отчёт. */
+ static getFinanceReport(params: AnalyticsParams): Promise {
+ return apiClient.get("api/v1/analytics/finance", {
+ query: buildQuery(params),
+ });
+ }
+
+ /** GET /api/v1/analytics/clients — клиенты (Beta). */
+ static getClientsAnalytics(
+ params: AnalyticsParams
+ ): Promise {
+ return apiClient.get("api/v1/analytics/clients", {
+ query: buildQuery(params),
+ });
+ }
+}
diff --git a/src/shared/services/appointment-service.ts b/src/shared/services/appointment-service.ts
new file mode 100644
index 0000000..516ef84
--- /dev/null
+++ b/src/shared/services/appointment-service.ts
@@ -0,0 +1,104 @@
+import { apiClient } from "../api/client";
+
+/** Тело запроса POST /api/v1/appointments/direct */
+export interface CreateAppointmentRequest {
+ phone: string;
+ first_name: string;
+ last_name: string;
+ comment?: string;
+ employee_id: string;
+ location_id: string;
+ service_id: string;
+ /** ISO 8601 с offset таймзоны (например 2025-01-25T14:00:00+05:00). */
+ start_at: string;
+}
+
+export interface CreateAppointmentResponse {
+ id: string;
+}
+
+/** Запрос доступных слотов POST /api/v1/appointments/slots */
+export interface GetSlotsRequest {
+ location_id: string;
+ service_id: string;
+ start_date: string;
+ end_date: string;
+ /** UUID сотрудника (опционально) */
+ employee_id?: string;
+}
+
+export interface TimeSlot {
+ start_at: string;
+ end_at: string;
+}
+
+export interface MasterSlot {
+ employee_id: string;
+ employee_name: string;
+ price: number;
+ slots: TimeSlot[];
+}
+
+export interface GetSlotsResponse {
+ location_id: string;
+ service_id: string;
+ duration_minutes: number;
+ master_slots: MasterSlot[];
+}
+
+/** Подтверждение записи POST /api/v1/appointments/{id}/confirm */
+export interface ConfirmAppointmentRequest {
+ code: string;
+}
+
+/** Отмена записи POST /api/v1/appointments/{id}/cancel */
+export interface CancelAppointmentRequest {
+ reason?: string;
+}
+
+/**
+ * Сервис записей (appointments).
+ * Эндпоинты: /api/v1/appointments/*
+ */
+export class AppointmentService {
+ /** Прямое создание записи сотрудником (без OTP) */
+ static async createAppointment(
+ payload: CreateAppointmentRequest
+ ): Promise {
+ return apiClient.post(
+ "api/v1/appointments/direct",
+ payload
+ );
+ }
+
+ /** Получение доступных слотов */
+ static async getSlots(payload: GetSlotsRequest): Promise {
+ return apiClient.post(
+ "api/v1/appointments/slots",
+ payload
+ );
+ }
+
+ /** Подтверждение записи OTP-кодом */
+ static async confirmAppointment(
+ id: string,
+ payload: ConfirmAppointmentRequest
+ ): Promise {
+ await apiClient.post(`api/v1/appointments/${id}/confirm`, payload);
+ }
+
+ /** Отмена записи */
+ static async cancelAppointment(
+ id: string,
+ payload: CancelAppointmentRequest
+ ): Promise {
+ await apiClient.post(`api/v1/appointments/${id}/cancel`, payload);
+ }
+
+ /** Повторная отправка OTP подтверждения записи */
+ static async resendOtp(id: string): Promise {
+ await apiClient.post(`api/v1/appointments/${id}/resend-otp`, {});
+ }
+}
+
+export default AppointmentService;
diff --git a/src/shared/services/auth-service.ts b/src/shared/services/auth-service.ts
index 6038d33..16467e2 100644
--- a/src/shared/services/auth-service.ts
+++ b/src/shared/services/auth-service.ts
@@ -5,7 +5,8 @@ import {
clearAuthTokens,
} from "../utils/cookies";
-export type VerifyAuthTokenPurpose = "register" | "password_change";
+/** Назначение action-токена (инвайт или сброс пароля) */
+export type VerifyAuthTokenPurpose = "password_reset" | "staff_invitation";
export interface LoginRequestDto {
phone: string;
@@ -15,69 +16,47 @@ export interface LoginRequestDto {
export interface LoginResponseDto {
access_token: string;
refresh_token: string;
- message?: string;
- code?: number;
-}
-
-export interface RegisterRequestDto {
- phone: string;
- password: string;
- token: string;
-}
-
-export interface RegisterResponseDto {
- access_token: string;
- refresh_token: string;
- message?: string;
- code?: number;
}
+/** Ответ на проверку action-токена */
export interface VerifyAuthTokenResponseDto {
- network_code: string;
phone: string;
- point_code: string;
purpose: VerifyAuthTokenPurpose;
+ organization_id: string;
+ location_id: string;
}
-export interface PasswordChangeRequestDto {
- password: string;
- token: string;
+/** Запрос сброса пароля (шаг 1) */
+export interface PasswordResetRequestDto {
+ phone: string;
}
-export interface PasswordChangeResponseDto {
- message?: string;
- code?: number;
+export interface PasswordResetResponseDto {
+ message: string;
}
-export interface PasswordChangeRequestRequestDto {
- phone: string;
+/** Подтверждение сброса пароля (шаг 2) */
+export interface PasswordResetConfirmRequestDto {
+ token: string;
+ new_password: string;
}
-export interface PasswordChangeRequestResponseDto {
- message?: string;
- code?: number;
+export interface PasswordResetConfirmResponseDto {
+ access_token: string;
+ refresh_token: string;
}
+
/**
- * Сервис авторизации. Инкапсулирует эндпоинты и маппинг данных
+ * Сервис авторизации. Инкапсулирует эндпоинты и маппинг данных.
+ * Эндпоинты регистрации (register, register/verify, register/resend)
+ * используются на лендинге bagsy.kz, НЕ в ЛК.
*/
export class AuthService {
/**
* Авторизация пользователя
*/
static async login(payload: LoginRequestDto): Promise {
- return apiClient.post("v1/auth/login", payload);
- }
-
- /**
- * Регистрация пароля пользователя
- */
- static async registerConfirm(
- payload: RegisterRequestDto
- ): Promise {
- return apiClient.post(
- "v1/auth/staff/register/confirm",
- payload
- );
+ return apiClient.post("api/v1/auth/login", payload);
}
/**
@@ -89,56 +68,61 @@ export class AuthService {
throw new Error("Refresh token not found");
}
- return apiClient.post(
- "v1/auth/refresh",
- {},
- {
- body: JSON.stringify({
- refresh_token: refreshToken,
- }),
- }
- );
+ return apiClient.post("api/v1/auth/refresh", {
+ refresh_token: refreshToken,
+ });
}
/**
- * Проверка валидности токена авторизации
+ * Проверка валидности action-токена (для инвайта или сброса пароля)
*/
static async verifyAuthToken(
token: string
): Promise {
return apiClient.get(
- `v1/auth/verify-auth-token/${token}`
+ `api/v1/auth/verify/${token}`
);
}
/**
- * Изменение пароля пользователя
+ * Запрос на сброс пароля (отправляет ссылку)
*/
- static async passwordChange(
- payload: PasswordChangeRequestDto
- ): Promise {
- return apiClient.post(
- "v1/auth/password/change/confirm",
+ static async passwordReset(
+ payload: PasswordResetRequestDto
+ ): Promise {
+ return apiClient.post(
+ "api/v1/auth/password/reset",
payload
);
}
/**
- * Запрос на изменение пароля
+ * Подтверждение сброса пароля (устанавливает новый пароль)
*/
- static async passwordChangeRequest(
- payload: PasswordChangeRequestRequestDto
- ): Promise {
- return apiClient.post(
- "v1/auth/password/change",
+ static async passwordResetConfirm(
+ payload: PasswordResetConfirmRequestDto
+ ): Promise {
+ return apiClient.post(
+ "api/v1/auth/password/reset/confirm",
payload
);
}
/**
- * Выход из системы
+ * Выход из системы — инвалидация refresh токена на бэке + очистка кук
*/
static async logout(): Promise {
+ const refreshToken = await getRefreshToken();
+ // Отправляем logout на бэк, если есть refresh token
+ if (refreshToken) {
+ try {
+ await apiClient.post("api/v1/auth/logout", {
+ refresh_token: refreshToken,
+ });
+ } catch {
+ // Даже если logout на бэке упал, очищаем куки локально
+ }
+ }
await clearAuthTokens();
}
diff --git a/src/shared/services/bagsies-service.ts b/src/shared/services/bagsies-service.ts
deleted file mode 100644
index 1a26686..0000000
--- a/src/shared/services/bagsies-service.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { apiClient } from "../api/client";
-
-/** Тело запроса POST /api/v1/bagsies/master */
-export interface CreateBagsieRequestDto {
- client_phone: string;
- comment?: string;
- master_phone: string;
- name: string;
- service_id: string;
- /** ISO 8601 с offset таймзоны (например 2025-01-25T14:00:00+05:00). */
- start_at: string;
- surname: string;
-}
-
-class BagsieService {
- static async createBagsie(payload: CreateBagsieRequestDto) {
- return apiClient.post("v1/bagsies/master", payload);
- }
-}
-
-export default BagsieService;
diff --git a/src/shared/services/calendar-service.ts b/src/shared/services/calendar-service.ts
index debd71b..86067cb 100644
--- a/src/shared/services/calendar-service.ts
+++ b/src/shared/services/calendar-service.ts
@@ -8,7 +8,8 @@ import type { GetCalendarParams, CalendarApiResponse } from "../types/calendar";
const MAX_DATE_RANGE_DAYS = 35;
/**
- * Сервис календаря. Инкапсулирует эндпоинты и валидацию данных
+ * Сервис календаря. Инкапсулирует эндпоинты и валидацию данных.
+ * Эндпоинт: GET /api/v1/appointments/calendar
*/
export class CalendarService {
/**
@@ -39,15 +40,19 @@ export class CalendarService {
to: params.to,
};
- if (params.point_code) {
- queryParams.point_code = params.point_code;
+ if (params.location_id) {
+ queryParams.location_id = params.location_id;
}
- if (params.master_phone) {
- queryParams.master_phone = params.master_phone;
+ if (params.employee_id) {
+ queryParams.employee_id = params.employee_id;
}
- return apiClient.get("v1/calendar", {
+ if (params.include_cancelled) {
+ queryParams.include_cancelled = "true";
+ }
+
+ return apiClient.get("api/v1/appointments/calendar", {
query: queryParams,
});
}
diff --git a/src/shared/services/employee-service.ts b/src/shared/services/employee-service.ts
new file mode 100644
index 0000000..07b26d0
--- /dev/null
+++ b/src/shared/services/employee-service.ts
@@ -0,0 +1,265 @@
+import { apiClient } from "../api";
+import type {
+ IEmployeeDto,
+ IEmployeePermissions,
+ TUserRole,
+ UpdateEmployeeAccountRequest,
+} from "../types/user";
+import type { IEmployeesResponse } from "../types/staff";
+
+// ============================================================
+// Invite types
+// ============================================================
+
+/** Запрос на приглашение сотрудника (POST /api/v1/employees/invite) */
+export interface InviteEmployeeRequest {
+ phone: string;
+ first_name: string;
+ last_name: string;
+ role: "owner" | "manager" | "staff";
+ location_id: string;
+}
+
+export interface InviteEmployeeResponse {
+ message: string;
+ phone: string;
+ expires_in: number;
+}
+
+/** Подтверждение приглашения (POST /api/v1/employees/invite/confirm) */
+export interface ConfirmInviteRequest {
+ token: string;
+ password: string;
+}
+
+export interface ConfirmInviteResponse {
+ access_token: string;
+ refresh_token: string;
+}
+
+/** Повторная отправка приглашения (POST /api/v1/employees/invite/resend) */
+export interface ResendInviteRequest {
+ phone: string;
+}
+
+export interface ResendInviteResponse {
+ message: string;
+ phone: string;
+ expires_in: number;
+ retry_after: number;
+}
+
+// ============================================================
+// Employee services types (связь сотрудник-услуга)
+// ============================================================
+
+/** Услуга сотрудника (из GET /api/v1/employees/{id}/services) */
+export interface IEmployeeServiceItem {
+ /** UUID услуги (это service.id, не employee_service.id) */
+ id: string;
+ /** UUID привязки сотрудник-услуга (для DELETE /api/v1/employee-services/{id}) */
+ employee_service_id: string;
+ category_id: string;
+ name: string;
+ description: string;
+ duration_minutes: number;
+ color: string;
+ sort_order: number;
+ active: boolean;
+ /** Индивидуальная цена сотрудника */
+ price: number;
+}
+
+/** Ответ GET /api/v1/employees/{id}/services */
+export interface IEmployeeServicesResponse {
+ services: IEmployeeServiceItem[];
+}
+
+/** Запрос POST /api/v1/employee-services — привязка сотрудника к услуге */
+export interface CreateEmployeeServiceRequest {
+ employee_id: string;
+ service_id: string;
+ /** Цена в тенге (строка) */
+ price: string;
+}
+
+/** Ответ POST /api/v1/employee-services */
+export interface CreateEmployeeServiceResponse {
+ id: string;
+}
+
+// ============================================================
+// List params
+// ============================================================
+
+/** Параметры запроса GET /api/v1/employees */
+export interface GetEmployeesParams {
+ location_id?: string;
+ role?: TUserRole[];
+ search?: string;
+ active?: boolean;
+ limit?: number;
+ offset?: number;
+ order_by?: "created_at" | "first_name" | "phone" | "role";
+ sort_order?: "asc" | "desc";
+}
+
+// ============================================================
+// Service
+// ============================================================
+
+/** Нормализация телефона: убираем + и оставляем только цифры */
+function normalizePhone(phone: string): string {
+ return phone.replace(/^\+/, "").match(/\d/g)?.join("") || "";
+}
+
+/**
+ * Сервис сотрудников. Инкапсулирует все эндпоинты /api/v1/employees/*.
+ */
+export class EmployeeService {
+ // ——— Me ———
+
+ /** Получение текущего сотрудника (GET /api/v1/employees/me) */
+ static async getMe(): Promise {
+ return apiClient.get("api/v1/employees/me");
+ }
+
+ /** Обновление профиля (PUT /api/v1/employees/me) */
+ static async updateMe(
+ data: UpdateEmployeeAccountRequest
+ ): Promise {
+ return apiClient.put("api/v1/employees/me", data);
+ }
+
+ // ——— List ———
+
+ /** Список сотрудников с фильтрацией и пагинацией (GET /api/v1/employees) */
+ static async getEmployees(
+ params?: GetEmployeesParams
+ ): Promise {
+ return apiClient.get("api/v1/employees", {
+ query: {
+ ...(params?.location_id && { location_id: params.location_id }),
+ ...(params?.role && params.role.length > 0 && { role: params.role }),
+ ...(params?.search?.trim() && {
+ search: params.search,
+ }),
+ ...(params?.active !== undefined && { active: params.active }),
+ ...(params?.limit && { limit: params.limit }),
+ ...(params?.offset !== undefined && { offset: params.offset }),
+ ...(params?.order_by && { order_by: params.order_by }),
+ ...(params?.sort_order && { sort_order: params.sort_order }),
+ },
+ });
+ }
+
+ // ——— Invite ———
+
+ /** Приглашение нового сотрудника (POST /api/v1/employees/invite) */
+ static async invite(
+ data: InviteEmployeeRequest
+ ): Promise {
+ return apiClient.post("api/v1/employees/invite", {
+ ...data,
+ phone: normalizePhone(data.phone),
+ });
+ }
+
+ /** Подтверждение приглашения + установка пароля (POST /api/v1/employees/invite/confirm) */
+ static async confirmInvite(
+ data: ConfirmInviteRequest
+ ): Promise {
+ return apiClient.post(
+ "api/v1/employees/invite/confirm",
+ data
+ );
+ }
+
+ /** Повторная отправка приглашения (POST /api/v1/employees/invite/resend) */
+ static async resendInvite(
+ data: ResendInviteRequest
+ ): Promise {
+ return apiClient.post(
+ "api/v1/employees/invite/resend",
+ { phone: normalizePhone(data.phone) }
+ );
+ }
+
+ // ——— Employee management ———
+
+ /** Активация сотрудника (POST /api/v1/employees/{id}/activate) */
+ static async activate(id: string): Promise {
+ await apiClient.post(`api/v1/employees/${encodeURIComponent(id)}/activate`);
+ }
+
+ /** Деактивация сотрудника (POST /api/v1/employees/{id}/deactivate) */
+ static async deactivate(id: string): Promise {
+ await apiClient.post(
+ `api/v1/employees/${encodeURIComponent(id)}/deactivate`
+ );
+ }
+
+ /** Смена роли (PATCH /api/v1/employees/{id}/role) — только owner */
+ static async changeRole(id: string, role: TUserRole): Promise {
+ await apiClient.patch(`api/v1/employees/${encodeURIComponent(id)}/role`, {
+ role,
+ });
+ }
+
+ /** Смена прав доступа (PATCH /api/v1/employees/{id}/permissions) */
+ static async changePermissions(
+ id: string,
+ data: IEmployeePermissions
+ ): Promise {
+ await apiClient.patch(
+ `api/v1/employees/${encodeURIComponent(id)}/permissions`,
+ data
+ );
+ }
+
+ /** Услуги сотрудника с ценами (GET /api/v1/employees/{id}/services) */
+ static async getEmployeeServices(
+ id: string
+ ): Promise {
+ return apiClient.get(
+ `api/v1/employees/${encodeURIComponent(id)}/services`
+ );
+ }
+
+ /** Перевод на другую точку (POST /api/v1/employees/{id}/transfer) */
+ static async transfer(id: string, location_id: string): Promise {
+ await apiClient.post(
+ `api/v1/employees/${encodeURIComponent(id)}/transfer`,
+ { location_id }
+ );
+ }
+
+ /** Отвязка сотрудника от точки (DELETE /api/v1/employees/{id}/location) — только owner */
+ static async removeFromLocation(id: string): Promise {
+ await apiClient.delete(
+ `api/v1/employees/${encodeURIComponent(id)}/location`
+ );
+ }
+
+ // ——— Employee-Service links ———
+
+ /** Привязка сотрудника к услуге (POST /api/v1/employee-services) */
+ static async createEmployeeService(
+ data: CreateEmployeeServiceRequest
+ ): Promise {
+ return apiClient.post(
+ "api/v1/employee-services",
+ data
+ );
+ }
+
+ /**
+ * Отвязка сотрудника от услуги (DELETE /api/v1/employee-services/{id})
+ * TODO: эндпоинт на беке ещё не готов — подключить когда появится
+ */
+ static async removeEmployeeService(id: string): Promise {
+ return apiClient.delete(
+ `api/v1/employee-services/${encodeURIComponent(id)}`
+ );
+ }
+}
diff --git a/src/shared/services/index.ts b/src/shared/services/index.ts
index 04263ab..369a387 100644
--- a/src/shared/services/index.ts
+++ b/src/shared/services/index.ts
@@ -1,5 +1,8 @@
export * from "./auth-service";
export * from "./calendar-service";
-export * from "./master-service";
-export * from "./point-service";
+export * from "./employee-service";
+export * from "./appointment-service";
+export * from "./location-service";
export * from "./service-service";
+export * from "./media-service";
+export * from "./schedule-service";
diff --git a/src/shared/services/location-service.ts b/src/shared/services/location-service.ts
new file mode 100644
index 0000000..7184468
--- /dev/null
+++ b/src/shared/services/location-service.ts
@@ -0,0 +1,187 @@
+import { apiClient } from "../api";
+
+/**
+ * Категория локации (GET /api/v1/locations/categories)
+ */
+export interface ILocationCategory {
+ id: string;
+ name: string;
+ slug: string;
+ sort_order: number;
+}
+
+/**
+ * Ответ API для получения списка категорий локаций
+ */
+export interface ILocationCategoriesResponse {
+ categories: ILocationCategory[];
+}
+
+/**
+ * Адрес локации (из swagger)
+ */
+export interface ILocationAddress {
+ city: string;
+ street: string;
+ building: string;
+ details?: string;
+}
+
+/**
+ * Координаты локации
+ */
+export interface ILocationCoordinates {
+ latitude: number;
+ longitude: number;
+}
+
+/**
+ * Полная информация о локации (GET /api/v1/locations, GET /api/v1/locations/{id})
+ */
+export interface ILocationDto {
+ id: string;
+ name: string;
+ active: boolean;
+ address: ILocationAddress;
+ coordinates: ILocationCoordinates;
+ category_id: string;
+ description: string;
+ phone: string;
+ schedule_type: string;
+ slot_duration_minutes: number;
+ slug: string;
+ created_at: string;
+}
+
+/**
+ * Параметры запроса GET /api/v1/locations
+ */
+export interface GetLocationsParams {
+ active?: boolean;
+ limit?: number;
+ offset?: number;
+ order_by?: "created_at" | "name";
+ sort_order?: "asc" | "desc";
+}
+
+/**
+ * Ответ GET /api/v1/locations
+ */
+export interface GetLocationsResponse {
+ locations: ILocationDto[];
+ total: number;
+}
+
+/**
+ * Данные для создания локации обслуживания (POST /api/v1/locations)
+ */
+export interface CreateLocationRequestDto {
+ name: string;
+ description?: string;
+ phone: string;
+ category_id: string;
+ latitude: number;
+ longitude: number;
+ schedule_type: string;
+ slot_duration_minutes: number;
+ address: ILocationAddress;
+}
+
+/**
+ * Ответ на создание локации
+ */
+export interface CreateLocationResponseDto {
+ id: string;
+ prompt_org_profile: boolean;
+}
+
+/**
+ * Данные для обновления локации (PUT /api/v1/locations/{id})
+ * Все поля опциональны
+ */
+export interface UpdateLocationRequestDto {
+ name?: string;
+ description?: string;
+ phone?: string;
+ latitude?: number;
+ longitude?: number;
+ schedule_type?: string;
+ slot_duration_minutes?: number;
+ active?: boolean;
+ address?: Partial;
+}
+
+/**
+ * Сервис локаций (locations).
+ */
+export class LocationService {
+ /**
+ * Получение списка локаций организации (GET /api/v1/locations)
+ */
+ static async getLocations(
+ params?: GetLocationsParams
+ ): Promise {
+ const searchParams = new URLSearchParams();
+ if (params) {
+ if (params.active !== undefined)
+ searchParams.set("active", String(params.active));
+ if (params.limit !== undefined)
+ searchParams.set("limit", String(params.limit));
+ if (params.offset !== undefined)
+ searchParams.set("offset", String(params.offset));
+ if (params.order_by) searchParams.set("order_by", params.order_by);
+ if (params.sort_order) searchParams.set("sort_order", params.sort_order);
+ }
+ const qs = searchParams.toString();
+ return apiClient.get(
+ `api/v1/locations${qs ? `?${qs}` : ""}`
+ );
+ }
+
+ /**
+ * Получение информации о локации по ID (GET /api/v1/locations/{id})
+ */
+ static async getLocation(id: string): Promise {
+ return apiClient.get(
+ `api/v1/locations/${encodeURIComponent(id)}`
+ );
+ }
+
+ /**
+ * Получение списка категорий локаций (GET /api/v1/locations/categories)
+ */
+ static async getLocationCategories(): Promise