feat: work shifts planning

This commit is contained in:
2025-01-22 18:01:16 +04:00
parent 71332e4ebd
commit f62d2662d6
18 changed files with 575 additions and 49 deletions

View File

@@ -220,6 +220,7 @@ export type { GetDepartmentSectionsResponse } from './models/GetDepartmentSectio
export type { GetDepartmentsResponse } from './models/GetDepartmentsResponse';
export type { GetManagersResponse } from './models/GetManagersResponse';
export type { GetPaymentRecordsResponse } from './models/GetPaymentRecordsResponse';
export type { GetPlannedWorkShiftsResponse } from './models/GetPlannedWorkShiftsResponse';
export type { GetProductBarcodePdfRequest } from './models/GetProductBarcodePdfRequest';
export type { GetProductBarcodePdfResponse } from './models/GetProductBarcodePdfResponse';
export type { GetProductBarcodeRequest } from './models/GetProductBarcodeRequest';
@@ -234,6 +235,7 @@ export type { GetServiceKitSchema } from './models/GetServiceKitSchema';
export type { GetTimeTrackingRecordsRequest } from './models/GetTimeTrackingRecordsRequest';
export type { GetTimeTrackingRecordsResponse } from './models/GetTimeTrackingRecordsResponse';
export type { GetTransactionTagsResponse } from './models/GetTransactionTagsResponse';
export type { GetWorkShiftsPlanningDataRequest } from './models/GetWorkShiftsPlanningDataRequest';
export type { GetWorkShiftsResponse } from './models/GetWorkShiftsResponse';
export type { GroupBillRequestSchema } from './models/GroupBillRequestSchema';
export type { HTTPValidationError } from './models/HTTPValidationError';
@@ -257,6 +259,8 @@ export type { PayRateSchema } from './models/PayRateSchema';
export type { PayRateSchemaBase } from './models/PayRateSchemaBase';
export type { PayrollSchemeSchema } from './models/PayrollSchemeSchema';
export type { PermissionSchema } from './models/PermissionSchema';
export type { PlannedWorkShiftSchema } from './models/PlannedWorkShiftSchema';
export type { PlanningTableRow } from './models/PlanningTableRow';
export type { PositionSchema } from './models/PositionSchema';
export type { ProductAddBarcodeRequest } from './models/ProductAddBarcodeRequest';
export type { ProductAddBarcodeResponse } from './models/ProductAddBarcodeResponse';
@@ -332,6 +336,8 @@ export type { UpdateMarketplaceRequest } from './models/UpdateMarketplaceRequest
export type { UpdateMarketplaceResponse } from './models/UpdateMarketplaceResponse';
export type { UpdatePayRateRequest } from './models/UpdatePayRateRequest';
export type { UpdatePayRateResponse } from './models/UpdatePayRateResponse';
export type { UpdatePlanningWorkShiftRequest } from './models/UpdatePlanningWorkShiftRequest';
export type { UpdatePlanningWorkShiftResponse } from './models/UpdatePlanningWorkShiftResponse';
export type { UpdatePriceCategoryRequest } from './models/UpdatePriceCategoryRequest';
export type { UpdatePriceCategoryResponse } from './models/UpdatePriceCategoryResponse';
export type { UpdateResidualProductRequest } from './models/UpdateResidualProductRequest';
@@ -387,3 +393,4 @@ export { TimeTrackingService } from './services/TimeTrackingService';
export { TransactionService } from './services/TransactionService';
export { UserService } from './services/UserService';
export { WorkShiftsService } from './services/WorkShiftsService';
export { WorkShiftsPlanningService } from './services/WorkShiftsPlanningService';

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { PlanningTableRow } from './PlanningTableRow';
export type GetPlannedWorkShiftsResponse = {
shifts: Array<PlanningTableRow>;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type GetWorkShiftsPlanningDataRequest = {
dateFrom: string;
dateTo: string;
};

View File

@@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { PositionSchema } from './PositionSchema';
export type PlannedWorkShiftSchema = {
id: number;
shiftDate: string;
positions: Array<PositionSchema>;
};

View File

@@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { PlannedWorkShiftSchema } from './PlannedWorkShiftSchema';
import type { UserSchema } from './UserSchema';
export type PlanningTableRow = {
user: UserSchema;
shifts: Array<PlannedWorkShiftSchema>;
};

View File

@@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type UpdatePlanningWorkShiftRequest = {
shiftDate: string;
positionKeys: Array<string>;
userId: number;
};

View File

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type UpdatePlanningWorkShiftResponse = {
ok: boolean;
message: string;
};

View File

@@ -0,0 +1,53 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { GetPlannedWorkShiftsResponse } from '../models/GetPlannedWorkShiftsResponse';
import type { GetWorkShiftsPlanningDataRequest } from '../models/GetWorkShiftsPlanningDataRequest';
import type { UpdatePlanningWorkShiftRequest } from '../models/UpdatePlanningWorkShiftRequest';
import type { UpdatePlanningWorkShiftResponse } from '../models/UpdatePlanningWorkShiftResponse';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
export class WorkShiftsPlanningService {
/**
* Get Work Shifts
* @returns GetPlannedWorkShiftsResponse Successful Response
* @throws ApiError
*/
public static getWorkShifts({
requestBody,
}: {
requestBody: GetWorkShiftsPlanningDataRequest,
}): CancelablePromise<GetPlannedWorkShiftsResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/work-shifts-planning/',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Update Work Shift
* @returns UpdatePlanningWorkShiftResponse Successful Response
* @throws ApiError
*/
public static updateWorkShift({
requestBody,
}: {
requestBody: UpdatePlanningWorkShiftRequest,
}): CancelablePromise<UpdatePlanningWorkShiftResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/work-shifts-planning/update',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
}

View File

@@ -2,11 +2,12 @@ import styles from "./AdminPage.module.css";
import { Tabs } from "@mantine/core";
import PageBlock from "../../components/PageBlock/PageBlock.tsx";
import {
IconTopologyStar3,
IconCalendarEvent,
IconCalendarUser,
IconCoins,
IconCurrencyDollar,
IconQrcode,
IconTopologyStar3,
IconUser,
} from "@tabler/icons-react";
import UsersTab from "./tabs/Users/UsersTab.tsx";
@@ -18,11 +19,26 @@ import { TransactionsTab } from "./tabs/Transactions/TransactionsTab.tsx";
import { useSelector } from "react-redux";
import { RootState } from "../../redux/store.ts";
import OrganizationalStructureTab from "./tabs/OrganizationalStructureTab/OrganizationalStructureTab.tsx";
import { ReactNode } from "react";
import WorkShiftsPlanning from "./tabs/WorkShiftsPlanning/WorkShiftsPlanning.tsx";
const AdminPage = () => {
const userRole = useSelector((state: RootState) => state.auth.role);
const isAdmin = userRole === "admin";
const getTabPanel = (label: string, content: ReactNode) => {
return (
<Tabs.Panel value={label}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}>
{content}
</motion.div>
</Tabs.Panel>
);
};
return (
<div className={styles["container"]}>
<PageBlock fullHeight>
@@ -55,6 +71,11 @@ const AdminPage = () => {
Рабочее время
</Tabs.Tab>
)}
<Tabs.Tab
value={"workShiftsPlanning"}
leftSection={<IconCalendarEvent />}>
Планирование смен
</Tabs.Tab>
<Tabs.Tab
value={"workShifts"}
leftSection={<IconQrcode />}>
@@ -68,54 +89,13 @@ const AdminPage = () => {
</Tabs.Tab>
)}
</Tabs.List>
<Tabs.Panel value={"users"}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}>
<UsersTab />
</motion.div>
</Tabs.Panel>
<Tabs.Panel value={"rolesAndPositions"}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}>
<OrganizationalStructureTab />
</motion.div>
</Tabs.Panel>
<Tabs.Panel value={"finances"}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}>
<FinancesTab />
</motion.div>
</Tabs.Panel>
<Tabs.Panel value={"workTimeTable"}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}>
<WorkTimeTable />
</motion.div>
</Tabs.Panel>
<Tabs.Panel value={"workShifts"}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}>
<WorkShiftsTab />
</motion.div>
</Tabs.Panel>
<Tabs.Panel value={"transactions"}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}>
<TransactionsTab />
</motion.div>
</Tabs.Panel>
{getTabPanel("users", <UsersTab />)}
{getTabPanel("rolesAndPositions", <OrganizationalStructureTab />)}
{getTabPanel("finances", <FinancesTab />)}
{getTabPanel("workTimeTable", <WorkTimeTable />)}
{getTabPanel("workShiftsPlanning", <WorkShiftsPlanning />)}
{getTabPanel("workShifts", <WorkShiftsTab />)}
{getTabPanel("transactions", <TransactionsTab />)}
</Tabs>
</PageBlock>
</div>

View File

@@ -0,0 +1,37 @@
import { MultiSelect } from "@mantine/core";
import { UserSchema } from "../../../../client";
type Props = {
shownUsers: UserSchema[];
setShownUsers: (shownUsersProp: UserSchema[]) => void;
users: UserSchema[];
}
const ShownUsersMultiSelect = ({
shownUsers,
setShownUsers,
users,
}: Props) => {
return (
<MultiSelect
data={users.map(user => ({
label: `${user.firstName} ${user.secondName}`,
value: user.id.toString(),
}))}
onChange={event =>
setShownUsers(
users.filter(user =>
event.includes(user.id.toString()),
),
)
}
value={shownUsers.map(user => user.id.toString())}
placeholder={
shownUsers.length > 0 ? "" : "Показанные пользователи"
}
/>
);
};
export default ShownUsersMultiSelect;

View File

@@ -0,0 +1,17 @@
import { WorkShiftsPlanningContextProvider } from "./contexts/WorkShiftsPlanningContext.tsx";
import WorkShiftsPlanningTable from "./components/WorkShiftsPlanningTable/WorkShiftsPlanningTable.tsx";
import { rem, Stack } from "@mantine/core";
import WorkShiftsPlanningHeader from "./components/WorkShiftsPlanningHeader/WorkShiftsPlanningHeader.tsx";
const WorkShiftsPlanning = () => {
return (
<WorkShiftsPlanningContextProvider>
<Stack p={rem(10)} gap={rem(10)}>
<WorkShiftsPlanningHeader />
<WorkShiftsPlanningTable />
</Stack>
</WorkShiftsPlanningContextProvider>
);
};
export default WorkShiftsPlanning;

View File

@@ -0,0 +1,38 @@
import { MultiSelect, rem } from "@mantine/core";
import { PositionSchema, UserSchema } from "../../../../../../client";
import { useWorkShiftsPlanningContext } from "../../contexts/WorkShiftsPlanningContext.tsx";
type Props = {
date: Date;
positions?: PositionSchema[];
user: UserSchema;
}
const PositionsMultiSelect = ({ date, positions, user }: Props) => {
const {
onUpdate,
userPositions,
} = useWorkShiftsPlanningContext();
return (
<MultiSelect
data={userPositions.get(user.id)?.map(
position => ({
value: position.key,
label: position.name,
}),
)}
onChange={values => {
onUpdate(user.id, date, values);
}}
value={positions?.map(p => p.key) ?? []}
miw={rem(140)}
/>
);
};
export default PositionsMultiSelect;

View File

@@ -0,0 +1,38 @@
import { Flex, rem } from "@mantine/core";
import ShownUsersMultiSelect from "../../../../components/ShownUsersMultiselect/ShownUsersMultiSelect.tsx";
import { DatePickerInput } from "@mantine/dates";
import { useWorkShiftsPlanningContext } from "../../contexts/WorkShiftsPlanningContext.tsx";
const WorkShiftsPlanningHeader = () => {
const {
dateRange,
setDateRange,
users,
shownUsers,
setShownUsers,
} = useWorkShiftsPlanningContext();
return (
<Flex
align={"center"}
justify={"space-between"}
gap={rem(10)}
>
<ShownUsersMultiSelect
shownUsers={shownUsers}
setShownUsers={setShownUsers}
users={users}
/>
<DatePickerInput
placeholder={"Выберите временной промежуток"}
type={"range"}
value={dateRange}
onChange={(value) => {
setDateRange(value);
}}
/>
</Flex>
);
};
export default WorkShiftsPlanningHeader;

View File

@@ -0,0 +1,50 @@
import { BaseTable } from "../../../../../../components/BaseTable/BaseTable.tsx";
import { useWorkShiftsPlanningContext } from "../../contexts/WorkShiftsPlanningContext.tsx";
import useWorkShiftsPlanningColumns from "../../hooks/useWorkShiftsPlanningColumns.tsx";
import { MRT_TableOptions } from "mantine-react-table";
import { WorkShiftPlanningRow } from "../../types/WorkShiftPlanningRow.tsx";
import { ActionIcon, Flex, Tooltip } from "@mantine/core";
import { IconEyeOff } from "@tabler/icons-react";
const WorkShiftsPlanningTable = () => {
const {
tableRows,
shownUsers,
setShownUsers,
} = useWorkShiftsPlanningContext();
const columns = useWorkShiftsPlanningColumns();
return (
<BaseTable
data={tableRows}
columns={columns}
restProps={
{
enableColumnActions: false,
enableSorting: false,
enableRowActions: true,
renderRowActions: ({ row }) => (
<Flex gap="md">
<Tooltip label="Скрыть">
<ActionIcon
onClick={() => {
setShownUsers(
shownUsers.filter(
user => user.id !== row.original.user.id,
),
);
}}
variant={"default"}>
<IconEyeOff />
</ActionIcon>
</Tooltip>
</Flex>
),
} as MRT_TableOptions<WorkShiftPlanningRow>
}
/>
);
};
export default WorkShiftsPlanningTable;

View File

@@ -0,0 +1,176 @@
import React, { createContext, FC, useContext, useEffect, useState } from "react";
import { PlanningTableRow, PositionSchema, UserSchema, WorkShiftsPlanningService } from "../../../../../client";
import { useListState } from "@mantine/hooks";
import { dateToString } from "../../../../../types/utils.ts";
import { getDefaultEndDate } from "../utils/utils.tsx";
import { WorkShiftPlanningRow } from "../types/WorkShiftPlanningRow.tsx";
import useUsersList from "../../../hooks/useUsersList.tsx";
import { notifications } from "../../../../../shared/lib/notifications.ts";
import { getDatesBetween } from "../../../../../shared/lib/date.ts";
type WorkShiftsPlanningContextState = {
dateRange: [Date | null, Date | null];
setDateRange: React.Dispatch<React.SetStateAction<[Date | null, Date | null]>>;
tableRows: WorkShiftPlanningRow[];
onUpdate: (userId: number, date: Date, positions: string[]) => void;
users: UserSchema[];
shownUsers: UserSchema[];
setShownUsers: (showUsersProp: UserSchema[]) => void;
userPositions: Map<number, PositionSchema[]>;
range: Date[];
};
const WorkShiftsPlanningContext = createContext<WorkShiftsPlanningContextState | undefined>(
undefined,
);
const useWorkShiftsPlanningContextState = () => {
const [tableRows, tableRowsHandlers] = useListState<WorkShiftPlanningRow>([]);
const [filteredTableRows, filteredTableRowsHandlers] = useListState<WorkShiftPlanningRow>([]);
const [dateRange, setDateRange] = useState<
[Date | null, Date | null]
>([new Date(), getDefaultEndDate()]);
const { objects: users } = useUsersList();
const [shownUsers, shownUsersHandlers] = useListState<UserSchema>([]);
const userPositions = new Map<number, PositionSchema[]>(
users.map(user => [user.id, user.position ? [user.position] : []]),
);
const filterRows = (showUsersProp: UserSchema[], rows?: WorkShiftPlanningRow[]) => {
shownUsersHandlers.setState(showUsersProp);
if (!rows) {
rows = tableRows;
}
filteredTableRowsHandlers.setState(
rows.filter(row => {
return showUsersProp.findIndex(user => user.id === row.user.id) !== -1;
}),
);
};
const setShownUsers = (showUsersProp: UserSchema[]) => {
filterRows(showUsersProp);
};
const processShifts = (shifts: PlanningTableRow[]) => {
const processedRows: WorkShiftPlanningRow[] = [];
shifts.forEach((row: PlanningTableRow) => {
const processedRow: WorkShiftPlanningRow = {
user: row.user,
...Object.fromEntries(
row.shifts.reduce((acc, workShift) => {
return acc.set(
dateToString(new Date(workShift.shiftDate)) ?? "",
workShift.positions,
);
}, new Map<string, PositionSchema[]>()),
),
};
processedRows.push(processedRow);
});
tableRowsHandlers.setState(processedRows);
let shownUsersProp = shownUsers;
if (shownUsersProp.length === 0) {
shownUsersProp = processedRows.map(row => row.user);
}
users.forEach((user: UserSchema) => {
if (processedRows.findIndex(row => row.user.id === user.id) === -1) {
processedRows.push({ user });
}
});
processedRows.sort((a, b) => a.user.id - b.user.id);
filterRows(shownUsersProp, processedRows);
};
const refetch = () => {
const dateFrom = dateToString(dateRange[0]);
const dateTo = dateToString(dateRange[1]);
if (!(dateFrom && dateTo)) return;
WorkShiftsPlanningService.getWorkShifts({
requestBody: {
dateFrom,
dateTo,
},
})
.then(res => {
processShifts(res.shifts);
})
.catch(err => console.log(err));
};
const onUpdate = (userId: number, date: Date, positionKeys: string[]) => {
const shiftDate = dateToString(date);
if (!shiftDate) return;
WorkShiftsPlanningService.updateWorkShift({
requestBody: {
userId,
shiftDate,
positionKeys,
},
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
}
refetch();
})
.catch(err => console.log(err));
};
useEffect(() => {
refetch();
}, [dateRange]);
const getRange = () => {
const startDate = dateRange[0];
if (!startDate) return [];
const endDate = dateRange[1] ?? getDefaultEndDate(new Date(startDate));
const dayjsDates = getDatesBetween(startDate, endDate);
return dayjsDates.map(date => date.toDate());
};
const range = getRange();
return {
dateRange,
setDateRange,
tableRows: filteredTableRows,
onUpdate,
users,
shownUsers,
setShownUsers,
userPositions,
range,
};
};
type WorkShiftsPlanningContextProviderProps = {
children: React.ReactNode;
};
export const WorkShiftsPlanningContextProvider: FC<WorkShiftsPlanningContextProviderProps> = ({ children }) => {
const state = useWorkShiftsPlanningContextState();
return (
<WorkShiftsPlanningContext.Provider value={state}>
{children}
</WorkShiftsPlanningContext.Provider>
);
};
export const useWorkShiftsPlanningContext = () => {
const context = useContext(WorkShiftsPlanningContext);
if (!context) {
throw new Error(
"useWorkShiftsPlanningContext must be used within a WorkShiftsPlanningContextProvider",
);
}
return context;
};

View File

@@ -0,0 +1,58 @@
import { useMemo } from "react";
import { MRT_Cell, MRT_ColumnDef, MRT_Row } from "mantine-react-table";
import { getDayOfWeek } from "../../../../../shared/lib/date.ts";
import { Box, Flex, Text } from "@mantine/core";
import { WorkShiftPlanningRow } from "../types/WorkShiftPlanningRow.tsx";
import { useWorkShiftsPlanningContext } from "../contexts/WorkShiftsPlanningContext.tsx";
import { PositionSchema } from "../../../../../client";
import PositionsMultiSelect from "../components/PositionsMultiSelect/PositionsMultiSelect.tsx";
import { dateToString } from "../../../../../types/utils.ts";
const useWorkShiftsPlanningColumns = () => {
const { range } = useWorkShiftsPlanningContext();
return useMemo<MRT_ColumnDef<WorkShiftPlanningRow>[]>(
() => [
{
accessorKey: "name",
header: "ФИО",
Cell: ({ row }: { row: MRT_Row<WorkShiftPlanningRow> }) => (
<Flex direction={"column"}>
<Text size={"sm"}>
{row.original.user.firstName} {row.original.user.secondName}
</Text>
</Flex>
),
},
...range.map(date => ({
size: 100,
accessorKey: dateToString(date) ?? "",
header: dateToString(date) ?? "",
enableSorting: false,
enableColumnActions: false,
Header: (
<Flex direction={"column"}>
<Box>{date.toLocaleString("ru").substring(0, 10)}</Box>
<Box>{getDayOfWeek(date.getDay())}</Box>
</Flex>
),
Cell: ({ cell }: { cell: MRT_Cell<WorkShiftPlanningRow> }) => {
return (
<Flex direction={"column"}>
<PositionsMultiSelect
date={date}
positions={cell.getValue() as PositionSchema[]}
user={cell.row.original.user}
/>
</Flex>
);
},
})),
],
[range],
);
};
export default useWorkShiftsPlanningColumns;

View File

@@ -0,0 +1,6 @@
import { PlannedWorkShiftSchema, UserSchema } from "../../../../../client";
export type WorkShiftPlanningRow = {
user: UserSchema;
[key: string]: UserSchema | PlannedWorkShiftSchema;
};

View File

@@ -0,0 +1,7 @@
const DEFAULT_PLANNING_DURATION = 14;
export const getDefaultEndDate = (startDate?: Date) => {
const date = startDate ?? new Date();
date.setDate(date.getDate() + DEFAULT_PLANNING_DURATION);
return date;
};