Initial commit

This commit is contained in:
admin committed 2026-10-07 13:41:38 +04:00
1 parent 2d9431dc9d
commit 3372ca041f
254 files changed
+60975

No files matched your search

+20
View File
@@ -1,3 +1,4 @@
<<<<<<< HEAD
```
# Build artifacts
dist/
@@ -15,4 +16,23 @@ node_modules/
# Original rules preserved
*.log
=======
```
# Build artifacts
dist/
build/
# Dependencies
node_modules/
# Logs and temp files
*.log
*.tmp
# Editors
.vscode/
# Original rules preserved
*.log
>>>>>>> 23f02f8 (Initial commit)
```
+58
View File
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="996262b6-9925-4046-a710-d4de5e5651e6" name="Changes" comment="" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo"><![CDATA[{
"associatedIndex": 4,
"fromUser": false
}]]></component>
<component name="ProjectId" id="3I2Mhb4scziyY5ZKgBaXFoy9rXS" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"codeWithMe.voiceChat.enabledByDefault": "false",
"git-widget-placeholder": "main",
"last_opened_file_path": "C:/Users/leonid.kudryashov/Documents/code/planning",
"node.js.detected.package.eslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"nodejs_package_manager_path": "npm"
}
}]]></component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-python-sdk-6fa658e1df1a-5bd6d13a2a75-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-262.8665.369" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="996262b6-9925-4046-a710-d4de5e5651e6" name="Changes" comment="" />
<created>1786958329291</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1786958329291</updated>
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
</project>
+29
View File
@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
])
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

+17
View File
@@ -0,0 +1,17 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
"# planning"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+4016
View File
File diff suppressed because it is too large. Load diff
+45
View File
@@ -1,3 +1,4 @@
<<<<<<< HEAD
{
"name": "planning",
"private": true,
@@ -40,3 +41,47 @@
"vitest": "^4.1.10"
}
}
=======
{
"name": "planning",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.9.1",
"@lobehub/fluent-emoji-3d": "^1.1.0",
"axios": "^1.19.0",
"emoji-picker-react": "^4.22.2",
"framer-motion": "^13.3.0",
"pop-emojis": "^0.0.15",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-hook-form": "^7.85.0",
"react-router-dom": "^7.18.2",
"zod": "^3.25.76",
"zustand": "^5.0.15"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.4",
"eslint": "^10.8.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"typescript": "^7.0.2",
"vite": "^8.2.0",
"vitest": "^4.1.10"
}
}
>>>>>>> 23f02f8 (Initial commit)
BIN
View File
Binary file not shown.
+11944
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+38
View File
@@ -1,3 +1,4 @@
<<<<<<< HEAD
import { StoreProvider } from './context/StoreContext';
import { ToastProvider, useToast } from './context/ToastContext';
import { ConfirmProvider } from './context/ConfirmContext';
@@ -33,4 +34,41 @@ export default function App() {
</ToastProvider>
</StoreProvider>
);
=======
import { StoreProvider } from './context/StoreContext';
import { ToastProvider, useToast } from './context/ToastContext';
import { ConfirmProvider } from './context/ConfirmContext';
import { useStore } from './hooks';
import { useAuth } from './hooks/useAuth';
import LoginScreen from './components/LoginScreen';
import MainLayout from './components/MainLayout';
function AppContent() {
const { store, login } = useStore();
const { user } = useAuth();
const { showToast } = useToast();
if (!user) {
return (
<LoginScreen
registerEmployee={(payload) => store.registerEmployee(payload)}
onLogin={login}
toast={showToast}
/>
);
}
return <MainLayout store={store} user={user} />;
}
export default function App() {
return (
<StoreProvider>
<ToastProvider>
<ConfirmProvider>
<AppContent />
</ConfirmProvider>
</ToastProvider>
</StoreProvider>
);
>>>>>>> 23f02f8 (Initial commit)
}
+4
View File
@@ -1 +1,5 @@
<<<<<<< HEAD
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated
=======
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated
>>>>>>> 23f02f8 (Initial commit)

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 17 KiB

+175
View File
@@ -0,0 +1,175 @@
// src/components/Archive.jsx
import React, { useMemo } from 'react';
import { PROJECT_TYPES } from '../utils/constants';
import { fmtDMY, TODAY } from '../utils/date';
import { canRestore, canRestoreTask } from '../utils/permissions';
import { isArchived } from '../utils/entityState';
import { Ic, ICONS } from './Icons';
import { useDataHelpers, useFilters } from '../hooks';
import { Select } from './Select';
import { optionsFromList } from '../utils/selectOptions';
const INITIAL_FILTERS = Object.freeze({
dateFrom: '',
dateTo: '',
projectId: 'all',
assigneeId: 'all',
deptId: 'all',
});
export default function Archive({ db, ur, openTask, openProject, restoreTask, restoreProject }) {
const { getTaskSpent, empName } = useDataHelpers(db);
const { filters, setFilter } = useFilters(INITIAL_FILTERS);
const { dateFrom, dateTo, projectId, assigneeId, deptId } = filters;
const archProjects = db.projects.filter(isArchived);
const archTasks = db.tasks.filter(isArchived);
const fit = (archivedAt) => (!dateFrom || archivedAt >= dateFrom) && (!dateTo || archivedAt <= dateTo);
const projList = archProjects.filter(p => fit(p.archivedAt || TODAY));
const taskList = archTasks.filter(t => {
if (!fit(t.archivedAt || TODAY)) return false;
if (projectId !== 'all' && t.projectId !== projectId) return false;
if (assigneeId !== 'all' && t.assigneeId !== assigneeId) return false;
if (deptId !== 'all') {
const assignee = t.assigneeId ? db.employees.find(x => x.id === t.assigneeId) : null;
if (!assignee || !assignee.departments.some(d => d.deptId === deptId)) return false;
}
return true;
});
const execs = [...new Set(archTasks.map(t => t.assigneeId).filter(Boolean))]
.map(id => db.employees.find(e => e.id === id))
.filter(Boolean);
const projOptions = [...new Set(archTasks.map(t => t.projectId))]
.map(id => db.projects.find(p => p.id === id))
.filter(Boolean);
const projectSelectOptions = useMemo(
() => optionsFromList(projOptions, 'Все проекты', p => ({ value: p.id, label: p.code })),
[projOptions],
);
const assigneeSelectOptions = useMemo(
() => optionsFromList(execs, 'Все исполнители', e => ({ value: e.id, label: e.last })),
[execs],
);
const deptSelectOptions = useMemo(
() => optionsFromList(db.departments, 'Все подразделения', d => ({ value: d.id, label: d.name })),
[db.departments],
);
const renderRestoreCell = (task) => {
if (!canRestore(ur) || !isArchived(task)) return null;
if (canRestoreTask(ur, task, db)) {
return (
<button className="btn ghost sm" onClick={() => restoreTask(task.id)}>
<Ic d={ICONS.restore} size={12} /> Восстановить
</button>
);
}
return <span className="mut sm ml-2">Проект в архиве - сначала восстановите его</span>;
};
return (
<div>
<div className="sec-head">
<div className="sec-note">Архив закрытых задач и проектов. Только чтение.</div>
</div>
<div className="toolbar">
<input
className="inp arch-date-inp"
type="date"
value={dateFrom}
onChange={e => setFilter('dateFrom', e.target.value)}
/>
<input
className="inp arch-date-inp"
type="date"
value={dateTo}
onChange={e => setFilter('dateTo', e.target.value)}
/>
<Select
className="sm"
value={projectId}
onChange={v => setFilter('projectId', v)}
options={projectSelectOptions}
/>
<Select
className="sm"
value={assigneeId}
onChange={v => setFilter('assigneeId', v)}
options={assigneeSelectOptions}
/>
<Select
className="sm"
value={deptId}
onChange={v => setFilter('deptId', v)}
options={deptSelectOptions}
/>
</div>
<div className="rep-panel">
<div className="rep-panel-title">Архивные проекты ({projList.length})</div>
<table className="tbl">
<thead>
<tr>
<th>Код</th>
<th>Название</th>
<th>Тип</th>
<th>В архиве с</th>
<th></th>
</tr>
</thead>
<tbody>
{projList.map(p => (
<tr key={p.id}>
<td><span className="pj-code" style={{ background: p.color + '22', color: p.color }}>{p.code}</span></td>
<td><b>{p.name}</b></td>
<td>{PROJECT_TYPES[p.ptype || 'prod']}</td>
<td>{fmtDMY(p.archivedAt)}</td>
<td>
<button className="btn ghost sm" onClick={() => openProject(p.id)}>Открыть</button>
{canRestore(ur) && isArchived(p) && (
<button className="btn ghost sm" onClick={() => restoreProject(p.id)}>
<Ic d={ICONS.restore} size={12} /> Восстановить
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="rep-panel">
<div className="rep-panel-title">Архивные задачи ({taskList.length})</div>
<table className="tbl">
<thead>
<tr>
<th>Задача</th>
<th>Проект</th>
<th>Исполнитель</th>
<th>В архиве с</th>
<th></th>
</tr>
</thead>
<tbody>
{taskList.map(t => (
<tr key={t.id}>
<td><b>{t.title}</b></td>
<td>{db.projects.find(x => x.id === t.projectId)?.code}</td>
<td>{t.assigneeId ? empName(t.assigneeId) : '-'}</td>
<td>{fmtDMY(t.archivedAt)}</td>
<td>
<button className="btn ghost sm" onClick={() => openTask(t.id)}>Открыть</button>
{renderRestoreCell(t)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
// src/components/Avatar.jsx
import React, { memo, useState, useRef } from 'react';
import { createPortal } from 'react-dom';
import { initials } from '../utils/date';
import { DOMAIN } from '../utils/constants';
/**
* Аватар сотрудника с тултипом при наведении.
*
* memo-компонент: рендерится в списках и канбане десятками, при
* неизменном сотруднике не должен перерисовываться при изменении
* чужих данных. Пропсы - employee (ссылка на объект), size, className;
* memo сравнивает их стандартным shallow-equal.
*
* ВАЖНО: хуки вызываются до возможного return null. Раньше стоял
* `if (!employee) return null;` перед useState/useRef - это нарушение
* Rules of Hooks: если employee менялся с null на не-null (или наоборот)
* на смонтированном компоненте, порядок хуков в двух рендерах различался,
* и React падал с «Rendered fewer hooks than expected».
*
* Тултип показывает ФИО, служебный e-mail и (если есть) внутренний
* телефон. Состав полей формируется здесь, а не приходит пропсом:
* он одинаков во всех местах, где рендерится аватар, и не должен
* зависеть от того, что вызывающий код счёл нужным передать.
*/
const Avatar = memo(function Avatar({ employee, size = 'sm', className = '' }) {
const [tooltip, setTooltip] = useState({ visible: false, text: '', x: 0, y: 0 });
const ref = useRef(null);
if (!employee) return null;
// В employee.email хранится короткий логин без домена (так же, как
// в моках и в поле ввода при логине). Тултип показывает полный
// адрес - тот же формат, что в профиле кабинета.
const tooltipText = [
`${employee.last} ${employee.first}`,
employee.email ? `E-mail: ${employee.email}@${DOMAIN}` : null,
employee.extension ? `Вн. телефон: ${employee.extension}` : null,
].filter(Boolean).join('\n');
const handleMouseEnter = () => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect();
setTooltip({
visible: true,
text: tooltipText,
x: rect.left + rect.width / 2,
y: rect.top - 10,
});
}
};
const handleMouseLeave = () => {
setTooltip(prev => ({ ...prev, visible: false }));
};
const classes = `avatar${size ? ' ' + size : ''}${className ? ' ' + className : ''}`;
const avatarContent = employee.photo ? (
<img
src={employee.photo}
alt={`${employee.first} ${employee.last}`}
className={`${classes} avatar-img`}
/>
) : (
<div className={`${classes} avatar-initials`}>
{initials(employee.first, employee.last) || '?'}
</div>
);
return (
<>
<span
ref={ref}
className="avatar-wrapper"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{avatarContent}
</span>
{tooltip.visible &&
createPortal(
<div
className="avatar-tooltip"
style={{
left: tooltip.x,
top: tooltip.y,
transform: 'translateX(-50%) translateY(-100%)',
}}
>
{tooltip.text}
</div>,
document.body
)
}
</>
);
});
export default Avatar;
+584
View File
@@ -0,0 +1,584 @@
// src/components/Cabinet.jsx
import { useState, useMemo, useRef } from 'react';
import {
DOMAIN, TASK_STATUSES, TASK_STATUS_ORDER, VACATION_TYPES,
DIALOGS, TOASTS, FILE_LIMITS, FILE_MESSAGES,
} from '../utils/constants';
import { TODAY, fmtDMY, fmtD, iso, addDays } from '../utils/date';
import { isTaskActive } from '../utils/entityState';
import { useDataHelpers } from '../hooks';
import { useToast } from '../context/ToastContext';
import { useConfirm } from '../context/ConfirmContext';
import {
canManageProductionCalendar,
canManageSubstitution,
canViewRoleDelegation,
} from '../utils/permissions';
import { downloadCsv } from '../utils/csvExport';
import {
buildEmployeePositions,
formatPositionLabel,
} from '../utils/helpers';
import { Ic, ICONS } from './Icons';
import { Modal } from './Modal';
import ProductionCalendarView from './views/ProductionCalendarView';
const PasswordChangeModal = ({ user, store, onClose }) => {
const { showToast } = useToast();
const [oldPass, setOldPass] = useState('');
const [newPass, setNewPass] = useState('');
const [confirmPass, setConfirmPass] = useState('');
const [error, setError] = useState('');
const handleSubmit = () => {
setError('');
if (user.pass !== oldPass) { setError('Неверный старый пароль'); return; }
if (newPass.length < 8 || !/[A-ZА-ЯЁ]/.test(newPass) || !/[a-zа-яё]/.test(newPass) || !/\d/.test(newPass) || !/[^A-Za-zА-Яа-яЁё0-9]/.test(newPass)) {
setError('Пароль должен содержать минимум 8 символов, заглавные и строчные буквы, цифру и спецсимвол');
return;
}
if (newPass !== confirmPass) { setError('Пароли не совпадают'); return; }
const history = user.passwordHistory || [];
if (history.some(p => p === newPass)) {
setError('Этот пароль уже использовался ранее. Выберите другой.');
return;
}
store.upsertEmployee({
...user,
pass: newPass,
passwordHistory: [...history.slice(-4), newPass],
});
showToast('Пароль успешно изменён', 'success');
onClose();
};
return (
<Modal title="Смена пароля" onClose={onClose} width={460}>
<div className="form-grid">
<label className="lbl">Старый пароль<span className="required-star">*</span></label>
<input className="inp" type="password" value={oldPass} onChange={e => setOldPass(e.target.value)} />
<label className="lbl">Новый пароль<span className="required-star">*</span></label>
<input className="inp" type="password" value={newPass} onChange={e => setNewPass(e.target.value)} />
<label className="lbl">Подтверждение<span className="required-star">*</span></label>
<input className="inp" type="password" value={confirmPass} onChange={e => setConfirmPass(e.target.value)} />
</div>
{error && <div className="login-err mt-2">{error}</div>}
<div className="modal-foot">
<div className="spacer" />
<button className="btn ghost" onClick={onClose}>Отмена</button>
<button className="btn primary" onClick={handleSubmit}>Сменить пароль</button>
</div>
</Modal>
);
};
export default function Cabinet({ store, data, user, openTask, openVacation, openDelegation }) {
const { showToast } = useToast();
const { confirm } = useConfirm();
const { empName } = useDataHelpers(data);
const [tab, setTab] = useState('overview');
const [expFrom, setExpFrom] = useState('');
const [expTo, setExpTo] = useState('');
const [editMode, setEditMode] = useState(false);
const [extension, setExtension] = useState(user.extension || '');
const [showPasswordModal, setShowPasswordModal] = useState(false);
const positions = useMemo(
() => buildEmployeePositions(user, data),
[user, data],
);
const tasks = data.tasks;
const projects = data.projects;
const vacations = data.vacations;
const delegations = data.roleDelegations;
const myTasks = useMemo(
() => tasks.filter(t => isTaskActive(t) && t.assigneeId === user.id && !user.fired),
[tasks, user.id, user.fired],
);
const myProjects = useMemo(() => {
const ids = [...new Set(myTasks.map(t => t.projectId))];
return ids.map(id => projects.find(p => p.id === id)).filter(Boolean);
}, [myTasks, projects]);
const myVacs = useMemo(
() => vacations.filter(v => v.empId === user.id),
[vacations, user.id],
);
// Видимость записи — через доменный предикат (admin/director, fromId,
// toId). Дубль условия fromId/toId === user.id удалён.
const myDeleg = useMemo(
() => delegations.filter(r => canViewRoleDelegation(user, r)),
[delegations, user],
);
const days = useMemo(() => {
const out = [];
for (let i = 19; i >= 0; i--) out.push(iso(addDays(new Date(), -i)));
return out;
}, []);
const taskTableData = useMemo(() => {
return myTasks.map(t => {
const logMap = {};
t.logs.forEach(l => {
logMap[l.date] = (logMap[l.date] || 0) + l.hours;
});
return { task: t, logMap };
});
}, [myTasks]);
const exportMyReport = () => {
if (!expFrom || !expTo) {
showToast('Выберите даты начала и окончания периода', 'warning');
return;
}
if (expFrom > expTo) {
showToast('Дата начала не может быть позже даты окончания', 'error');
return;
}
const allLogs = [];
myTasks.forEach(t => {
const project = projects.find(p => p.id === t.projectId);
t.logs.forEach(l => {
if (l.date >= expFrom && l.date <= expTo) {
allLogs.push({
date: l.date,
hours: l.hours,
task: t.title,
project: project?.code || '-',
});
}
});
});
if (!allLogs.length) {
showToast('За выбранный период нет учтенных часов', 'warning');
return;
}
const rows = [['Проект', 'Задача', 'Дата', 'Часы']];
allLogs.forEach(l => rows.push([l.project, l.task, fmtDMY(l.date), l.hours]));
downloadCsv(`отчет_${user.last}_${expFrom}_${expTo}`, rows);
showToast('Отчёт выгружен!', 'success');
};
const canEditCalendar = canManageProductionCalendar(user);
const tabs = [
['overview', 'Сводка'],
['vacations', 'Мои отпуска'],
['delegation', 'Делегирование и замещение'],
['profile', 'Профиль и уведомления'],
...(canEditCalendar ? [['calendar', 'Правка календаря']] : []),
];
const handleSaveProfile = () => {
if (!extension.trim()) {
showToast('Внутренний номер телефона не может быть пустым', 'error');
return;
}
store.upsertEmployee({
...user,
extension: extension.trim(),
});
setEditMode(false);
showToast('Данные обновлены', 'success');
};
const fileInputRef = useRef(null);
const handlePhotoUpload = (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
showToast(FILE_MESSAGES.notImage, 'error');
e.target.value = '';
return;
}
if (file.size > FILE_LIMITS.profilePhoto) {
showToast(FILE_MESSAGES.profilePhotoTooLarge, 'error');
e.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = (ev) => {
store.upsertEmployee({ ...user, photo: ev.target.result });
showToast('Фото загружено', 'success');
};
reader.readAsDataURL(file);
};
const handlePhotoDelete = async () => {
if (!user.photo) return;
const ok = await confirm(DIALOGS.deleteProfilePhoto);
if (!ok) return;
store.upsertEmployee({ ...user, photo: null });
showToast(TOASTS.photoDeleted, 'info');
};
const handleRevokeDelegation = (rd) => {
try {
store.revokeRoleDelegation(rd.id);
showToast('Замещение отозвано', 'success');
} catch (err) {
showToast(err.message || 'Не удалось отозвать замещение', 'error');
}
};
return (
<div className="cab">
<div className="tabs">
{tabs.map(([id, label]) => (
<button key={id} className={`tab${tab === id ? ' on' : ''}`} onClick={() => setTab(id)}>
{label}
</button>
))}
</div>
{tab === 'overview' && (
<div className="flex flex-col gap-4">
<div className="rep-panel">
<div className="rep-panel-title">Затраченные часы по задачам (последние 20 дней)</div>
<div className="toolbar flex items-center flex-wrap gap-2 mb-3">
<span className="mut sm">Экспорт:</span>
<input className="inp w-150" type="date" value={expFrom} onChange={e => setExpFrom(e.target.value)} />
<span className="mut sm">-</span>
<input className="inp w-150" type="date" value={expTo} onChange={e => setExpTo(e.target.value)} />
<button className="btn primary sm" onClick={exportMyReport}>
<Ic d={ICONS.download} size={13} /> Выгрузить в Excel (CSV)
</button>
</div>
<div className="w-full overflow-x-auto">
<table className="tbl w-full cab-timesheet-table">
<thead>
<tr>
<th className="text-left border-b cab-timesheet-task-col">Задача</th>
{days.map(d => (
<th key={d} className="text-center border-b text-xs text-mut cab-timesheet-day-col">
{fmtD(d)}
</th>
))}
</tr>
</thead>
<tbody>
{taskTableData.map(({ task, logMap }) => (
<tr key={task.id} className="cursor-pointer" onClick={() => openTask(task.id)}>
<td className="text-left font-semibold border-b">
{task.title}
<span className="mut sm font-normal ml-2">
({projects.find(p => p.id === task.projectId)?.code || '-'})
</span>
</td>
{days.map(d => (
<td key={d} className="text-center border-b">
{logMap[d] ? <b>{logMap[d]} ч</b> : ''}
</td>
))}
</tr>
))}
{taskTableData.length === 0 && (
<tr><td colSpan={days.length + 1} className="mut text-center p-5">Нет учтённых часов</td></tr>
)}
</tbody>
</table>
</div>
</div>
<div className="rep-panel">
<div className="rep-panel-title">Мои задачи по статусам</div>
{TASK_STATUS_ORDER.map(st => {
const list = myTasks.filter(t => t.status === st);
if (!list.length) return null;
return (
<div key={st} className="cab-stat">
<div className="cab-stat-head">
<span className="kdot" style={{ background: TASK_STATUSES[st].color }} />
{TASK_STATUSES[st].label}
<span className="kcount">{list.length}</span>
</div>
{list.map(t => (
<div key={t.id} className="cab-task" onClick={() => openTask(t.id)}>
<span className="pdot" style={{ background: projects.find(p => p.id === t.projectId)?.color }} />
{t.title}
<span className="mut sm">
{' · '}
{t.deadline ? `до ${fmtD(t.deadline)}` : 'без срока исполнения'}
{' · '}
{t.plannedHours ?? '-'} ч
</span>
</div>
))}
</div>
);
})}
{myTasks.length === 0 && <div className="mut">Задач пока нет</div>}
</div>
<div className="rep-panel">
<div className="rep-panel-title">Мои проекты</div>
{myProjects.map(p => (
<div key={p.id} className="cab-proj">
<span className="pdot" style={{ background: p.color }} />
{p.code} - {p.name}
{p.ptype === 'admin' && <span className="adm-badge ml-2">адм</span>}
</div>
))}
{myProjects.length === 0 && <div className="mut">Нет участия в проектах</div>}
</div>
</div>
)}
{tab === 'vacations' && (
<div className="rep-panel">
<div className="rep-panel-title flex items-center">
Мои отпуска
<button className="btn primary sm ml-auto" onClick={() => openVacation(null)}>
<Ic d={ICONS.plus} size={13} /> Добавить отпуск
</button>
</div>
<table className="tbl">
<thead>
<tr>
<th>Начало</th><th>Окончание</th><th>Тип</th><th>Комментарий</th>
<th>Статус</th><th></th>
</tr>
</thead>
<tbody>
{myVacs.map(v => {
const started = v.start <= TODAY;
return (
<tr key={v.id}>
<td>{fmtDMY(v.start)}</td>
<td>{fmtDMY(v.end)}</td>
<td>{VACATION_TYPES[v.type]}</td>
<td>{v.comment || '-'}</td>
<td>
<span className={`st-chip ${v.status}`}>
{{ pending: 'На утверждении', approved: 'Утверждён', rejected: 'Отклонён' }[v.status]}
</span>
</td>
<td>
{!started && v.status !== 'rejected' ? (
<>
<button className="icon-btn" onClick={() => openVacation(v.id)}>
<Ic d={ICONS.edit} size={14} />
</button>
<button className="icon-btn danger" onClick={() => {
store.deleteVacation(v.id);
store.addAudit('Удаление отпуска', fmtDMY(v.start));
}}>
<Ic d={ICONS.trash} size={14} />
</button>
</>
) : (
<span className="mut sm">{started ? 'изменение через HR/администратора' : ''}</span>
)}
</td>
</tr>
);
})}
{myVacs.length === 0 && <tr><td colSpan="6" className="mut">Отпусков нет</td></tr>}
</tbody>
</table>
</div>
)}
{tab === 'delegation' && (
<div className="rep-panel">
<div className="rep-panel-title flex items-center">
Временная передача ролей
<button className="btn primary sm ml-auto" onClick={openDelegation}>
<Ic d={ICONS.plus} size={13} /> Добавить
</button>
</div>
<p className="mut sm">
Роли «Суперадминистратор» и «Генеральный директор» делегируются только через
суперадминистратора. Получатель должен подтвердить принятие. Здесь же — замещения
на период отсутствия (отпуск, командировка, больничный), создаваемые вручную:
с отпуском они больше не связаны.
</p>
<table className="tbl">
<thead>
<tr>
<th>От кого</th><th>Кому</th><th>Роли</th><th>Период</th>
<th>Статус</th><th></th>
</tr>
</thead>
<tbody>
{myDeleg.map(r => {
const canManage = canManageSubstitution(user, r);
return (
<tr key={r.id}>
<td>{empName(r.fromId)}</td>
<td>{empName(r.toId)}</td>
<td>{r.roles.join(', ')}</td>
<td>{fmtDMY(r.start)} - {r.end ? fmtDMY(r.end) : 'до отмены'}</td>
<td>
<span className={`st-chip ${r.status}`}>
{{
pending: 'Ожидает принятия',
active: 'Активно',
rejected: 'Отклонено',
revoked: 'Отозвано',
cancelled: 'Отменено',
expired: 'Истекло',
}[r.status] || r.status}
</span>
</td>
<td>
{r.status === 'active' && canManage && (
<button
className="btn ghost sm"
onClick={() => handleRevokeDelegation(r)}
>
Отозвать
</button>
)}
</td>
</tr>
);
})}
{myDeleg.length === 0 && <tr><td colSpan="6" className="mut">Делегирований нет</td></tr>}
</tbody>
</table>
</div>
)}
{tab === 'calendar' && canEditCalendar && (
<ProductionCalendarView store={store} />
)}
{tab === 'profile' && (
<>
<div className="cab-grid">
<div className="rep-panel">
<div className="rep-panel-title">Личные данные</div>
<div className="flex gap-4 flex-wrap">
<div className="cab-photo-col">
{user.photo
? <img src={user.photo} alt="Аватар" className="cab-profile-photo" />
: <div className="cab-profile-photo-empty">Нет фото</div>}
<input type="file" accept="image/*" ref={fileInputRef}
className="file-input-hidden" onChange={handlePhotoUpload} />
<button className="btn primary sm" onClick={() => fileInputRef.current?.click()}>
Загрузить фото
</button>
{user.photo && (
<button className="btn ghost sm" onClick={handlePhotoDelete}>Удалить фото</button>
)}
</div>
<div className="cab-profile-fields">
<div className="form-grid">
<label className="lbl">Фамилия</label>
<input className="inp" disabled value={user.last} />
<label className="lbl">Имя</label>
<input className="inp" disabled value={user.first} />
<label className="lbl">Должность</label>
<input className="inp" disabled value={user.position || 'Сотрудник'} />
<label className="lbl">E-mail</label>
<input className="inp" disabled value={user.email + '@' + DOMAIN} />
<label className="lbl">
Внутренний номер телефона<span className="required-star">*</span>
</label>
<input className="inp" disabled={!editMode} value={extension}
onChange={e => setExtension(e.target.value)}
onBlur={() => {
if (!extension.trim()) showToast('Внутренний номер телефона обязателен', 'error');
}} />
<label className="lbl">Табельный №</label>
<input className="inp" disabled value={user.tab || ''} />
<label className="lbl">Подразделения</label>
<div className="depts-readonly">
{positions.map((p, i) => (
<span key={i} className={`dept-chip${p.kind === 'primary' ? ' prim' : ''}`}>
{formatPositionLabel(p)}
<span className="mut sm">
{' · '}{p.kind === 'primary' ? 'основное' : 'совм'}
</span>
</span>
))}
{positions.length === 0 && <span className="mut sm">не назначены</span>}
</div>
</div>
<div className="mt-4 flex gap-3 justify-end items-center">
<button className="btn danger" onClick={() => setShowPasswordModal(true)}>
Изменить пароль
</button>
{editMode ? (
<>
<button className="btn ghost" onClick={() => {
setEditMode(false);
setExtension(user.extension || '');
}}>Отмена</button>
<button className="btn primary" onClick={handleSaveProfile}>Сохранить</button>
</>
) : (
<button className="btn primary" onClick={() => setEditMode(true)}>
Изменить данные
</button>
)}
</div>
</div>
</div>
</div>
<div className="rep-panel">
<div className="rep-panel-title">Уведомления</div>
{[
['deadlineEmail', 'E-mail о сроках - за 3 дня до срока исполнения задачи'],
['overdueDigest', 'Контроль просрочек - ежедневная сводка'],
['commentSub', 'Подписка на обсуждение задач, где я исполнитель или ответственный'],
].map(([k, label]) => (
<label key={k} className="toggle-row">
<span>{label}</span>
<span className={`toggle${user.notif[k] ? ' on' : ''}`} onClick={() => {
store.upsertEmployee({
...user,
notif: { ...user.notif, [k]: !user.notif[k] },
});
}}><span className="toggle-knob" /></span>
</label>
))}
<p className="mut sm">
Критичные уведомления (восстановление пароля, утверждения) отключить нельзя.
</p>
<div className="rep-panel-title mt-4">История делегирований</div>
{delegations.filter(r => r.fromId === user.id).map(r => (
<div key={r.id} className="mut sm">
→ {empName(r.toId)}: {r.roles.join(', ')} (
{fmtDMY(r.start)} - {r.end ? fmtDMY(r.end) : 'до отмены'})
</div>
))}
{delegations.filter(r => r.fromId === user.id).length === 0 && (
<div className="mut sm">Передач ролей не было</div>
)}
</div>
</div>
{showPasswordModal && (
<PasswordChangeModal
user={user}
store={store}
onClose={() => setShowPasswordModal(false)}
/>
)}
</>
)}
</div>
);
}
+213
View File
@@ -0,0 +1,213 @@
// src/components/Calendar.jsx
import { useState, useMemo, memo } from 'react';
import { useToast } from '../context/ToastContext';
import { computeScope, canSeeAllContent, taskVisible } from '../utils/permissions';
import { isTaskActive } from '../utils/entityState';
import { changeTaskStatus, deleteTask } from '../utils/taskActions';
import { buildTaskMenu } from './menus';
import FloatingMenu from './FloatingMenu';
import EmployeeCardLink from './EmployeeCardLink';
import { Ic, ICONS } from './Icons';
import { ToggleSwitch } from './ToggleSwitch';
import { fmtDMY, iso, addDays } from '../utils/date';
import { useScheduleDb } from '../hooks/useDb';
const MONTH_NAMES = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь',
];
const DayCell = ({ d, big, byDay, renderChip }) => {
const dayIso = iso(d);
const tasks = byDay[dayIso] || [];
const todayIso = iso(new Date());
return (
<div className={`cal-cell${dayIso === todayIso ? ' today' : ''}`}>
<div className="cal-daynum">{d.getDate()}</div>
<div className="cal-chips">
{tasks.slice(0, big ? 12 : 3).map(t => renderChip(t))}
</div>
</div>
);
};
function Calendar({ ur, openTask, store, openCopyTask, openTemplateFromTask }) {
const db = useScheduleDb();
const { tasks, projects, employees } = db;
const { showToast } = useToast();
const scope = useMemo(() => computeScope(ur, db), [ur, db]);
const [mode, setMode] = useState('month');
const [anchor, setAnchor] = useState(new Date());
const [showOnlyMy, setShowOnlyMy] = useState(false);
// Единая точка правила «видит весь контент» - та же, что в TasksView
// и ProjectsView. Раньше список ролей был продублирован здесь строкой,
// и при изменении набора ролей приходилось править два файла.
const canSeeAll = canSeeAllContent(ur);
const allTasks = useMemo(() => {
let list = tasks.filter(t =>
isTaskActive(t) &&
taskVisible(ur, scope, t, db) &&
t.deadline &&
!['closed', 'cancelled'].includes(t.status)
);
if (showOnlyMy) list = list.filter(t => t.assigneeId === ur.id);
return list;
}, [tasks, ur, scope, db, showOnlyMy]);
const byDay = useMemo(() => {
const m = {};
allTasks.forEach(t => {
(m[t.deadline] = m[t.deadline] || []).push(t);
});
return m;
}, [allTasks]);
const shift = (dir) => {
if (mode === 'month') {
setAnchor(new Date(anchor.getFullYear(), anchor.getMonth() + dir, 1));
} else if (mode === 'week') {
setAnchor(addDays(anchor, dir * 7));
} else {
setAnchor(addDays(anchor, dir));
}
};
const title =
mode === 'month'
? `${MONTH_NAMES[anchor.getMonth()]} ${anchor.getFullYear()}`
: mode === 'week'
? `Неделя ${fmtDMY(iso(addDays(anchor, -((anchor.getDay() + 6) % 7))))} - ${fmtDMY(iso(addDays(anchor, 6 - (anchor.getDay() + 6) % 7)))}`
: fmtDMY(iso(anchor));
const handleMoveTask = (taskId, newStatus) => {
if (changeTaskStatus({
task: tasks.find(t => t.id === taskId),
newStatus, user: ur, db, store,
}) === false) {
showToast('У вас нет прав на изменение статуса этой задачи.', 'error');
}
};
const handleDeleteTask = (task) => {
deleteTask({ task, store });
showToast(`Задача «${task.title}» удалена`, 'success');
};
const renderTaskChip = (task) => {
const p = projects.find(x => x.id === task.projectId);
const assignee = task.assigneeId ? employees.find(e => e.id === task.assigneeId) : null;
const menuItems = buildTaskMenu({
task, user: ur, db, openTask,
onMove: handleMoveTask,
onDelete: handleDeleteTask,
onCopy: openCopyTask,
onMakeTemplate: openTemplateFromTask,
});
return (
<FloatingMenu key={task.id} items={menuItems}>
{({ anchorProps }) => (
<div
{...anchorProps}
className="cal-chip"
style={{ borderColor: p?.color }}
onClick={() => openTask(task.id)}
title={`${task.title} (${p?.code || 'без проекта'})`}
>
<div className="cal-task-title">
<span className="pdot" style={{ background: p?.color }} />
<span>{task.title}</span>
</div>
<div className="cal-executors">
{assignee && <EmployeeCardLink employee={assignee} size="xs" />}
</div>
</div>
)}
</FloatingMenu>
);
};
let body = null;
if (mode === 'month') {
const first = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
const offset = (first.getDay() + 6) % 7;
const cells = [];
for (let i = 0; i < 42; i++) cells.push(addDays(first, i - offset));
body = (
<>
<div className="cal-grid-head">
{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(w => (
<div key={w} className="cal-wd">{w}</div>
))}
</div>
<div className="cal-grid">
{cells.map(d => (
<div key={iso(d)} className={d.getMonth() === anchor.getMonth() ? '' : 'outwrap'}>
<DayCell d={d} big={false} byDay={byDay} renderChip={renderTaskChip} />
</div>
))}
</div>
</>
);
} else if (mode === 'week') {
const mon = addDays(anchor, -((anchor.getDay() + 6) % 7));
body = (
<div className="cal-week">
{[0, 1, 2, 3, 4, 5, 6].map(i => (
<DayCell key={i} d={addDays(mon, i)} big byDay={byDay} renderChip={renderTaskChip} />
))}
</div>
);
} else {
body = (
<div className="cal-week one">
<DayCell d={anchor} big byDay={byDay} renderChip={renderTaskChip} />
</div>
);
}
return (
<div className="cal-panel">
<div className="cal-head">
<div className="cal-nav">
<button className="icon-btn" onClick={() => shift(-1)}>
<Ic d={ICONS.left} size={16} />
</button>
<div className="cal-title">{title}</div>
<button className="icon-btn" onClick={() => shift(1)}>
<Ic d={ICONS.right} size={16} />
</button>
</div>
<div className="cal-right">
<button className="btn ghost sm" onClick={() => setAnchor(new Date())}>Сегодня</button>
<div className="seg">
{['day', 'week', 'month'].map(m => (
<button
key={m}
className={`seg-btn${mode === m ? ' on' : ''}`}
onClick={() => setMode(m)}
>
{['День', 'Неделя', 'Месяц'][['day', 'week', 'month'].indexOf(m)]}
</button>
))}
</div>
{canSeeAll && (
<ToggleSwitch
checked={showOnlyMy}
onChange={setShowOnlyMy}
label="Мои задачи"
/>
)}
</div>
</div>
<div className="cal-note">Только задачи со сроком выполнения.</div>
{body}
</div>
);
}
export default memo(Calendar);
+59
View File
@@ -0,0 +1,59 @@
import { useState } from 'react';
import { Modal } from './Modal';
import { useNestedModalEscape } from './Templates/useNestedModalEscape';
export const ConfirmDialog = ({ state, onCancel, onSubmit }) => {
const [value, setValue] = useState(state.defaultValue || '');
const isPrompt = state.mode === 'prompt';
const canSubmit = !isPrompt || value.trim().length > 0;
// Перехватываем Escape до модалки, лежащей под нами. Иначе нажатие
// закрыло бы и наш диалог, и родительский (TaskModal, ProjectModal и т.п.).
useNestedModalEscape(onCancel);
const handleSubmit = () => {
if (!canSubmit) return;
onSubmit(isPrompt ? value.trim() : true);
};
const title = state.title || (isPrompt ? 'Введите значение' : 'Подтвердите действие');
return (
<Modal
title={title}
onClose={onCancel}
width={440}
footer={
<div className="modal-foot">
<div className="spacer" />
<button className="btn ghost" onClick={onCancel}>
{state.cancelLabel || 'Отмена'}
</button>
<button
className={state.danger ? 'btn danger' : 'btn primary'}
onClick={handleSubmit}
disabled={!canSubmit}
>
{state.confirmLabel || (isPrompt ? 'Создать' : 'OK')}
</button>
</div>
}
>
{state.message && (
<p style={{ fontSize: 15, lineHeight: 1.55, marginBottom: isPrompt ? 14 : 0 }}>
{state.message}
</p>
)}
{isPrompt && (
<input
className="inp"
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={state.placeholder || ''}
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
/>
)}
</Modal>
);
};
+38
View File
@@ -0,0 +1,38 @@
// src/components/DateMaskInput.jsx
import { formatDateMask } from '../utils/dateMask';
/**
* Текстовый ввод с маской ДД.ММ.ГГГГ. Контролируемый: значение держит
* родитель, компонент только форматирует очередное нажатие.
*
* Маске передаётся текущее значение поля (`value`) как «предыдущее» -
* по нему она отличает печать от стирания. Это нужно для корректного
* разворота двузначного года: при стирании "01.01.2026" → "01.01.20"
* год не должен снова превращаться в "2020".
*
* `inputMode` не задан: на мобильных нужна обычная клавиатура, чтобы
* пользователь мог поставить разделитель. Маска нормализует ввод в любом
* случае - от пользователя нужны только цифры.
*/
export default function DateMaskInput({
value,
onChange,
placeholder,
disabled = false,
className = 'inp',
}) {
const handleChange = (e) => onChange(formatDateMask(e.target.value, value));
return (
<input
type="text"
className={className}
value={value}
onChange={handleChange}
placeholder={placeholder}
disabled={disabled}
autoComplete="off"
spellCheck={false}
/>
);
}
+68
View File
@@ -0,0 +1,68 @@
// src/components/DelegationFields.jsx
import { FormField } from "./FormField";
import { DELEGATION_STATUS_OPTIONS } from "../utils/transferModes";
/**
* Мультивыбор статусов задач для делегирования.
*
* Один компонент — одна разметка для двух модалок (передача полномочий
* и отпуск). Если список статусов или порядок чекбоксов поменяются —
* правка здесь применяется везде.
*
* Пустой набор означает «задачи не передавать»: никакого скрытого
* «умолчания на все активные» нет ни здесь, ни в сервисах.
*/
export function TaskStatusPicker({ value, onChange, disabled = false }) {
const selected = Array.isArray(value) ? value : [];
const toggle = (statusValue, checked) => {
onChange(
checked
? [...selected, statusValue]
: selected.filter((v) => v !== statusValue)
);
};
return (
<div className="field-row">
<label className="field-label">Какие задачи</label>
<div className="sub-picks">
{DELEGATION_STATUS_OPTIONS.map((opt) => (
<label className="dept-pick" key={opt.value}>
<input
type="checkbox"
checked={selected.includes(opt.value)}
disabled={disabled}
onChange={(e) => toggle(opt.value, e.target.checked)}
/>
{opt.label}
</label>
))}
</div>
</div>
);
}
/**
* Выбор получателя полномочий. Список уже отфильтрован правилами
* getSubstituteCandidates — компонент только рендерит его.
*/
export function RecipientSelect({
label = "Получатель",
value,
onChange,
options,
error,
disabled
}) {
return (
<FormField
label={label}
required
type="select"
options={options}
value={value}
onChange={onChange}
error={error}
disabled={disabled}
/>
);
}
+309
View File
@@ -0,0 +1,309 @@
// src/components/Discussion.jsx
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { extractMentions } from '../utils/mentionParser';
import { TOASTS } from '../utils/constants';
import { Lightbox } from './Lightbox';
import { DiscussionProvider } from './discussion/context';
import { CommentPolicy } from './discussion/CommentPolicy';
import { useCommentList } from '../hooks';
import { readLastReadAt } from '../hooks/useChatStats';
import DiscussionHeader from './discussion/DiscussionHeader';
import SortToolbar from './discussion/SortToolbar';
import CommentTree from './discussion/CommentTree';
import CommentComposer from './discussion/CommentComposer';
export { extractMentions };
/**
* Скролл к комментарию по id: подсветить и доскроллить до центра.
*
* Таймер снятия класса «comment-flash» держим в ref и снимаем в
* cleanup-эффекте при размонтировании Discussion - чтобы после ухода
* с вкладки он не пытался трогать DOM.
*
* `comments` в deps - чтобы повторный вызов на тот же targetId после
* изменения списка сработал снова.
*/
function useScrollToComment(comments) {
const [targetId, setTargetId] = useState(null);
const timerRef = useRef(null);
useEffect(() => () => clearTimeout(timerRef.current), []);
useLayoutEffect(() => {
if (!targetId) return;
const el = document.getElementById(`comment-${targetId}`);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('comment-flash');
timerRef.current = setTimeout(() => {
timerRef.current = null;
if (el.isConnected) el.classList.remove('comment-flash');
}, 2000);
setTargetId(null);
}, [targetId, comments]);
return setTargetId;
}
/**
* Обсуждение - чат задачи или проекта.
*
* Принимает projectId и taskId отдельными пропсами. Один из них может
* быть null:
* - чат проекта (ProjectChat): только projectId;
* - чат задачи (TaskModal): оба.
*/
export default function Discussion({
store,
projectId = null,
taskId = null,
currentUser,
candidates = [],
readOnly = false,
toast,
employees = [],
onTaskClick = null,
showTaskLink = false,
tasks = [],
}) {
const [searchQuery, setSearchQuery] = useState('');
const [sortOrder, setSortOrder] = useState('new');
const [replyTo, setReplyTo] = useState(null);
const [editingId, setEditingId] = useState(null);
const [editText, setEditText] = useState('');
const [lightbox, setLightbox] = useState({ index: null, list: [] });
/**
* Точка отсчёта «прочитано до». Всё, что старше и чужое, считается
* прочитанным; всё, что новее - подсвечивается как непрочитанное.
*
* Стартовое значение - что успело прочитаться до открытия чата
* (из store, через readLastReadAt - разовое чтение без подписки).
* Первое открытие - cutoff = 0, вся история подсвечена.
*
* Дальше cutoff двигается вперёд:
* - при открытии чата (эффект ниже);
* - при отправке своего сообщения (handleCommentCreated) - старые
* непрочитанные «гаснут».
*/
const [readCutoff, setReadCutoff] = useState(() =>
readLastReadAt(store, currentUser.id, { projectId, taskId })
);
const isCommentUnread = useCallback(
(c) => c.authorId !== currentUser.id && c.createdAt > readCutoff,
[currentUser.id, readCutoff],
);
// Пометка «прочитано = сейчас» при открытии чата и при закрытии:
// счётчики на вкладке и карточке обнуляются сразу, а не по закрытию
// модалки. Cleanup закрывает окно между открытием и закрытием -
// если во время просмотра пришли новые сообщения, они тоже попадут
// в прочитанные.
useEffect(() => {
const userId = currentUser.id;
const filter = { projectId, taskId };
store.markChatRead(userId, filter);
return () => store.markChatRead(userId, filter);
}, [store, currentUser.id, projectId, taskId]);
const { comments, visibleComments, matchSteps } = useCommentList(
projectId, taskId, searchQuery, sortOrder,
);
const scrollToComment = useScrollToComment(comments);
/**
* Отправка нового сообщения: сдвигаем readCutoff вперёд - все
* прежние непрочитанные гаснут; отмечаем чат прочитанным в store -
* счётчики на вкладке и карточке обнуляются; прокручиваем к
* созданному комментарию.
*/
const handleCommentCreated = useCallback((commentId) => {
setReadCutoff(Date.now());
store.markChatRead(currentUser.id, { projectId, taskId });
scrollToComment(commentId);
}, [store, currentUser.id, projectId, taskId, scrollToComment]);
const [currentMatchIndex, setCurrentMatchIndex] = useState(-1);
useEffect(() => {
setCurrentMatchIndex(-1);
}, [searchQuery]);
const goToMatch = useCallback((delta) => {
if (!matchSteps.length) return;
const next = currentMatchIndex === -1
? (delta > 0 ? 0 : matchSteps.length - 1)
: (currentMatchIndex + delta + matchSteps.length) % matchSteps.length;
setCurrentMatchIndex(next);
scrollToComment(matchSteps[next].commentId);
}, [currentMatchIndex, matchSteps, scrollToComment]);
const goToPrevMatch = useCallback(() => goToMatch(-1), [goToMatch]);
const goToNextMatch = useCallback(() => goToMatch(1), [goToMatch]);
const currentMatchNumber = currentMatchIndex === -1 ? 0 : currentMatchIndex + 1;
const activeStep = currentMatchIndex >= 0 ? matchSteps[currentMatchIndex] : null;
// Резолв автора по Map<id, employee>. Раньше был линейный .find -
// он вызывался для каждого комментария (и его потомков) в дереве,
// и на больших ветках складывался в O(N²). Стабильная ссылка на
// Map и на useCallback - чтобы getAuthor не менялся при каждом
// ре-рендере Discussion и не срывал возможный memo в потомках.
const employeesById = useMemo(
() => new Map(employees.map(e => [e.id, e])),
[employees],
);
const getAuthor = useCallback(
(id) => employeesById.get(id),
[employeesById],
);
const policy = useMemo(
() => new CommentPolicy({ currentUser, comments, readOnly }),
[currentUser, comments, readOnly]
);
const pinnedComments = useMemo(
() => comments.filter(c => c.pinned),
[comments]
);
const saveEdit = useCallback((c) => {
if (!editText.trim()) return;
store.updateComment(c.id, editText.trim());
setEditingId(null);
setEditText('');
toast?.('Комментарий обновлён');
}, [editText, store, toast]);
const togglePin = useCallback((c) => {
try {
if (!c.pinned && pinnedComments.length >= 5) {
toast?.('Нельзя закрепить более 5 сообщений', 'warning');
return;
}
const updated = store.togglePinComment(c.id);
toast?.(updated.pinned ? 'Комментарий закреплён' : 'Закрепление снято', 'success');
} catch (err) {
toast?.(err.message, 'error');
}
}, [pinnedComments.length, store, toast]);
const setReaction = useCallback((commentId, emoji) => {
try {
store.setReaction(commentId, emoji);
} catch (err) {
toast?.(err.message, 'error');
}
}, [store, toast]);
const onDelete = useCallback((c) => {
store.deleteComment(c.id);
toast?.(TOASTS.commentDeleted, 'success');
}, [store, toast]);
const openLightbox = useCallback((list, index) => setLightbox({ list, index }), []);
const closeLightbox = useCallback(() => setLightbox({ list: [], index: null }), []);
const prevLightbox = useCallback(() =>
setLightbox(s => ({ ...s, index: s.index === 0 ? s.list.length - 1 : s.index - 1 })), []);
const nextLightbox = useCallback(() =>
setLightbox(s => ({ ...s, index: s.index === s.list.length - 1 ? 0 : s.index + 1 })), []);
// Контекст пересобирается на каждый рендер - это неизбежно, потому
// что часть полей (editingId, editText, activeStep) меняется от
// действий внутри дерева. Мемоизация не даст выигрыша: её зависимости
// - почти все поля объекта. Если понадобится - разделим контекст на
// «данные» и «интерфейс».
const ctx = {
currentUser,
employees,
getAuthor,
policy,
comments,
visibleComments,
sortOrder,
searchQuery,
activeOccurrence: activeStep,
editingId, setEditingId,
editText, setEditText,
saveEdit,
togglePin,
setReaction,
onDelete,
openLightbox,
showTaskLink,
onTaskClick,
tasks,
readOnly,
onReply: setReplyTo,
isCommentUnread,
};
return (
<DiscussionProvider value={ctx}>
<div className="chat">
<DiscussionHeader
pinned={pinnedComments}
onJump={scrollToComment}
onSearchChange={setSearchQuery}
totalMatches={matchSteps.length}
currentMatch={currentMatchNumber}
onPrevMatch={goToPrevMatch}
onNextMatch={goToNextMatch}
/>
{comments.length > 1 && (
<SortToolbar value={sortOrder} onChange={setSortOrder} />
)}
<CommentTree />
{visibleComments.length === 0 && searchQuery && (
<div className="mut sm">Ничего не найдено</div>
)}
{comments.length === 0 && !searchQuery && (
<div className="mut sm">Обсуждений пока нет - начните диалог.</div>
)}
{!readOnly ? (
<CommentComposer
store={store}
projectId={projectId}
taskId={taskId}
currentUser={currentUser}
candidates={candidates}
toast={toast}
replyTo={replyTo}
setReplyTo={setReplyTo}
comments={comments}
getAuthor={getAuthor}
readOnly={readOnly}
onCommentCreated={handleCommentCreated}
/>
) : readOnly ? (
<div className="info-box">
Обсуждение сохранено. Добавление комментариев к архивным объектам запрещено.
</div>
) : (
<div className="info-box">
У вас нет прав для комментирования этого объекта.
</div>
)}
{lightbox.index !== null && lightbox.list.length > 0 && (
<Lightbox
photos={lightbox.list}
currentIndex={lightbox.index}
onClose={closeLightbox}
onPrev={prevLightbox}
onNext={nextLightbox}
/>
)}
</div>
</DiscussionProvider>
);
}
+75
View File
@@ -0,0 +1,75 @@
// src/components/EmployeeCardLink.jsx
import { useState, useCallback, useMemo } from 'react';
import Avatar from './Avatar';
import { EmployeeCardModal } from './Modals/EmployeeCardModal';
import { useSelector } from '../context/StoreContext';
/**
* Обёртка вокруг Avatar: клик открывает read-only карточку сотрудника.
*
* Держит своё состояние открытия локально — карточка привязана к
* одному конкретному сотруднику, глобальный стек модалок для неё
* избыточен.
*
* onClick обязан гасить событие: аватары рендерятся внутри
* кликабельных карточек (задача, проект, канбан, комментарий), без
* stopPropagation клик открыл бы и карточку сотрудника, и
* родительскую сущность.
*
* handleClose дополнительно гасит следующий click в capture-фазе.
* Причина: backdrop модалки закрывается на mousedown (Modal.jsx),
* модалка успевает размонтироваться до браузерного click, и click
* разрешается на элементе под курсором — обычно это чип задачи в
* календаре или карточка проекта. Без блокировки закрытие карточки
* сотрудника открывало бы родительскую сущность.
*
* Блокировка одноразовая (once: true) и подстрахована setTimeout(…, 0):
* если click после закрытия не придёт (закрытие по Escape или по
* крестику), слушатель снимется сам в ближайшем макротаске, до того
* как пользователь успеет кликнуть снова.
*
* Не знает о правах: справочные данные и так видны всем через
* аватары. db для модалки собирает сам из срезов departments / kbs.
*/
export default function EmployeeCardLink({ employee, size = 'sm', className = '' }) {
const [open, setOpen] = useState(false);
const departments = useSelector(s => s.departments);
const kbs = useSelector(s => s.kbs);
const db = useMemo(() => ({ departments, kbs }), [departments, kbs]);
const handleClick = useCallback((e) => {
e.stopPropagation();
if (!employee) return;
setOpen(true);
}, [employee]);
const handleClose = useCallback(() => {
const blockClick = (e) => {
e.stopPropagation();
e.preventDefault();
};
document.addEventListener('click', blockClick, { capture: true, once: true });
setTimeout(() => {
document.removeEventListener('click', blockClick, true);
}, 0);
setOpen(false);
}, []);
if (!employee) return null;
return (
<>
<span className="cursor-pointer" onClick={handleClick}>
<Avatar employee={employee} size={size} className={className} />
</span>
{open && (
<EmployeeCardModal
employee={employee}
db={db}
onClose={handleClose}
/>
)}
</>
);
}
+83
View File
@@ -0,0 +1,83 @@
// src/components/EmployeeFormFields.jsx
import { FormField } from './FormField';
export const EmployeeFormFields = ({
values,
onChange,
errors,
touched,
showPassword = false, // для Edit: поле нового пароля
passwordRequired = false, // для Create: поле пароля обязательно
disabled = false,
}) => {
return (
<div className="project-info-fields">
<FormField
label="Фамилия"
required
value={values.last}
onChange={(v) => onChange('last', v)}
error={touched.last && errors.last}
disabled={disabled}
/>
<FormField
label="Имя"
required
value={values.first}
onChange={(v) => onChange('first', v)}
error={touched.first && errors.first}
disabled={disabled}
/>
<FormField
label="E-mail"
required
value={values.email}
onChange={(v) => onChange('email', v)}
error={touched.email && errors.email}
disabled={disabled}
/>
{passwordRequired && (
<FormField
label="Пароль"
required
type="password"
value={values.pass}
onChange={(v) => onChange('pass', v)}
error={touched.pass && errors.pass}
disabled={disabled}
/>
)}
{showPassword && (
<FormField
label="Новый пароль"
type="password"
value={values.newPass || ''}
onChange={(v) => onChange('newPass', v)}
error={touched.newPass && errors.newPass}
disabled={disabled}
/>
)}
<FormField
label="Должность (основная)"
value={values.position}
onChange={(v) => onChange('position', v)}
disabled={disabled}
/>
<FormField
label="Внутренний номер"
value={values.extension}
onChange={(v) => onChange('extension', v)}
disabled={disabled}
/>
<FormField
label="Табельный №"
value={values.tab}
onChange={(v) => onChange('tab', v)}
disabled={disabled}
/>
</div>
);
};
+28
View File
@@ -0,0 +1,28 @@
import React from 'react';
export default class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('Error caught by ErrorBoundary:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="p-5 text-center" style={{ fontFamily: 'sans-serif' }}>
<h2>Что-то пошло не так</h2>
<p className="text-mut">Попробуйте перезагрузить страницу или сообщите разработчику.</p>
<button onClick={() => window.location.reload()} className="p-2 mt-3 cursor-pointer">
Перезагрузить
</button>
</div>
);
}
return this.props.children;
}
}
+586
View File
@@ -0,0 +1,586 @@
// src/components/FileManager.jsx
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Ic, ICONS } from './Icons';
import { SearchBox } from './SearchBox';
import { fmtDMY, fmtDT } from '../utils/date';
import { groupByDocument } from '../utils/fileVersions';
import {
getChildren,
buildBreadcrumb,
validateFolderName,
getFolderPathString,
flattenLatestDocuments,
} from '../utils/fileTree';
import { DIALOGS, TOASTS, FILE_ROOT_LABEL } from '../utils/constants';
import { useConfirm } from '../context/ConfirmContext';
import { useToast } from '../context/ToastContext';
import { copyToClipboard } from '../utils/clipboard';
import { fileShareUrl, folderShareUrl } from '../utils/fileLinks';
import FloatingMenu from './FloatingMenu';
const formatSize = (size) => {
if (size < 1024) return size + ' Б';
if (size < 1048576) return (size / 1024).toFixed(1) + ' КБ';
return (size / 1048576).toFixed(1) + ' МБ';
};
const COPY_TOASTS = Object.freeze({
ok: 'Ссылка скопирована',
fail: 'Не удалось скопировать ссылку',
});
const Breadcrumb = ({ items, onNavigate }) => (
<div className="file-breadcrumb">
<button type="button" className="link" onClick={() => onNavigate(null)}>
{FILE_ROOT_LABEL}
</button>
{' '}
{items.map(folder => (
<span key={folder.id} className="file-breadcrumb-item">
<span className="file-breadcrumb-sep">/</span>
{' '}
<button type="button" className="link" onClick={() => onNavigate(folder.id)}>
{folder.name}
</button>
{' '}
</span>
))}
</div>
);
const FilePathButton = ({ folders, folderId, onNavigate }) => {
const label = getFolderPathString(folders, folderId);
return (
<button
type="button"
className="file-path file-path-button"
onClick={() => onNavigate(folderId || null)}
title={`Перейти в папку: ${label}`}
>
{label}
</button>
);
};
const FolderRow = ({ folder, canDelete, onOpen, onDelete, onCopyLink, highlighted }) => {
// Меню вместо одинокой иконки удаления: у папки теперь два действия -
// «скопировать ссылку» (доступно всем) и «удалить» (под правом).
// FloatingMenu - тот же компонент, что в DocumentRow; единый вид
// контекстных действий по вложениям.
const menuItems = [
{
id: 'copy',
label: 'Скопировать ссылку',
icon: ICONS.link,
onClick: onCopyLink,
},
canDelete && { type: 'divider' },
canDelete && {
id: 'del',
label: 'Удалить папку',
icon: ICONS.trash,
danger: true,
onClick: onDelete,
},
].filter(Boolean);
const cls = `file-item file-folder clickable-row${highlighted ? ' file-folder--shared' : ''}`;
return (
<div className={cls} onClick={onOpen}>
<Ic d={ICONS.folder} size={24} />
<div className="file-info">
<div className="file-name">{folder.name}</div>
<div className="file-meta">Папка</div>
</div>
{/*
stopPropagation на обёртке: клик по кнопке меню не должен
проваливаться в onOpen строки (открытие папки). Тот же приём,
что у кнопки «Удалить» в старой версии FolderRow.
*/}
<span onClick={(e) => e.stopPropagation()}>
<FloatingMenu items={menuItems}>
{({ buttonProps }) => (
<button
{...buttonProps}
className="icon-btn"
title="Действия"
aria-label={`Действия с папкой ${folder.name}`}
>
<Ic d={ICONS.more} size={15} />
</button>
)}
</FloatingMenu>
</span>
</div>
);
};
const DocumentRow = ({ doc, onDelete, canDelete, employeeName, onCopyLink, highlighted }) => {
const [expanded, setExpanded] = useState(false);
const { latest, versions } = doc;
const hasHistory = versions.length > 1;
// Порядок пунктов меню: сначала «поделиться» (безопасное действие),
// потом действия над версиями, потом удаление. Разделители только
// там, где они разделяют разные категории - чтобы не было двух
// подряд идущих разделителей при hasHistory && !canDelete.
const menuItems = [];
menuItems.push({
id: 'copy',
label: 'Скопировать ссылку',
icon: ICONS.link,
onClick: () => onCopyLink(latest.id),
});
if (hasHistory) {
menuItems.push({ type: 'divider' });
menuItems.push({
id: 'history',
label: expanded ? 'Скрыть историю' : `История версий (${versions.length})`,
icon: ICONS.archive,
onClick: () => setExpanded(v => !v),
});
}
if (canDelete) {
menuItems.push({ type: 'divider' });
menuItems.push({
id: 'del',
label: 'Удалить последнюю версию',
icon: ICONS.trash,
danger: true,
onClick: () => onDelete(latest.id),
});
}
const wrapperCls = `file-document${highlighted ? ' file-document--shared' : ''}`;
return (
<div className={wrapperCls}>
<div className="file-item">
<Ic d={ICONS.file} size={24} />
<div className="file-info">
<div className="file-name">
{latest.name}
{hasHistory && <span className="file-version-badge">v{latest.version}</span>}
</div>
<div className="file-meta">
{formatSize(latest.size)} · загрузил {employeeName(latest.uploadedBy)}
{latest.uploadedAt && ` ${fmtDMY(latest.uploadedAt)}`}
</div>
</div>
<a
href={latest.url}
download={latest.name}
target="_blank"
rel="noopener noreferrer"
className="btn ghost sm"
>
Скачать
</a>
{menuItems.length > 0 && (
<FloatingMenu items={menuItems}>
{({ buttonProps }) => (
<button
{...buttonProps}
className="icon-btn"
title="Действия"
aria-label={`Действия с файлом ${latest.name}`}
>
<Ic d={ICONS.more} size={15} />
</button>
)}
</FloatingMenu>
)}
</div>
{expanded && hasHistory && (
<div className="file-versions">
{versions.map((v, i) => (
<div key={v.id} className={`file-version-row${i === 0 ? ' current' : ''}`}>
<span className="file-version-tag">v{v.version}</span>
<span className="file-version-date">{v.uploadedAt ? fmtDT(v.uploadedAt) : '-'}</span>
<span className="file-version-author">{employeeName(v.uploadedBy)}</span>
<span className="file-version-size">{formatSize(v.size)}</span>
<a
href={v.url}
download={v.name}
target="_blank"
rel="noopener noreferrer"
className="link"
>
Скачать
</a>
{canDelete && (
<button
type="button"
className="icon-btn danger xs"
onClick={() => onDelete(v.id)}
title={`Удалить версию v${v.version}`}
>
<Ic d={ICONS.trash} size={12} />
</button>
)}
</div>
))}
</div>
)}
</div>
);
};
const AllFilesList = ({
folders,
documents,
canDelete,
onDelete,
employeeName,
onNavigateToFolder,
onCopyLink,
highlightFileId = null,
emptyMessage = 'Файлов нет',
}) => {
if (documents.length === 0) {
return <div className="mut sm">{emptyMessage}</div>;
}
return (
<div className="file-all-list">
{documents.map(f => {
const highlighted = highlightFileId && f.id === highlightFileId;
const cls = `file-item${highlighted ? ' file-item--shared' : ''}`;
return (
<div key={f.id} className={cls}>
<Ic d={ICONS.file} size={24} />
<div className="file-info">
<div className="file-name">
{f.name}
{f.version > 1 && <span className="file-version-badge">v{f.version}</span>}
</div>
<div className="file-meta">
<FilePathButton
folders={folders}
folderId={f.folderId}
onNavigate={onNavigateToFolder}
/>
{' · '}
{formatSize(f.size)} · загрузил {employeeName(f.uploadedBy)}
</div>
</div>
<a
href={f.url}
download={f.name}
target="_blank"
rel="noopener noreferrer"
className="btn ghost sm"
>
Скачать
</a>
<button
type="button"
className="icon-btn"
onClick={() => onCopyLink(f.id)}
title="Скопировать ссылку"
aria-label={`Скопировать ссылку на файл ${f.name}`}
>
<Ic d={ICONS.link} size={15} />
</button>
{canDelete && (
<button
type="button"
className="icon-btn danger"
onClick={() => onDelete(f.id)}
title="Удалить"
>
<Ic d={ICONS.trash} size={14} />
</button>
)}
</div>
);
})}
</div>
);
};
export const FileManager = ({
files = [],
folders = [],
highlightFileId = null,
highlightFolderId = null,
onUpload,
onDelete,
onCreateFolder,
onDeleteFolder,
canUpload = true,
canDelete = true,
employeeName = (id) => id,
}) => {
const { prompt, confirm } = useConfirm();
const { showToast } = useToast();
const [view, setView] = useState({
folderId: null,
showAll: false,
searchQuery: '',
});
const [history, setHistory] = useState([]);
const navigate = useCallback((patch) => {
setHistory(prev => [...prev, view]);
setView(prev => ({ ...prev, ...patch }));
}, [view]);
const goBack = useCallback(() => {
if (history.length === 0) return;
setView(history[history.length - 1]);
setHistory(prev => prev.slice(0, -1));
}, [history]);
const setSearchQuery = useCallback((q) => {
setView(prev => ({ ...prev, searchQuery: q }));
}, []);
const breadcrumb = useMemo(
() => buildBreadcrumb(folders, view.folderId),
[folders, view.folderId]
);
const { subfolders, subfiles } = useMemo(
() => getChildren(folders, files, view.folderId),
[folders, files, view.folderId]
);
const documents = useMemo(() => groupByDocument(subfiles), [subfiles]);
const allDocuments = useMemo(
() => flattenLatestDocuments(folders, files),
[folders, files]
);
const normalizedQuery = view.searchQuery.trim().toLowerCase();
const isSearching = normalizedQuery.length > 0;
const searchResults = useMemo(() => {
if (!isSearching) return [];
return allDocuments.filter(f =>
f.name.toLowerCase().includes(normalizedQuery)
);
}, [allDocuments, isSearching, normalizedQuery]);
const navigateToFolder = useCallback((folderId) => {
navigate({ folderId, showAll: false, searchQuery: '' });
}, [navigate]);
/**
* Авто-навигация к подсвеченной папке.
*
* Открываем родителя папки, а не саму папку: пользователь должен
* видеть узел в контексте (его соседей и крошки), чтобы понимать,
* куда он попал. Открыть содержимое папки сразу - тоже вариант,
* но тогда «где я» приходится восстанавливать из крошек, а строка
* самой папки нигде не видна. Для файловой ссылки аналог - тот же
* приём: открываем папку-владельца и подсвечиваем файл.
*
* Ref-защёлка: эффект идемпотентен, но deps (folders, files) могут
* пересоздаваться - без защёлки каждое обновление вложений
* возвращало бы пользователя к подсвеченному узлу.
*/
const highlightFolderRef = useRef(null);
useEffect(() => {
if (!highlightFolderId) return;
if (highlightFolderRef.current === highlightFolderId) return;
const folder = folders.find(f => f.id === highlightFolderId);
if (!folder) return;
highlightFolderRef.current = highlightFolderId;
setHistory([]);
setView({
folderId: folder.parentId ?? null,
showAll: false,
searchQuery: '',
});
}, [highlightFolderId, folders]);
const highlightFileRef = useRef(null);
useEffect(() => {
if (!highlightFileId) return;
if (highlightFileRef.current === highlightFileId) return;
const file = files.find(f => f.id === highlightFileId);
if (!file) return;
highlightFileRef.current = highlightFileId;
setHistory([]);
setView({
folderId: file.folderId ?? null,
showAll: false,
searchQuery: '',
});
}, [highlightFileId, files]);
const handleCopyFileLink = useCallback(async (fileId) => {
const ok = await copyToClipboard(fileShareUrl(fileId));
showToast(ok ? COPY_TOASTS.ok : COPY_TOASTS.fail, ok ? 'success' : 'warning');
}, [showToast]);
const handleCopyFolderLink = useCallback(async (folderId) => {
const ok = await copyToClipboard(folderShareUrl(folderId));
showToast(ok ? COPY_TOASTS.ok : COPY_TOASTS.fail, ok ? 'success' : 'warning');
}, [showToast]);
const handleCreateFolder = async () => {
const name = await prompt(DIALOGS.createFolder);
if (!name) return;
const error = validateFolderName(name, subfolders, subfiles);
if (error) {
showToast(error, 'error');
return;
}
onCreateFolder(name.trim(), view.folderId);
showToast(TOASTS.folderCreated(name.trim()), 'success');
};
const handleDeleteFolder = async (folder) => {
const inside = getChildren(folders, files, folder.id);
if (inside.subfolders.length > 0 || inside.subfiles.length > 0) {
showToast(TOASTS.folderNotEmpty, 'warning');
return;
}
const ok = await confirm(DIALOGS.deleteFolder(folder.name));
if (!ok) return;
onDeleteFolder(folder.id);
showToast(TOASTS.folderDeleted, 'success');
};
const handleFileChange = (e) => {
const picked = Array.from(e.target.files || []);
if (picked.length > 0) onUpload(picked, view.folderId);
e.target.value = '';
};
const isEmpty = subfolders.length === 0 && documents.length === 0;
const hasAnyFiles = files.length > 0;
const canGoBack = history.length > 0;
const viewMode = isSearching ? 'search' : view.showAll ? 'all' : 'folder';
return (
<div className="tm-block">
<div className="file-toolbar">
<div className="file-toolbar-row">
{canGoBack && (
<button
type="button"
className="btn ghost sm"
onClick={goBack}
title="Вернуться к предыдущему виду"
>
<Ic d={ICONS.left} size={14} /> Назад
</button>
)}
<SearchBox
value={view.searchQuery}
onChange={setSearchQuery}
placeholder="Поиск файла…"
className="file-search-box"
/>
{viewMode === 'folder' && hasAnyFiles && (
<button
type="button"
className="btn ghost sm"
onClick={() => navigate({ showAll: true })}
>
<Ic d={ICONS.list} size={13} /> Все файлы
</button>
)}
{viewMode !== 'search' && canUpload && (
<>
<button type="button" className="btn ghost sm" onClick={handleCreateFolder}>
<Ic d={ICONS.folder} size={13} /> Создать папку
</button>
<input
type="file"
id="file-upload-input"
className="file-input-hidden"
onChange={handleFileChange}
multiple
/>
<label htmlFor="file-upload-input" className="btn primary sm">
<Ic d={ICONS.file} size={14} /> Загрузить файлы
</label>
</>
)}
</div>
<Breadcrumb
items={breadcrumb}
onNavigate={navigateToFolder}
/>
</div>
{viewMode === 'search' && (
<AllFilesList
folders={folders}
documents={searchResults}
canDelete={canDelete}
onDelete={onDelete}
employeeName={employeeName}
onNavigateToFolder={navigateToFolder}
onCopyLink={handleCopyFileLink}
highlightFileId={highlightFileId}
emptyMessage="Ничего не найдено"
/>
)}
{viewMode === 'all' && (
<AllFilesList
folders={folders}
documents={allDocuments}
canDelete={canDelete}
onDelete={onDelete}
employeeName={employeeName}
onNavigateToFolder={navigateToFolder}
onCopyLink={handleCopyFileLink}
highlightFileId={highlightFileId}
/>
)}
{viewMode === 'folder' && (
<>
{isEmpty && <div className="mut sm">Папка пуста</div>}
{subfolders.map(folder => (
<FolderRow
key={folder.id}
folder={folder}
canDelete={canDelete}
highlighted={folder.id === highlightFolderId}
onOpen={() => navigateToFolder(folder.id)}
onDelete={() => handleDeleteFolder(folder)}
onCopyLink={() => handleCopyFolderLink(folder.id)}
/>
))}
{documents.map(doc => {
// Подсветка срабатывает и если ссылка ведёт на не-последнюю
// версию: пользователь ждёт «вот этот файл», а не «вот эта
// строка документа». Проверяем все версии, не только latest.
const highlighted = highlightFileId
&& doc.versions.some(v => v.id === highlightFileId);
return (
<DocumentRow
key={doc.name}
doc={doc}
onDelete={onDelete}
canDelete={canDelete}
employeeName={employeeName}
highlighted={highlighted}
onCopyLink={handleCopyFileLink}
/>
);
})}
</>
)}
</div>
);
};
+234
View File
@@ -0,0 +1,234 @@
// src/components/FloatingMenu.jsx
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Ic } from './Icons';
import { useNestedModalEscape } from './Templates/useNestedModalEscape';
const EDGE = 8; // минимальный отступ от края вьюпорта
const OFFSET = 4; // зазор между триггером и меню
/**
* Плавающее меню действий.
*
* API - render-prop. Потребитель получает два набора пропсов и раскладывает
* их по своим узлам:
*
* anchorProps - на контейнер, по которому правый клик открывает меню
* (обычно это вся карточка). Содержит onContextMenu.
* buttonProps - на кнопку «⋯» в углу. Содержит ref (для позиционирования
* под кнопкой), onClick с stopPropagation (чтобы клик по
* кнопке не открывал карточку), onMouseDown с
* stopPropagation (чтобы draggable-родитель в канбане не
* начал перетаскивание), onContextMenu для правого клика
* именно по кнопке, aria-haspopup / aria-expanded.
*
* Пункты меню - массив узлов одного из трёх видов:
* { id, label, icon?, hint?, danger?, disabled?, onClick? }
* { type: 'divider' }
* { type: 'header', label }
*
* Каждый экземпляр FloatingMenu - самодостаточный: держит своё состояние,
* свой портал, свою позицию. Поэтому 50 карточек в канбане = 50 маленьких
* изолированных состояний, а не одно общее на весь список.
*
* Закрытие: клик вне, Escape (через общий стек useNestedModalEscape),
* scroll любой вложенности, resize.
*
* Стабильность колбэков: close / openFromAnchor / openAt / toggle
* мемоизированы. Это не косметика - close попадает в useNestedModalEscape
* и в useEffect-слушатели портала. Без мемоизации каждый ре-рендер
* родителя (например, канбана при обновлении задач) пересоздавал бы
* обработчики: токен Escape снимался/ставился бы в стек, слушатель
* mousedown - снимался/ставился на document. В окне между cleanup и
* setup событие может «проскочить». memo убирает это окно.
*/
export default function FloatingMenu({ items, placement = 'bottom-end', children }) {
const anchorRef = useRef(null);
const menuRef = useRef(null);
const [open, setOpen] = useState(false);
const [origin, setOrigin] = useState(null);
// Меню монтируется скрытым, useLayoutEffect измеряет его и ставит в
// правильную точку - до первого пейнта. Мигания в (0,0) не видно.
const [style, setStyle] = useState({ left: 0, top: 0, visibility: 'hidden' });
const close = useCallback(() => {
setOpen(false);
setOrigin(null);
}, []);
const openFromAnchor = useCallback(() => {
setOrigin({ type: 'anchor' });
setOpen(true);
}, []);
const openAt = useCallback((x, y) => {
setOrigin({ type: 'cursor', x, y });
setOpen(true);
}, []);
const toggle = useCallback(() => {
if (open) close();
else openFromAnchor();
}, [open, close, openFromAnchor]);
useLayoutEffect(() => {
if (!open || !origin) return;
const menu = menuRef.current;
if (!menu) return;
const mw = menu.offsetWidth;
const mh = menu.offsetHeight;
const vw = window.innerWidth;
const vh = window.innerHeight;
let left;
let top;
if (origin.type === 'cursor') {
left = origin.x;
top = origin.y;
} else {
const r = anchorRef.current?.getBoundingClientRect();
if (!r) {
// Теоретически недостижимо: anchorRef проставляется через
// buttonProps.ref, и если кнопка отрендерена - rect есть.
// Но безопаснее показать меню в углу, чем оставить его невидимым.
setStyle({ left: EDGE, top: EDGE, visibility: 'visible' });
return;
}
left = placement.endsWith('end') ? r.right - mw : r.left;
top = placement.startsWith('top') ? r.top - mh - OFFSET : r.bottom + OFFSET;
}
if (left + mw > vw - EDGE) left = vw - mw - EDGE;
if (left < EDGE) left = EDGE;
if (top + mh > vh - EDGE) top = vh - mh - EDGE;
if (top < EDGE) top = EDGE;
setStyle({ left, top, visibility: 'visible' });
}, [open, origin, placement]);
// anchorProps не зависит от open - стабилен. buttonProps зависит от open
// через aria-expanded и toggle, поэтому пересобирается при смене open.
const anchorProps = useMemo(() => ({
onContextMenu: (e) => {
e.preventDefault();
openAt(e.clientX, e.clientY);
},
}), [openAt]);
const buttonProps = useMemo(() => ({
ref: anchorRef,
onClick: (e) => {
e.stopPropagation();
toggle();
},
onMouseDown: (e) => e.stopPropagation(),
onContextMenu: (e) => {
e.preventDefault();
e.stopPropagation();
openAt(e.clientX, e.clientY);
},
'aria-haspopup': 'menu',
'aria-expanded': open,
}), [toggle, open, openAt]);
return (
<>
{children({ anchorProps, buttonProps })}
{open && (
<FloatingMenuPortal
menuRef={menuRef}
style={style}
items={items}
onClose={close}
/>
)}
</>
);
}
/**
* Портал меню - отдельный компонент, чтобы useNestedModalEscape
* регистрировался ровно на время, пока меню открыто. Хук кладёт токен
* в общий стек Escape при монтировании; если бы он жил в FloatingMenu,
* он занимал бы стек постоянно - и Escape при закрытом меню «съедался»
* бы впустую.
*/
function FloatingMenuPortal({ menuRef, style, items, onClose }) {
// Escape закрывает ровно верхний слой: если под меню лежит TaskModal,
// закроется только меню. Порядок стека = порядок монтирования.
useNestedModalEscape(onClose);
useEffect(() => {
const onDown = (e) => {
if (menuRef.current?.contains(e.target)) return;
onClose();
};
// Скролл и resize закрывают: позиция fixed, и без этого меню
// «оторвётся» от триггера и будет висеть в пустоте.
const onScrollOrResize = () => onClose();
document.addEventListener('mousedown', onDown);
document.addEventListener('scroll', onScrollOrResize, true);
window.addEventListener('resize', onScrollOrResize);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('scroll', onScrollOrResize, true);
window.removeEventListener('resize', onScrollOrResize);
};
}, [onClose, menuRef]);
// Фокус в первое активное действие - клавиатурная навигация Tab
// начинает работать сразу, а не после «пустого» Tab.
useEffect(() => {
const first = menuRef.current?.querySelector('.fm-item:not(:disabled)');
first?.focus();
}, [menuRef]);
return createPortal(
<div
ref={menuRef}
className="floating-menu"
style={style}
role="menu"
// mousedown гасит outside-close до клика по пункту, click не даёт
// событию всплыть по React-дереву к onClick карточки (порталы
// сохраняют bubbling в дереве компонентов).
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
>
{items.map((item, i) => renderMenuItem(item, i, onClose))}
</div>,
document.body,
);
}
function renderMenuItem(item, i, onClose) {
if (item.type === 'divider') {
return <div key={`d-${i}`} className="fm-divider" role="separator" />;
}
if (item.type === 'header') {
return <div key={`h-${i}`} className="fm-header">{item.label}</div>;
}
return (
<button
key={item.id ?? i}
type="button"
role="menuitem"
className={`fm-item${item.danger ? ' danger' : ''}`}
disabled={item.disabled}
onClick={() => {
// Сначала закрываем: React 18 батчит setOpen и действие пункта,
// поэтому модалка, открытая из onClick, уже не увидит меню поверх.
onClose();
item.onClick?.();
}}
>
{item.icon && <span className="fm-icon"><Ic d={item.icon} size={14} /></span>}
<span className="fm-label">{item.label}</span>
{item.hint && <span className="fm-hint">{item.hint}</span>}
</button>
);
}
+115
View File
@@ -0,0 +1,115 @@
// src/components/FormField.jsx
import { useId } from "react";
import { Select } from "./Select";
/**
* Подписанное поле формы. Одна обёртка для input / textarea / Select,
* чтобы во всех модалках label был связан с полем через htmlFor/id
* и красная звёздочка обязательности рисовалась одним и тем же кодом.
*
* inline={true} — label слева от инпута (стандарт для полей, у которых
* лейбл короткий: даты, числа, селекты). Разметка —
* .field-row с фиксированной шириной лейбла.
* inline={false} — label сверху (по умолчанию; для длинных форм,
* где лейбл не влезает в 100px).
*
* below — необязательный узел, который рендерится под инпутом в той же
* колонке, что и сам инпут (внутри .flex-1 для inline). Нужен для
* случаев, когда с полем связан уточняющий контрол — например чекбокс
* «до отмены» под датой окончания. Держать это здесь, а не собирать
* вручную рядом с FormField, — чтобы не разворачивать разметку поля
* (label + input + error + required-star) в родительских компонентах.
*
* placement — для type="select" прокидывается в Select как сторона
* открытия попапа ('auto' | 'top' | 'bottom'). По умолчанию 'auto' —
* Select сам решает, куда открыться, по свободному месту. Если
* значение не задано (undefined), проп не передаётся — Select
* применит свой дефолт.
*/
export const FormField = ({
label,
type = "text",
value,
onChange,
disabled,
options,
required,
inline = false,
error,
multiple = false,
below = null,
placement,
...props
}) => {
const id = `field-${useId()}`;
const handleChange = (e) => onChange(e.target.value);
const inputElement = (() => {
if (type === "select") {
return (
<Select
id={id}
value={multiple ? value ?? [] : value ?? ""}
onChange={onChange}
options={options}
disabled={disabled}
multiple={multiple}
placement={placement}
/>
);
}
if (type === "textarea") {
return (
<textarea
className="inp"
id={id}
rows={props.rows || 2}
value={value ?? ""}
onChange={handleChange}
disabled={disabled}
/>
);
}
return (
<input
className="inp"
type={type}
id={id}
value={value ?? ""}
onChange={handleChange}
disabled={disabled}
{...props}
/>
);
})();
if (inline) {
return (
<div className="field-row">
<label className="field-label" htmlFor={id}>
{label}
{required && <span className="required-star">*</span>}
</label>
<div className="flex-1">
{inputElement}
{error && <div className="text-red text-sm mt-1">{error}</div>}
{below}
</div>
</div>
);
}
return (
<>
<label className="lbl" htmlFor={id}>
{label}
{required && <span className="required-star">*</span>}
</label>
{inputElement}
{error && <div className="text-red text-sm">{error}</div>}
{below}
</>
);
};
export default FormField;
+725
View File
@@ -0,0 +1,725 @@
// src/components/Gantt.jsx
import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react';
import { Ic, ICONS } from './Icons';
import { Select } from './Select';
import { SearchBox } from './SearchBox';
import EmployeeCardLink from './EmployeeCardLink';
import { useFilters } from '../hooks/useFilters';
import { useScopeDb } from '../hooks/useDb';
import { useDataHelpers } from '../hooks/useDataHelpers';
import { computeScope, taskVisible } from '../utils/permissions';
import { isTaskActive } from '../utils/entityState';
import { TASK_STATUSES, PRIORITIES } from '../utils/constants';
import { getProjectColor } from '../utils/projectHelpers';
import { fmtD, fmtDMY, iso, parseISO, TODAY } from '../utils/date';
import { optionsFromList } from '../utils/selectOptions';
import { taskWord } from '../utils/pluralize';
import { formatPeriodLabel } from '../utils/periodLabel';
const isMilestoneTask = (task) => task.isMilestone === true;
const MILESTONE_SIZE = 14;
const UNKNOWN_PROJECT_ID = '__unknown__';
const UNKNOWN_PROJECT = { id: UNKNOWN_PROJECT_ID, code: '—', name: 'Без проекта' };
const normalizeAnchor = (dateStr, modeArg) => {
const d = parseISO(dateStr);
if (modeArg === 'quarter') return iso(new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1));
if (modeArg === 'year') return iso(new Date(d.getFullYear(), 0, 1));
return iso(new Date(d.getFullYear(), d.getMonth(), 1));
};
const buildTaskTree = (tasks) => {
const map = {};
const roots = [];
tasks.forEach(t => { map[t.id] = { ...t, children: [] }; });
tasks.forEach(t => {
const parentId = t.parentTaskId ? String(t.parentTaskId) : null;
if (parentId && map[parentId]) map[parentId].children.push(map[t.id]);
else roots.push(map[t.id]);
});
const sortChildren = (node) => {
node.children.sort((a, b) => (a.start || '').localeCompare(b.start || ''));
node.children.forEach(sortChildren);
};
roots.forEach(sortChildren);
return roots;
};
const flattenTree = (nodes, level = 0, acc = []) => {
nodes.forEach(node => {
acc.push({ ...node, level, hasChildren: node.children.length > 0 });
flattenTree(node.children, level + 1, acc);
});
return acc;
};
const computeTaskIndices = (task, dayIndexByIso, daysLength, viewStart, viewEnd) => {
const normalizeDate = (s) => (s ? s.slice(0, 10) : '');
// Для вехи важна только дата start.
if (isMilestoneTask(task)) {
const sRaw = dayIndexByIso.get(normalizeDate(task.start));
if (sRaw === undefined) return null;
return { sIdx: sRaw, eIdx: sRaw };
}
const sRaw = dayIndexByIso.get(normalizeDate(task.start));
const eRaw = dayIndexByIso.get(normalizeDate(task.deadline));
let sIdx = sRaw === undefined ? -1 : sRaw;
let eIdx = eRaw === undefined ? -1 : eRaw;
if (sIdx === -1 && eIdx === -1) {
if (task.start < viewStart && task.deadline > viewEnd) { sIdx = 0; eIdx = daysLength - 1; }
else return null;
}
if (sIdx === -1 && eIdx !== -1) {
const diffDays = Math.round((parseISO(task.deadline) - parseISO(task.start)) / 86400000);
sIdx = eIdx - diffDays >= 0 ? eIdx - diffDays : 0;
}
if (eIdx === -1 && sIdx !== -1) {
const diffDays = Math.round((parseISO(task.deadline) - parseISO(task.start)) / 86400000);
eIdx = sIdx + diffDays < daysLength ? sIdx + diffDays : daysLength - 1;
}
if (sIdx === -1 || eIdx === -1 || sIdx > eIdx) return null;
return { sIdx, eIdx };
};
const computeBarGeometry = (task, indices, DW) => {
const { sIdx, eIdx } = indices;
if (isMilestoneTask(task)) {
const cellCenter = sIdx * DW + DW / 2;
const left = cellCenter - MILESTONE_SIZE / 2;
return { left, width: MILESTONE_SIZE };
}
const left = sIdx * DW + 2;
const width = Math.max((eIdx - sIdx + 1) * DW - 4, DW - 8);
return { left, width };
};
const isOverdue = (task) =>
task.deadline && task.deadline < TODAY &&
task.status !== 'closed' && task.status !== 'cancelled';
const buildRowOrder = (tasks, expandedTasks) => {
const tree = buildTaskTree(tasks);
const nodeById = new Map();
const walk = (n) => { nodeById.set(n.id, n); n.children.forEach(walk); };
tree.forEach(walk);
const successorsOf = new Map();
for (const root of tree) {
if (!root.dependencyId) continue;
const pred = nodeById.get(root.dependencyId);
if (!pred || pred.parentTaskId) continue;
const list = successorsOf.get(pred.id) || [];
list.push(root);
successorsOf.set(pred.id, list);
}
for (const list of successorsOf.values()) {
list.sort((a, b) => (a.start || '').localeCompare(b.start || '') || a.id.localeCompare(b.id));
}
const order = [];
const visited = new Set();
const traverse = (node, level) => {
if (visited.has(node.id)) return; // защита от циклов
visited.add(node.id);
order.push({ id: node.id, level, hasChildren: node.children.length > 0, node });
const successors = successorsOf.get(node.id) || [];
for (const s of successors) traverse(s, level);
if (expandedTasks.has(node.id)) node.children.forEach(child => traverse(child, level + 1));
};
tree.forEach(root => traverse(root, 0));
return order;
};
const TaskRow = memo(function TaskRow({
task, level, hasChildren, expanded, onToggle,
indices, DW,
employee, project, openTask, getTaskSpent, vacOverlap,
}) {
const geo = computeBarGeometry(task, indices, DW);
const milestone = isMilestoneTask(task);
const overdue = isOverdue(task);
const sp = getTaskSpent(task);
const pct = Math.min(100, (sp / Math.max(1, task.plannedHours || 0)) * 100);
const fillWidth = pct > 0 ? Math.max(pct, 2) : 0;
const vac = employee ? vacOverlap(employee.id, task.start, task.deadline) : null;
const priorityColor = PRIORITIES[task.priority]?.color || '#64748b';
const statusColor = task.status === 'closed' ? '#10b981' : task.status === 'cancelled' ? '#94a3b8' : priorityColor;
const bgColor = priorityColor + '33';
const tooltipLines = [
`${milestone ? '◆ Веха: ' : ''}${task.title}`,
`Проект: ${project?.code || '-'}`,
`Статус: ${TASK_STATUSES[task.status]?.label || task.status}`,
`Приоритет: ${PRIORITIES[task.priority]?.label || task.priority}`,
`План: ${task.plannedHours ?? '-'} ч, Факт: ${sp} ч`,
milestone ? `Дата: ${fmtD(task.start)}` : `Срок: ${fmtD(task.start)} - ${fmtD(task.deadline)}`,
...(employee ? [`Исполнитель: ${employee.last} ${employee.first}`] : []),
...(vac ? [`⚠️ В отпуске ${fmtDMY(vac.start)}–${fmtDMY(vac.end)}`] : []),
...(overdue ? ['🔴 Просрочено'] : []),
].join('\n');
const handleToggleClick = useCallback((e) => { e.stopPropagation(); onToggle(task.id); }, [onToggle, task.id]);
const handleOpen = useCallback(() => { openTask(task.id); }, [openTask, task.id]);
const handleKeyOpen = useCallback((e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleOpen(); }
}, [handleOpen]);
const rowClass = `gantt-row${level > 0 ? ' gantt-row-child' : ''}`;
return (
<div className={rowClass}>
<div
className="gantt-label"
role="button"
tabIndex={0}
onClick={handleOpen}
onKeyDown={handleKeyOpen}
style={{ '--indent-level': (level * 20) + 'px' }}
title={tooltipLines}
>
<div className="flex items-center gap-1">
{hasChildren && (
<button
type="button"
className={`gantt-expand-btn${expanded ? ' expanded' : ''}`}
onClick={handleToggleClick}
aria-label={expanded ? 'Свернуть' : 'Развернуть'}
title={expanded ? 'Свернуть' : 'Развернуть'}
>▶</button>
)}
{milestone && <span className="gantt-milestone-marker" title="Веха" aria-hidden="true">◆</span>}
<span className={`gtitle${task.status === 'cancelled' ? ' dim' : ''}`}>{task.title}</span>
{overdue && <span className="gantt-overdue-dot" title="Просрочено" aria-label="Просрочено" />}
</div>
<span className="gsub">
{employee && <EmployeeCardLink employee={employee} size="xs" />}
{' · '}
{milestone
? `веха · ${fmtD(task.start)}`
: `${task.plannedHours ?? '-'} ч · ${TASK_STATUSES[task.status]?.label || task.status}`}
</span>
</div>
<div className="gantt-track">
{milestone ? (
<div
className={`gantt-milestone${task.status === 'cancelled' ? ' cancelled' : ''}`}
role="button"
tabIndex={0}
style={{ '--ms-left': geo.left + 'px', '--ms-color': statusColor }}
onClick={handleOpen}
onKeyDown={handleKeyOpen}
title={tooltipLines}
/>
) : (
<div
className={`gbar${overdue ? ' overdue' : ''}`}
role="button"
tabIndex={0}
style={{
'--bar-left': geo.left + 'px',
'--bar-width': geo.width + 'px',
'--bar-bg': bgColor,
'--bar-opacity': task.status === 'cancelled' ? 0.45 : 1,
'--fill-width': fillWidth + '%',
'--fill-color': task.status === 'closed' ? '#10b981' : priorityColor,
}}
onClick={handleOpen}
onKeyDown={handleKeyOpen}
title={tooltipLines}
>
<div className="gbar-fill" />
{vac && <span className="gbar-vac" aria-hidden="true">🏖</span>}
</div>
)}
</div>
</div>
);
});
const ProjectGroup = memo(function ProjectGroup({
project, order, daysLength, dayIndexByIso, DW, viewStart, viewEnd,
employeesById, projectsById, openTask, openProject, getTaskSpent,
vacOverlap, expandedTasks, onToggleTask,
}) {
const projectColor = getProjectColor(project);
// Отсеиваем невидимые строки до подсчёта и рендера.
const visibleRows = useMemo(() => {
const result = [];
for (const { id, level, hasChildren, node } of order) {
const indices = computeTaskIndices(node, dayIndexByIso, daysLength, viewStart, viewEnd);
if (!indices) continue;
result.push({ id, level, hasChildren, node, indices });
}
return result;
}, [order, dayIndexByIso, daysLength, viewStart, viewEnd]);
if (visibleRows.length === 0) return null;
const canOpenProject = !!openProject && project.id !== UNKNOWN_PROJECT_ID;
const handleOpenProject = () => { if (canOpenProject) openProject(project.id); };
const handleKeyOpenProject = (e) => {
if (!canOpenProject) return;
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleOpenProject(); }
};
return (
<div>
<div className="gantt-group">
<div
className="gantt-group-name cursor-pointer underline"
role={canOpenProject ? 'button' : undefined}
tabIndex={canOpenProject ? 0 : undefined}
onClick={canOpenProject ? handleOpenProject : undefined}
onKeyDown={canOpenProject ? handleKeyOpenProject : undefined}
title={canOpenProject ? 'Открыть проект' : project.name}
>
<span className="pdot" style={{ background: projectColor }} />
{project.code} · {project.name}
<span className="mut sm ml-2">({visibleRows.length} {taskWord(visibleRows.length)})</span>
</div>
</div>
{visibleRows.map(({ id, level, hasChildren, node, indices }) => {
const employee = node.assigneeId ? employeesById.get(node.assigneeId) : null;
const taskProject = projectsById.get(node.projectId) || project;
return (
<TaskRow
key={id}
task={node}
level={level}
hasChildren={hasChildren}
expanded={expandedTasks.has(id)}
onToggle={onToggleTask}
indices={indices}
DW={DW}
employee={employee}
project={taskProject}
openTask={openTask}
getTaskSpent={getTaskSpent}
vacOverlap={vacOverlap}
/>
);
})}
</div>
);
});
const INITIAL_FILTERS = Object.freeze({
projectId: 'all', assigneeId: 'all', status: 'all', query: '',
});
function Gantt({ ur, openTask, openProject, store }) {
const db = useScopeDb();
const { tasks, projects, employees } = db;
const { getTaskSpent, vacOverlap } = useDataHelpers(db);
const scope = useMemo(() => computeScope(ur, db), [ur, db]);
const { filters, setFilter } = useFilters(INITIAL_FILTERS);
const { projectId, assigneeId, status, query } = filters;
const employeesById = useMemo(() => new Map(employees.map(e => [e.id, e])), [employees]);
const projectsById = useMemo(() => new Map(projects.map(p => [p.id, p])), [projects]);
const [zoomLevel, setZoomLevel] = useState(1);
const DW = Math.round(34 * zoomLevel);
const [expandedTasks, setExpandedTasks] = useState(() => new Set());
const [mode, setMode] = useState('month');
const [anchor, setAnchor] = useState(() => {
const now = new Date();
return iso(new Date(now.getFullYear(), now.getMonth(), 1));
});
const [cornerWidth, setCornerWidth] = useState(500);
const [isResizing, setIsResizing] = useState(false);
const scrollRef = useRef(null);
const pendingScrollRef = useRef(false);
const statusOptions = useMemo(
() => Object.entries(TASK_STATUSES).filter(([key]) => key !== 'closed' && key !== 'cancelled' && key !== 'draft'),
[],
);
// Сброс фильтра статуса, если он не входит в допустимый список.
useEffect(() => {
const allowed = new Set(['all', ...statusOptions.map(([k]) => k)]);
if (!allowed.has(status)) setFilter('status', 'all');
}, [status, statusOptions, setFilter]);
// Ре-нормализация anchor при смене режима.
useEffect(() => {
setAnchor(prev => normalizeAnchor(prev, mode));
}, [mode]);
const onToggleTask = useCallback((id) => {
setExpandedTasks(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const handleResizerMouseDown = useCallback((e) => {
e.preventDefault(); e.stopPropagation();
setIsResizing(true);
const startX = e.clientX;
const startWidth = cornerWidth;
let rafId = null;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
const handleMouseMove = (moveEvent) => {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const diff = moveEvent.clientX - startX;
setCornerWidth(Math.max(150, Math.min(500, startWidth + diff)));
rafId = null;
});
};
const handleMouseUp = () => {
setIsResizing(false);
document.body.style.cursor = '';
document.body.style.userSelect = '';
if (rafId) cancelAnimationFrame(rafId);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}, [cornerWidth]);
const getDaysInRange = useCallback((anchorDate, modeArg) => {
const start = parseISO(anchorDate);
let end;
if (modeArg === 'quarter') {
end = new Date(start.getFullYear(), start.getMonth() + 3, 1);
} else if (modeArg === 'year') {
end = new Date(start.getFullYear() + 1, 0, 1);
} else {
end = new Date(start.getFullYear(), start.getMonth() + 1, 1);
}
const days = [];
for (const d = new Date(start); d < end; d.setDate(d.getDate() + 1)) {
days.push(iso(d));
}
return days;
}, []);
const shift = useCallback((dir) => {
const d = parseISO(anchor);
if (mode === 'month') d.setMonth(d.getMonth() + dir);
else if (mode === 'quarter') d.setMonth(d.getMonth() + dir * 3);
else d.setFullYear(d.getFullYear() + dir);
setAnchor(normalizeAnchor(iso(d), mode));
}, [anchor, mode]);
const baseTasks = useMemo(() => {
return tasks
.filter(t => {
if (!isTaskActive(t)) return false;
if (!taskVisible(ur, scope, t, db)) return false;
if (!t.start) return false;
if (!isMilestoneTask(t) && !t.deadline) return false;
return true;
})
.map(t => ({
...t,
start: t.start ? t.start.slice(0, 10) : null,
deadline: t.deadline ? t.deadline.slice(0, 10) : null,
}));
}, [tasks, ur, scope, db]);
const allTasks = useMemo(() => {
let list = baseTasks;
if (projectId !== 'all') list = list.filter(t => t.projectId === projectId);
if (assigneeId !== 'all') list = list.filter(t => t.assigneeId === assigneeId);
if (status !== 'all') list = list.filter(t => t.status === status);
if (query.trim()) {
const q = query.trim().toLowerCase();
list = list.filter(t => (t.title || '').toLowerCase().includes(q));
}
return list;
}, [baseTasks, projectId, assigneeId, status, query]);
// Очистка expandedTasks от исчезнувших задач.
useEffect(() => {
const valid = new Set(allTasks.map(t => t.id));
setExpandedTasks(prev => {
let changed = false;
const next = new Set();
for (const id of prev) {
if (valid.has(id)) next.add(id);
else changed = true;
}
return changed ? next : prev;
});
}, [allTasks]);
const projectOptions = useMemo(() => {
const ids = new Set(baseTasks.map(t => t.projectId).filter(Boolean));
return projects.filter(p => ids.has(p.id));
}, [baseTasks, projects]);
const tasksForAssignee = useMemo(() => {
let list = baseTasks;
if (projectId !== 'all') list = list.filter(t => t.projectId === projectId);
return list;
}, [baseTasks, projectId]);
const assigneeOptions = useMemo(() => {
const ids = new Set(tasksForAssignee.map(t => t.assigneeId).filter(Boolean));
return employees.filter(e => ids.has(e.id));
}, [tasksForAssignee, employees]);
const projectSelectOptions = useMemo(
() => optionsFromList(projectOptions, 'Все проекты', (p) => ({ value: p.id, label: p.code })),
[projectOptions],
);
const assigneeSelectOptions = useMemo(
() => optionsFromList(assigneeOptions, 'Все исполнители', (e) => ({
value: e.id, label: `${e.last} ${e.first}`,
})),
[assigneeOptions],
);
const statusSelectOptions = useMemo(
() => optionsFromList(statusOptions, 'Все статусы', ([key, val]) => ({
value: key, label: val.label,
})),
[statusOptions],
);
const projectGroups = useMemo(() => {
const groups = new Map();
allTasks.forEach(t => {
const key = t.projectId || UNKNOWN_PROJECT_ID;
let group = groups.get(key);
if (!group) {
const project = projectsById.get(t.projectId) || UNKNOWN_PROJECT;
group = { project, tasks: [] };
groups.set(key, group);
}
group.tasks.push(t);
});
return Array.from(groups.values()).map(g => ({
project: g.project,
tasks: g.tasks,
order: buildRowOrder(g.tasks, expandedTasks),
}));
}, [allTasks, projectsById, expandedTasks]);
const days = useMemo(() => getDaysInRange(anchor, mode), [anchor, mode, getDaysInRange]);
const daysLength = days.length;
const todayIdx = daysLength ? days.indexOf(TODAY) : -1;
const dayIndexByIso = useMemo(() => {
const m = new Map();
days.forEach((d, i) => m.set(d, i));
return m;
}, [days]);
const dayStatusByIso = useMemo(() => {
const map = new Map();
if (!store || typeof store.getYearCalendar !== 'function') return map;
const years = new Set();
for (const d of days) years.add(Number(d.slice(0, 4)));
for (const y of years) {
let months;
try { months = store.getYearCalendar(y); } catch { continue; }
if (!Array.isArray(months)) continue;
for (const m of months) {
if (!m || !Array.isArray(m.days)) continue;
for (const day of m.days) {
if (day && day.iso) map.set(day.iso, day.status);
}
}
}
return map;
}, [store, days]);
const months = useMemo(() => {
const result = [];
days.forEach((day, i) => {
const d = parseISO(day);
const lbl = `${['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'][d.getMonth()]} ${d.getFullYear()}`;
if (!result.length || result[result.length - 1].label !== lbl) result.push({ label: lbl, from: i, to: i });
else result[result.length - 1].to = i;
});
return result;
}, [days]);
const viewStart = days[0];
const viewEnd = days[daysLength - 1];
const width = daysLength * DW;
const totalWidth = width + cornerWidth;
const handleCollapseAll = useCallback(() => { setExpandedTasks(new Set()); }, []);
const handleExpandAll = useCallback(() => {
const allIds = [];
projectGroups.forEach(({ tasks: ts }) => {
flattenTree(buildTaskTree(ts)).forEach(n => {
if (n.hasChildren) allIds.push(n.id);
});
});
setExpandedTasks(new Set(allIds));
}, [projectGroups]);
const scrollToToday = useCallback(() => {
const idx = days.indexOf(TODAY);
if (idx !== -1) {
// Сегодня уже в диапазоне — просто скроллим.
const el = scrollRef.current;
if (!el) return;
const target = cornerWidth + idx * DW - el.clientWidth / 2 + DW / 2;
el.scrollTo({ left: Math.max(0, target), behavior: 'smooth' });
return;
}
// Сегодня вне диапазона (например, «Год» уехал в другой год) —
// сбрасываем anchor; скролл сделаем после ре-рендера.
pendingScrollRef.current = true;
const now = new Date();
setAnchor(normalizeAnchor(iso(now), mode));
}, [days, DW, cornerWidth, mode]);
useEffect(() => {
if (!pendingScrollRef.current) return;
pendingScrollRef.current = false;
const idx = days.indexOf(TODAY);
if (idx === -1) return;
const el = scrollRef.current;
if (!el) return;
const target = cornerWidth + idx * DW - el.clientWidth / 2 + DW / 2;
el.scrollTo({ left: Math.max(0, target), behavior: 'smooth' });
}, [days, DW, cornerWidth]);
const dayCellClass = (isoDate) => {
const st = dayStatusByIso.get(isoDate);
if (st === 'holiday') return 'gcell holiday';
if (st === 'shortday') return 'gcell shortday';
if (st === 'weekend') return 'gcell wk';
return 'gcell';
};
const dayHeadClass = (isoDate) => {
const st = dayStatusByIso.get(isoDate);
const base = 'gday';
const isToday = isoDate === TODAY ? ' td' : '';
if (st === 'holiday') return `${base} holiday${isToday}`;
if (st === 'shortday') return `${base} shortday${isToday}`;
if (st === 'weekend') return `${base} wk${isToday}`;
return `${base}${isToday}`;
};
return (
<div className="gantt-panel">
<div className="gantt-filter-bar">
<SearchBox value={query} onChange={(v) => setFilter('query', v)} placeholder="Поиск по названию…" className="gantt-search" />
<Select className="gantt-filter-select" value={projectId} onChange={(v) => setFilter('projectId', v)} options={projectSelectOptions} />
<Select className="gantt-filter-select" value={assigneeId} onChange={(v) => setFilter('assigneeId', v)} options={assigneeSelectOptions} />
<Select className="gantt-filter-select" value={status} onChange={(v) => setFilter('status', v)} options={statusSelectOptions} />
<div className="gantt-zoom">
<button type="button" className="icon-btn" aria-label="Уменьшить масштаб" onClick={() => setZoomLevel(Math.max(0.5, zoomLevel - 0.25))}>−</button>
<span className="zoom-value">{Math.round(zoomLevel * 100)}%</span>
<button type="button" className="icon-btn" aria-label="Увеличить масштаб" onClick={() => setZoomLevel(Math.min(2, zoomLevel + 0.25))}>+</button>
</div>
<button type="button" className="btn ghost sm" onClick={handleCollapseAll}>Свернуть всё</button>
<button type="button" className="btn ghost sm" onClick={handleExpandAll}>Развернуть всё</button>
</div>
<div className="cal-head p-3 border-b">
<div className="cal-nav">
<button type="button" className="icon-btn" aria-label="Предыдущий период" onClick={() => shift(-1)}><Ic d={ICONS.left} size={16} /></button>
<div className="cal-title">{formatPeriodLabel(mode, anchor, viewStart, viewEnd)}</div>
<button type="button" className="icon-btn" aria-label="Следующий период" onClick={() => shift(1)}><Ic d={ICONS.right} size={16} /></button>
<button
type="button"
className="btn ghost sm"
onClick={scrollToToday}
title="Перейти к текущей дате"
>Сегодня</button>
</div>
<div className="cal-right">
<div className="seg">
{[['month','Месяц'],['quarter','Квартал'],['year','Год']].map(([m, l]) => (
<button type="button" key={m} className={`seg-btn${mode === m ? ' on' : ''}`} onClick={() => setMode(m)}>{l}</button>
))}
</div>
</div>
</div>
<div className="gantt-scroll" ref={scrollRef}>
{projectGroups.length === 0 ? (
<div className="empty-note p-4">Нет доступных задач в выбранном периоде</div>
) : (
<div
className="gantt"
style={{
'--gantt-width': totalWidth + 'px',
'--corner-width': cornerWidth + 'px',
'--gantt-days-width': width + 'px',
}}
>
<div className="gantt-top">
<div className="gantt-corner">
Проект / задача
<div className={`gantt-resizer${isResizing ? ' dragging' : ''}`} onMouseDown={handleResizerMouseDown} />
</div>
<div className="gantt-axis">
<div className="gantt-months">
{months.map((m, i) => (
<div key={i} className="gantt-month" style={{ width: (m.to - m.from + 1) * DW }}>{m.label}</div>
))}
</div>
<div className="gantt-days">
{days.map(d => {
const dt = parseISO(d);
return <div key={d} className={dayHeadClass(d)} style={{ width: DW }}>{dt.getDate()}</div>;
})}
</div>
</div>
</div>
<div className="gantt-body" style={{ position: 'relative' }}>
<div className="gantt-grid" style={{ left: cornerWidth }}>
{days.map(d => <div key={d} className={dayCellClass(d)} style={{ width: DW }} />)}
{todayIdx >= 0 && <div className="gtoday" style={{ left: todayIdx * DW + DW / 2 }} />}
</div>
{projectGroups.map(({ project, order }) => (
<ProjectGroup
key={project.id}
project={project}
order={order}
daysLength={daysLength}
dayIndexByIso={dayIndexByIso}
DW={DW}
viewStart={viewStart}
viewEnd={viewEnd}
employeesById={employeesById}
projectsById={projectsById}
openTask={openTask}
openProject={openProject}
getTaskSpent={getTaskSpent}
vacOverlap={vacOverlap}
expandedTasks={expandedTasks}
onToggleTask={onToggleTask}
/>
))}
</div>
</div>
)}
</div>
<div className="gantt-legend p-2 border-t flex flex-wrap gap-4 items-center">
<span className="legend-item"><span className="legend-milestone">◆</span> веха</span>
<span className="legend-item">🏖 исполнитель в отпуске</span>
<span className="legend-item">Заполнение полосы — факт / план</span>
<span className="legend-item"><span className="legend-swatch legend-swatch--holiday" /> праздник</span>
<span className="legend-item"><span className="legend-swatch legend-swatch--shortday" /> сокращённый день</span>
</div>
</div>
);
}
const Gantt_default = memo(Gantt);
export default Gantt_default;
+23
View File
@@ -0,0 +1,23 @@
// src/components/HistoryTab.jsx
import { fmtDT } from '../utils/date';
/**
* Универсальная вкладка «История» - для задачи и проекта.
*
* Формат записи: { ts, who, text }. Порядок отображения - от свежих
* к старым (массив в сторе хранит хронологию, реверс делает рендер).
* Отдельный компонент, а не дублирование разметки в двух модалках:
* список из одного и того же класса tm-log с одним и тем же форматом
* полей; если появится третий вид карточки - подключится сюда же.
*/
export const HistoryTab = ({ history, empName }) => (
<div className="tm-logs tm-logs-hist">
{[...history].reverse().map((h, i) => (
<div key={i} className="tm-log">
<span className="tm-log-name">{h.who === 'system' ? 'Система' : empName(h.who)}</span>
<span className="mut sm">{fmtDT(h.ts)}</span>
<span className="tm-log-note">{h.text}</span>
</div>
))}
</div>
);
+51
View File
@@ -0,0 +1,51 @@
export const Ic = ({ d, size = 18, w = 1.8 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={w} strokeLinecap="round" strokeLinejoin="round"><path d={d} /></svg>
);
export const ICONS = {
kanban: "M4 4h4v16H4zM10 4h4v10h-4zM16 4h4v7h-4z",
gantt: "M3 6h8M9 12h10M5 18h7M3 4v16",
cal: "M4 6h16v14H4zM4 10h16M8 4v4M16 4v4",
folder: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z",
users: "M9 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7zM2.5 20a6.5 6.5 0 0 1 13 0M16 4.5a3.5 3.5 0 0 1 0 6.6M17.5 14a6.5 6.5 0 0 1 4 6",
chart: "M4 20V10M10 20V4M16 20v-8M22 20H2",
clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2",
plus: "M12 5v14M5 12h14",
out: "M9 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h3M16 8l4 4-4 4M20 12H9",
x: "M6 6l12 12M18 6L6 18",
check: "M20 6L9 17l-5-5",
left: "M15 6l-6 6 6 6",
right: "M9 6l6 6-6 6",
search: "M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.3-4.3",
trash: "M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v6M14 11v6",
edit: "M4 20l1-4L16.5 4.5a2.1 2.1 0 0 1 3 3L8 19zM13 6l5 5",
copy: "M8 8h12v12H8zM4 16V4h12v4",
shield: "M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6z",
download: "M12 4v11M7 11l5 5 5-5M4 20h16",
bell: "M18 9a6 6 0 1 0-12 0c0 5-2 6-2 6h16s-2-1-2-6M10 19a2 2 0 0 0 4 0",
beach: "M12 3v9M12 12l7 3M12 12L5 15M4 20c2-1.5 4-1.5 6 0 2-1.5 4-1.5 6 0",
user: "M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 21a8 8 0 0 1 16 0",
inbox: "M3 13l3-8h12l3 8v6H3zM3 13h5l1.5 2.5h5L16 13h5",
book: "M4 5a2 2 0 0 1 2-2h14v18H6a2 2 0 0 0-2 2zM8 3v18",
swap: "M7 8h13M17 5l3 3-3 3M17 16H4M7 13l-3 3 3 3",
chat: "M21 12a8 8 0 0 1-8 8H4l2.5-2.5A8 8 0 1 1 21 12z",
archive: "M3 4h18v4H3zM5 8v12h14V8M10 12h4",
restore: "M3 12a9 9 0 1 0 3-6.7M3 4v5h5",
eye: "M1 12s3-7 11-7 11 7 11 7-3 7-11 7-11-7-11-7zM12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z",
filter: "M4 6h16M6 12h14M8 18h12",
close: "M6 6l12 12M18 6L6 18",
lock: "M7 11V7a5 5 0 0 1 10 0v4h1v10H6V11h1zm2 0h6V7a3 3 0 0 0-6 0v4z",
file: "M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM13 2v7h7",
list: "M3 6h18M3 12h18M3 18h18",
tasks: "M3 6h12M3 12h16M3 18h14M7 6l2 2 4-4M7 12l2 2 4-4M7 18l2 2 4-4",
external: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3",
star: "M12 2l2.4 7.2h7.6l-6 4.8 2.4 7.2-6-4.8-6 4.8 2.4-7.2-6-4.8h7.6z",
pin: "M12 2a8 8 0 0 0-8 8c0 3.5 2.5 6.5 5.5 7.5L12 22l2.5-4.5A8 8 0 0 0 20 10a8 8 0 0 0-8-8zM12 6a4 4 0 1 0 0 8 4 4 0 0 0 0-8z",
bookmark: "M6 2h12v20l-6-4-6 4z",
up: "M6 15l6-6 6 6",
down: "M6 9l6 6 6-6",
more: "M5 12h.01M12 12h.01M19 12h.01",
link: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71",
sun: 'M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10zM12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42',
moon: 'M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z',
};
+368
View File
@@ -0,0 +1,368 @@
// src/components/Journal.jsx
import React, { useState, useMemo } from 'react';
import { fmtDT, fmtDMY } from '../utils/date';
import { useDataHelpers, useFilters } from '../hooks';
import { downloadCsv } from '../utils/csvExport';
import { SearchBox } from './SearchBox';
import { Select } from './Select';
import { optionsFromList } from '../utils/selectOptions';
const safeDate = (ts) => {
const d = new Date(ts);
return isNaN(d.getTime()) ? null : d;
};
const parseDayKey = (dayKey) => {
const parts = dayKey.split('.');
if (parts.length !== 3) return null;
const [day, month, year] = parts.map(Number);
if (!day || !month || !year) return null;
return new Date(year, month - 1, day);
};
// Полный список действий журнала. Уже в формате { value, label } -
// хелпер не нужен, просто передаём массив в Select как есть.
// Пункт «все действия» добавлен первым, это лейбл фильтра по умолчанию.
const ACTION_OPTIONS = [
{ value: 'all', label: 'Все действия' },
{ value: 'Создание задачи', label: 'Создание задачи' },
{ value: 'Изменение задачи', label: 'Изменение задачи' },
{ value: 'Удаление задачи', label: 'Удаление задачи' },
{ value: 'Изменение статуса задачи', label: 'Изменение статуса' },
{ value: 'Создание проекта', label: 'Создание проекта' },
{ value: 'Изменение проекта', label: 'Изменение проекта' },
{ value: 'Удаление проекта', label: 'Удаление проекта' },
{ value: 'Архивация проекта', label: 'Архивация проекта' },
{ value: 'Создание сотрудника', label: 'Создание сотрудника' },
{ value: 'Изменение ролей', label: 'Изменение ролей' },
{ value: 'Изменение подразделений', label: 'Изменение подразделений' },
{ value: 'Увольнение сотрудника', label: 'Увольнение сотрудника' },
{ value: 'Восстановление сотрудника', label: 'Восстановление сотрудника' },
{ value: 'Создание отпуска', label: 'Создание отпуска' },
{ value: 'Изменение отпуска', label: 'Изменение отпуска' },
{ value: 'Удаление отпуска', label: 'Удаление отпуска' },
{ value: 'Запрос изменения часов', label: 'Запрос изменения часов' },
{ value: 'Утверждение запроса часов', label: 'Утверждение запроса часов' },
{ value: 'Отклонение запроса часов', label: 'Отклонение запроса часов' },
{ value: 'Создание делегирования ролей', label: 'Делегирование ролей' },
{ value: 'Принятие делегирования', label: 'Принятие делегирования' },
{ value: 'Отклонение делегирования', label: 'Отклонение делегирования' },
{ value: 'Автоматическая архивация задач', label: 'Автоматическая архивация' },
{ value: 'Утверждение отпуска', label: 'Утверждение отпуска' },
{ value: 'Отклонение отпуска', label: 'Отклонение отпуска' },
{ value: 'Одобрение регистрации', label: 'Одобрение регистрации' },
{ value: 'Отклонение регистрации', label: 'Отклонение регистрации' },
];
const INITIAL_FILTERS = Object.freeze({
dateFrom: '',
dateTo: '',
userId: 'all',
action: 'all',
search: '',
});
const PAGE_SIZE = 20;
export default function Journal({ db }) {
const { empName } = useDataHelpers(db);
const { filters, setFilter } = useFilters(INITIAL_FILTERS);
const { dateFrom, dateTo, userId, action, search } = filters;
const [page, setPage] = useState(1);
const handleFilterChange = (key, value) => {
setFilter(key, value);
setPage(1);
};
const allEntries = useMemo(() => {
return [...(db.audit || [])].sort((a, b) => b.ts - a.ts);
}, [db.audit]);
const userOptions = useMemo(() => {
const userIds = new Set(allEntries.map(e => e.userId).filter(id => id !== 'system'));
const list = [...userIds].map(id => ({ id, name: empName(id) || id }));
return optionsFromList(list, 'Все', u => ({ value: u.id, label: u.name }));
}, [allEntries, empName]);
const filteredEntries = useMemo(() => {
return allEntries.filter(entry => {
const dateObj = safeDate(entry.ts);
if (!dateObj) return false;
const entryDate = fmtDMY(entry.ts);
if (dateFrom && entryDate < dateFrom) return false;
if (dateTo && entryDate > dateTo) return false;
if (userId !== 'all' && entry.userId !== userId) return false;
if (action !== 'all' && entry.action !== action) return false;
if (search.trim()) {
const q = search.trim().toLowerCase();
const match = (entry.action || '').toLowerCase().includes(q) ||
(entry.details || '').toLowerCase().includes(q);
if (!match) return false;
}
return true;
});
}, [allEntries, dateFrom, dateTo, userId, action, search]);
const groupedEntries = useMemo(() => {
const groups = {};
filteredEntries.forEach(entry => {
const d = safeDate(entry.ts);
if (!d) return;
const dayKey = fmtDMY(entry.ts);
const monthKey = `${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
if (!groups[monthKey]) groups[monthKey] = {};
if (!groups[monthKey][dayKey]) groups[monthKey][dayKey] = [];
groups[monthKey][dayKey].push(entry);
});
const sortedMonths = Object.keys(groups).sort((a, b) => {
const [m1, y1] = a.split('.').map(Number);
const [m2, y2] = b.split('.').map(Number);
return y2 - y1 || m2 - m1;
});
const result = [];
sortedMonths.forEach(month => {
const days = Object.keys(groups[month]).sort((a, b) => {
const [d1, m1, y1] = a.split('.').map(Number);
const [d2, m2, y2] = b.split('.').map(Number);
return y2 - y1 || m2 - m1 || d2 - d1;
});
days.forEach(day => {
result.push({ type: 'day', date: day, entries: groups[month][day] });
});
});
return result;
}, [filteredEntries]);
const flatEntries = useMemo(() => {
const result = [];
let lastMonth = null;
groupedEntries.forEach(group => {
const monthDate = parseDayKey(group.date);
const monthLabel = monthDate && !isNaN(monthDate.getTime())
? monthDate.toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' })
: 'Неизвестный месяц';
group.entries.forEach((entry, idx) => {
const isFirstInDay = idx === 0;
const dayObj = safeDate(entry.ts);
const dayLabel = isFirstInDay && dayObj
? dayObj.toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric' })
: null;
result.push({
...entry,
_monthLabel: lastMonth !== monthLabel ? monthLabel : null,
_dayLabel: dayLabel,
});
if (lastMonth !== monthLabel) {
lastMonth = monthLabel;
}
});
});
return result;
}, [groupedEntries]);
const paginatedFlat = useMemo(() => {
return flatEntries.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
}, [flatEntries, page]);
const totalEntries = filteredEntries.length;
const totalPages = Math.ceil(totalEntries / PAGE_SIZE);
const renderDetails = (entry) => {
if (!entry.details) return null;
try {
const obj = typeof entry.details === 'string' ? JSON.parse(entry.details) : entry.details;
if (typeof obj === 'object') {
return (
<div className="text-xs text-mut mt-1">
{Object.entries(obj).map(([key, val]) => (
<div key={key}><b>{key}:</b> {String(val)}</div>
))}
</div>
);
}
return <span className="mut sm">{String(entry.details)}</span>;
} catch {
return <span className="mut sm">{String(entry.details)}</span>;
}
};
const exportToCSV = () => {
const rows = [['Дата', 'Время', 'Пользователь', 'Действие', 'Детали']];
filteredEntries.forEach(e => {
const d = safeDate(e.ts);
const date = d ? fmtDMY(e.ts) : 'неизвестно';
const time = d ? d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) : '-';
const user = e.userId === 'system' ? 'Система' : (empName(e.userId) || e.userId);
rows.push([date, time, user, e.action, e.details || '']);
});
downloadCsv(`journal_${dateFrom || 'start'}_${dateTo || 'end'}`, rows);
};
return (
<div className="rep">
<div className="rep-panel p-4">
<div className="rep-panel-title">Фильтры журнала</div>
<div className="journal-filters">
<label className="lbl m-0 flex-none">С даты:</label>
<input
className="inp w-150 flex-none"
type="date"
value={dateFrom}
onChange={e => handleFilterChange('dateFrom', e.target.value)}
/>
<span className="flex-none">-</span>
<input
className="inp w-150 flex-none"
type="date"
value={dateTo}
onChange={e => handleFilterChange('dateTo', e.target.value)}
/>
<label className="lbl m-0 flex-none">Пользователь:</label>
<Select
className="w-180 flex-none"
value={userId}
onChange={v => handleFilterChange('userId', v)}
options={userOptions}
/>
<label className="lbl m-0 flex-none">Действие:</label>
<Select
className="w-200 flex-none"
value={action}
onChange={v => handleFilterChange('action', v)}
options={ACTION_OPTIONS}
/>
<SearchBox
value={search}
onChange={(v) => handleFilterChange('search', v)}
placeholder="Поиск по журналу"
className="journal-search"
/>
</div>
</div>
<div className="rep-panel p-4">
<div className="rep-panel-title flex justify-between items-center">
<span>
Всего записей: {filteredEntries.length}
<span className="mut sm font-normal ml-3">
{' '}
(показано {paginatedFlat.length})
</span>
</span>
<button className="btn primary sm" onClick={exportToCSV}>
Выгрузить CSV
</button>
</div>
<div className="mt-3">
{paginatedFlat.length === 0 ? (
<div className="mut text-center p-5">
<div className="text-center" style={{ fontSize: '48px', marginBottom: '12px', opacity: 0.3 }}>📋</div>
<div className="text-sm font-semibold mb-1">Записей не найдено</div>
<div className="mut sm">Измените параметры фильтра или выберите другой период</div>
</div>
) : (
<table className="tbl" style={{ fontSize: '13px' }}>
<thead>
<tr>
<th className="w-160 text-mut text-xs uppercase tracking-wider">Дата и время</th>
<th className="w-180 text-mut text-xs uppercase tracking-wider">Пользователь</th>
<th className="text-mut text-xs uppercase tracking-wider">Действие</th>
<th className="text-mut text-xs uppercase tracking-wider">Детали</th>
</tr>
</thead>
<tbody>
{paginatedFlat.map((entry) => {
const showMonth = entry._monthLabel !== null;
const showDay = entry._dayLabel !== null;
return (
<React.Fragment key={entry.id}>
{showMonth && (
<tr>
<td colSpan="4" className="p-2 border-t" style={{ background: 'linear-gradient(90deg, #f8fafc, transparent)' }}>
<div className="text-xs font-extrabold text-slate-800 uppercase tracking-wider">
{entry._monthLabel}
</div>
</td>
</tr>
)}
{showDay && (
<tr>
<td colSpan="4" className="p-1 border-t border-dashed">
<div className="text-xs font-bold text-acc uppercase tracking-wider">
{entry._dayLabel}
</div>
</td>
</tr>
)}
<tr className={showDay ? 'bg-blue-50' : ''} onMouseEnter={(e) => e.currentTarget.style.background = showDay ? '#f0f7ff' : '#f8fafc'} onMouseLeave={(e) => e.currentTarget.style.background = showDay ? '#fafbff' : 'transparent'}>
<td className="whitespace-nowrap text-xs text-mut font-mono">
{safeDate(entry.ts) ? fmtDT(entry.ts) : '-'}
</td>
<td>
<div className="flex items-center gap-2 font-semibold text-sm">
<div className="avatar xs" style={{ background: entry.userId === 'system' ? 'linear-gradient(135deg, #64748b, #475569)' : 'linear-gradient(135deg, #1e3a8a, #0ea5e9)', width: 24, height: 24, fontSize: 9 }}>
{entry.userId === 'system' ? 'S' : (empName(entry.userId) || entry.userId).charAt(0).toUpperCase()}
</div>
{entry.userId === 'system' ? 'Система' : empName(entry.userId) || entry.userId}
</div>
</td>
<td>
<div className="font-semibold text-sm text-slate-800">
{entry.action}
</div>
</td>
<td style={{ maxWidth: '400px' }}>
{renderDetails(entry)}
</td>
</tr>
</React.Fragment>
);
})}
</tbody>
</table>
)}
</div>
{totalPages > 1 && (
<div className="flex justify-center items-center gap-3 mt-5 pt-4 border-t">
<button
className="btn ghost sm"
disabled={page === 1}
onClick={() => setPage(p => Math.max(1, p - 1))}
style={{ minWidth: '36px', height: '36px', padding: 0, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
>
←
</button>
<span className="text-sm text-mut font-medium">
Страница <b className="text-txt">{page}</b> из <b className="text-txt">{totalPages}</b>
</span>
<button
className="btn ghost sm"
disabled={page === totalPages}
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
style={{ minWidth: '36px', height: '36px', padding: 0, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
>
→
</button>
</div>
)}
</div>
</div>
);
}
+165
View File
@@ -0,0 +1,165 @@
import React, { useState, useMemo, memo } from 'react';
import { Ic, ICONS } from './Icons';
import { SearchBox } from './SearchBox';
/**
* Одна колонка канбана.
*
* memo-компонент: самодостаточная единица перерисовки. Когда состояние
* `dragOverCol` в родителе меняется (курсор вошёл в другую колонку),
* родитель передаёт каждой колонке новый булев `isDragOver`. У колонок,
* которые не были затронуты, флаг остаётся тем же - их memo сравнивает
* пропсы и пропускает рендер. Перерисовываются ровно две колонки:
* та, где курсор был, и та, куда он вошёл.
*
* Раньше dragOverCol жил на уровне всего Kanban и на каждый hover
* перерисовывались все колонки и все карточки внутри них.
*/
const KanbanColumn = memo(function KanbanColumn({
status,
label,
color,
items,
renderCard,
onDrop,
onDragStateChange,
isDragOver,
emptyMessage,
}) {
const handleDragOver = (e) => {
e.preventDefault();
if (!isDragOver) onDragStateChange(status);
};
const handleDragLeave = () => {
if (isDragOver) onDragStateChange(null);
};
const handleDrop = (e) => {
e.preventDefault();
onDragStateChange(null);
const id = e.dataTransfer.getData('text/plain');
if (id) onDrop(id, status);
};
return (
<div
className={`kcol${isDragOver ? ' over' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="kcol-head">
<span className="kdot" style={{ background: color }} />
{label}
<span className="kcount">{items.length}</span>
</div>
<div className="kcol-body">
{items.length === 0 ? (
<div className="kempty">{emptyMessage}</div>
) : (
items.map((item) => (
<div
key={item.id}
className="kcard"
draggable
onDragStart={(e) => e.dataTransfer.setData('text/plain', item.id)}
>
{renderCard(item)}
</div>
))
)}
</div>
</div>
);
});
function Kanban({
items,
statusOrder,
statusMap,
renderCard,
onDrop,
onNew,
searchQuery,
onSearchChange,
showOnlyMy,
onToggleMy,
extraFilters,
emptyMessage = 'Нет элементов',
columns,
}) {
const [dragOverCol, setDragOverCol] = useState(null);
// Группировка по статусу - один раз на срез items/statusOrder.
// Каждая колонка получает стабильную по ссылке «свою» половину;
// повторные .filter по всем items убраны.
const byStatus = useMemo(() => {
const m = new Map();
statusOrder.forEach(st => m.set(st, []));
items.forEach(item => {
const list = m.get(item.status);
if (list) list.push(item);
});
return m;
}, [items, statusOrder]);
const kanbanClasses = ['kanban'];
const style = {};
if (columns) {
style['--columns'] = columns;
} else if (statusOrder.length === 5) {
kanbanClasses.push('k5');
}
return (
<div>
<div className="toolbar">
{onSearchChange && (
<SearchBox
value={searchQuery}
onChange={onSearchChange}
placeholder="Поиск…"
/>
)}
{extraFilters}
{onToggleMy && (
<label className="dept-pick ml-2">
<input
type="checkbox"
checked={showOnlyMy}
onChange={(e) => onToggleMy(e.target.checked)}
/>
<span className="text-sm">Только мои</span>
</label>
)}
{onNew && (
<button className="btn primary" onClick={onNew}>
<Ic d={ICONS.plus} size={15} /> Создать
</button>
)}
</div>
<div className={kanbanClasses.join(' ')} style={style}>
{statusOrder.map((st) => {
const meta = statusMap[st];
return (
<KanbanColumn
key={st}
status={st}
label={meta.label}
color={meta.color}
items={byStatus.get(st) || []}
renderCard={renderCard}
onDrop={onDrop}
onDragStateChange={setDragOverCol}
isDragOver={dragOverCol === st}
emptyMessage={emptyMessage}
/>
);
})}
</div>
</div>
);
}
export default memo(Kanban);
+78
View File
@@ -0,0 +1,78 @@
// src/components/Lightbox.jsx
import { useEffect } from 'react';
import { Ic, ICONS } from './Icons';
export const Lightbox = ({ photos, currentIndex, onClose, onPrev, onNext }) => {
useEffect(() => {
const handleKey = (e) => {
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowLeft') onPrev();
if (e.key === 'ArrowRight') onNext();
};
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [onClose, onPrev, onNext]);
if (currentIndex === null || currentIndex === undefined) return null;
const photo = photos[currentIndex];
if (!photo) return null;
const hasMultiple = photos.length > 1;
// Обработчики с остановкой всплытия, чтобы не закрывать лайтбокс
const handlePrev = (e) => {
e.stopPropagation();
onPrev();
};
const handleNext = (e) => {
e.stopPropagation();
onNext();
};
return (
<div className="lightbox-overlay" onClick={onClose}>
<div className="lightbox-content" onClick={(e) => e.stopPropagation()}>
<img src={photo.url} alt={photo.name || 'Фото'} className="lightbox-image" />
{hasMultiple && (
<>
<button className="lightbox-btn prev" onClick={handlePrev}>
<Ic d={ICONS.left} size={32} />
</button>
<button className="lightbox-btn next" onClick={handleNext}>
<Ic d={ICONS.right} size={32} />
</button>
</>
)}
<button className="lightbox-close" onClick={onClose}>
<Ic d={ICONS.x} size={24} />
</button>
<div className="lightbox-info">
<span className="lightbox-name">{photo.name}</span>
<span className="lightbox-counter">
{currentIndex + 1} / {photos.length}
</span>
</div>
{hasMultiple && (
<div className="lightbox-dots">
{photos.map((_, idx) => (
<span
key={idx}
className={`lightbox-dot ${idx === currentIndex ? 'active' : ''}`}
onClick={() => {
const diff = idx - currentIndex;
if (diff > 0) onNext();
else if (diff < 0) onPrev();
}}
/>
))}
</div>
)}
</div>
</div>
);
};
+505
View File
@@ -0,0 +1,505 @@
// src/components/LoginScreen.jsx
import { useEffect, useRef, useState } from 'react';
import { useSelector } from '../context/StoreContext';
import { ALLOWED_DOMAINS, DOMAIN } from '../utils/constants';
import { usePasswordReveal } from '../hooks/usePasswordReveal';
import { Ic, ICONS } from './Icons';
const EMAIL_PLACEHOLDER = `ivanov@${DOMAIN}`;
const validateEmailFormat = (email) => {
if (!email.trim()) return 'E-mail обязателен';
const lower = email.trim().toLowerCase();
if (
!lower.includes('@') ||
lower.startsWith('@') ||
lower.endsWith('.') ||
lower.includes('..')
) return 'Некорректный формат e-mail';
if (!ALLOWED_DOMAINS.some(domain => lower.endsWith(domain))) {
return 'Допускаются домены ' + ALLOWED_DOMAINS.join(', ');
}
return null;
};
const emailLocalPart = (email) => {
const e = String(email || '').toLowerCase().trim();
return e.includes('@') ? e.split('@')[0] : e;
};
const validateEmailNotTaken = (email, employees, regRequests) => {
const formatError = validateEmailFormat(email);
if (formatError) return formatError;
const local = emailLocalPart(email);
if (
employees.some(e => e.email && emailLocalPart(e.email) === local) ||
(regRequests || []).some(r => r.email && emailLocalPart(r.email) === local)
) return 'Такой e-mail уже зарегистрирован';
return null;
};
const validateEmailExists = (email, employees) => {
if (!email.trim()) return 'E-mail обязателен';
const local = emailLocalPart(email);
if (!employees.some(e => e.email && emailLocalPart(e.email) === local)) {
return 'E-mail не найден в системе';
}
return null;
};
const validateForgotEmail = (email, employees) => {
const formatError = validateEmailFormat(email);
if (formatError) return formatError;
return validateEmailExists(email, employees);
};
function passIssues(p) {
return [
{ ok: p.length >= 8, t: 'Минимум 8 символов' },
{ ok: /[A-ZА-ЯЁ]/.test(p), t: 'Заглавная буква' },
{ ok: /[a-zа-яё]/.test(p), t: 'Строчная буква' },
{ ok: /\d/.test(p), t: 'Цифра' },
{ ok: /[^A-Za-zА-Яа-яЁё0-9]/.test(p), t: 'Специальный символ' },
];
}
export default function LoginScreen({ registerEmployee, onLogin, toast }) {
const employees = useSelector(s => s.employees);
const regRequests = useSelector(s => s.regRequests);
// Таймеры: 450 мс - снять анимацию «тряски»; 30 мс - короткая задержка
// перед onLogin, чтобы кнопка успела перейти в disabled до синхронного
// setState выше. Оба снимаются при размонтировании: экран исчезает
// при успешном входе, а оживший таймер трогал бы state снятого
// компонента.
const flashTimerRef = useRef(null);
const loginTimerRef = useRef(null);
useEffect(() => () => {
clearTimeout(flashTimerRef.current);
clearTimeout(loginTimerRef.current);
}, []);
const [mode, setMode] = useState('login');
const [lg, setLg] = useState('');
const [pw, setPw] = useState('');
const { shown: showPassword, toggle: togglePasswordVisibility } = usePasswordReveal();
const [busy, setBusy] = useState(false);
const [err, setErr] = useState(null);
const [shake, setShake] = useState(false);
const [reg, setReg] = useState({ first: '', last: '', email: '', pass: '', pass2: '' });
const [emailError, setEmailError] = useState(null);
const [emailTouched, setEmailTouched] = useState(false);
const [forgotError, setForgotError] = useState(null);
const [forgotTouched, setForgotTouched] = useState(false);
const [forgot, setForgot] = useState('');
const { shown: showRegPass, toggle: toggleRegPassVisibility } = usePasswordReveal();
const [loginEmailError, setLoginEmailError] = useState(null);
const flash = () => {
setShake(true);
clearTimeout(flashTimerRef.current);
flashTimerRef.current = setTimeout(() => {
flashTimerRef.current = null;
setShake(false);
}, 450);
};
const rejectField = () => { flash(); };
const reject = (message) => { setErr(message); flash(); };
const doLogin = (email, pass) => {
const emailErr = validateEmailFormat(email);
if (emailErr) {
setLoginEmailError(emailErr);
rejectField();
return;
}
setBusy(true);
setErr(null);
clearTimeout(loginTimerRef.current);
loginTimerRef.current = setTimeout(() => {
loginTimerRef.current = null;
const r = onLogin(email, pass);
if (r) reject(r);
setBusy(false);
}, 30);
};
const switchMode = (newMode) => {
setMode(newMode);
setErr(null);
setEmailError(null);
setEmailTouched(false);
setForgotError(null);
setForgotTouched(false);
setLoginEmailError(null);
if (newMode === 'forgot') setForgot('');
};
const handleLoginEmailChange = (value) => {
setLg(value);
setErr(null);
if (loginEmailError) setLoginEmailError(null);
};
const handleLoginEmailBlur = () => {
if (!lg.trim()) { setLoginEmailError(null); return; }
setLoginEmailError(validateEmailFormat(lg));
};
const handleEmailChange = (value, setter) => {
setter(value);
if (emailTouched) setEmailError(validateEmailNotTaken(value, employees, regRequests));
};
const handleEmailBlur = (value) => {
setEmailTouched(true);
if (!value.trim()) { setEmailError(null); return; }
setEmailError(validateEmailNotTaken(value, employees, regRequests));
};
const handleForgotEmailChange = (value) => {
setForgot(value);
if (forgotTouched) setForgotError(validateForgotEmail(value, employees));
};
const handleForgotEmailBlur = () => {
setForgotTouched(true);
if (!forgot.trim()) { setForgotError(null); return; }
setForgotError(validateForgotEmail(forgot, employees));
};
const submit = (e) => {
if (e && e.preventDefault) e.preventDefault();
try {
if (mode === 'login') {
doLogin(lg, pw);
return;
}
if (mode === 'register') {
if (!reg.first.trim() || !reg.last.trim() || !reg.email.trim()) {
return reject('Заполните все обязательные поля');
}
const emailErr = validateEmailNotTaken(reg.email, employees, regRequests);
if (emailErr) {
setEmailTouched(true);
setEmailError(emailErr);
return rejectField();
}
if (passIssues(reg.pass).some(i => !i.ok)) {
return reject('Пароль не соответствует требованиям безопасности');
}
if (reg.pass !== reg.pass2) return reject('Пароли не совпадают');
try {
const email = reg.email.trim().toLowerCase();
registerEmployee({ first: reg.first, last: reg.last, email, pass: reg.pass });
if (toast) toast('Регистрация успешна! Выполняется вход…', 'success');
if (!onLogin(email, reg.pass)) {
reject('Ошибка автоматического входа после регистрации.');
}
setReg({ first: '', last: '', email: '', pass: '', pass2: '' });
setEmailError(null);
setEmailTouched(false);
setMode('login');
} catch (ex) {
reject(ex.message || 'Не удалось зарегистрироваться');
}
return;
}
if (mode === 'forgot') {
if (!forgot.trim()) {
setForgotTouched(true);
setForgotError('E-mail обязателен');
return rejectField();
}
const forgotErr = validateForgotEmail(forgot, employees);
if (forgotErr) {
setForgotTouched(true);
setForgotError(forgotErr);
return rejectField();
}
if (toast) {
toast(
'Ссылка для восстановления пароля отправлена на ' + forgot.trim() +
' (действует 1 час). Заглушка.',
'success',
);
}
switchMode('login');
}
} catch (ex) {
reject('Внутренняя ошибка: ' + (ex && ex.message ? ex.message : ex));
}
};
const issues = passIssues(reg.pass);
return (
<div className="login-wrap">
<div className="login-hero">
<div className="logo lg">
<div className="logo-mark">АГ</div>
<div>
<div className="logo-name">АвиаГоризонт</div>
<div className="logo-sub">планирование и учёт времени</div>
</div>
</div>
<h2>Единая среда планирования ИЦ</h2>
<p>
АвиаГоризонт — корпоративная система планирования и учёта рабочего
времени инженерного центра. Канбан, список, диаграмма Ганта
и календарь — четыре проекции одной базы задач. Производственные
проекты и административные проекты предприятия, задачи
с подзадачами любой глубины и зависимостями, учёт часов, шаблоны,
отчёты, архив и журнал аудита.
</p>
<ul className="hero-list">
<li>
<b>10 ролей</b>, делегирование полномочий, отпуска с автопереназначением
задач на замещающего
</li>
<li>
<b>Задачи с подзадачами</b> любой глубины, 4 типа зависимостей,
автосдвиг дат последователей, распределение загрузки по узлам дерева
</li>
<li>
<b>Учёт времени и загрузка:</b> часы по дням, контроль перерасхода,
диаграмма загрузки с учётом производственного календаря РФ
</li>
<li>
<b>Шаблоны задач и проектов</b> с деревом подзадач, личные и общие
</li>
<li>
<b>Обсуждения и файлы:</b> ветки ответов, @упоминания, реакции,
папки, версии документов, публичные ссылки
</li>
<li>
<b>Запросы и заявки:</b> изменение часов и сроков, согласование
отпусков, делегирование ролей, регистрация сотрудников
</li>
<li>
<b>Отчёты, архив, журнал аудита</b> с фильтрами и выгрузкой в CSV
</li>
<li>
<b>Диаграмма Ганта очень кривая пока</b>
</li>
<li>
<b>Демо-режим. Работает без БД. Новые и измененные данные после перезагрузки приходят к первоначльному виду. Все данные хранятся локально в браузере</b>
</li>
</ul>
<div className="hero-stack">
React · Vite · Node.js · PostgreSQL · Ubuntu LTS · ООП/KISS/DRY
</div>
</div>
<div className="login-panel">
<form className={'login-card' + (shake ? ' shake' : '')} onSubmit={submit}>
{mode !== 'register' && (
<>
<h3>{mode === 'forgot' ? 'Восстановление пароля' : 'Вход в систему'}</h3>
<div className="login-sub">
{mode === 'forgot'
? 'Ссылка будет отправлена на зарегистрированный e-mail'
: `Введите рабочий e-mail целиком, например ${EMAIL_PLACEHOLDER}`}
</div>
{mode === 'forgot' ? (
<>
<label className="lbl">E-mail</label>
<input
className="inp"
type="email"
autoComplete="username"
value={forgot}
onChange={e => handleForgotEmailChange(e.target.value)}
onBlur={handleForgotEmailBlur}
placeholder={EMAIL_PLACEHOLDER}
autoFocus
/>
{forgotTouched && forgotError && (
<div className="login-err">{forgotError}</div>
)}
</>
) : (
<>
<label className="lbl">E-mail</label>
<input
className="inp"
type="email"
autoComplete="username"
value={lg}
onChange={e => handleLoginEmailChange(e.target.value)}
onBlur={handleLoginEmailBlur}
placeholder={EMAIL_PLACEHOLDER}
autoFocus
/>
{loginEmailError && <div className="login-err">{loginEmailError}</div>}
<label className="lbl">Пароль</label>
<div className="relative">
<input
className="inp"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
value={pw}
onChange={e => { setPw(e.target.value); setErr(null); }}
placeholder="с учётом регистра"
/>
<button
type="button"
onClick={togglePasswordVisibility}
className="pass-toggle-btn"
>
<Ic d={ICONS.eye} size={18} />
</button>
</div>
</>
)}
</>
)}
{mode === 'register' && (
<>
<h3>Регистрация сотрудника</h3>
<div className="login-sub">
После регистрации вы автоматически войдёте с ролью «Исполнитель».
Суперадминистратор получит уведомление.
</div>
<div className="reg-row">
<div>
<label className="lbl">Имя<span className="required-star">*</span></label>
<input
className="inp"
value={reg.first}
onChange={e => setReg({ ...reg, first: e.target.value })}
/>
</div>
<div>
<label className="lbl">Фамилия<span className="required-star">*</span></label>
<input
className="inp"
value={reg.last}
onChange={e => setReg({ ...reg, last: e.target.value })}
/>
</div>
</div>
<label className="lbl">E-mail<span className="required-star">*</span></label>
<input
className="inp"
type="email"
autoComplete="email"
value={reg.email}
onChange={e =>
handleEmailChange(e.target.value, (v) =>
setReg(prev => ({ ...prev, email: v })))}
onBlur={() => handleEmailBlur(reg.email)}
placeholder={EMAIL_PLACEHOLDER}
/>
{emailTouched && emailError && <div className="login-err">{emailError}</div>}
<label className="lbl">Пароль<span className="required-star">*</span></label>
<div className="relative">
<input
className="inp"
type={showRegPass ? 'text' : 'password'}
autoComplete="new-password"
value={reg.pass}
onChange={e => setReg({ ...reg, pass: e.target.value })}
/>
<button
type="button"
onClick={toggleRegPassVisibility}
className="pass-toggle-btn"
>
<Ic d={ICONS.eye} size={18} />
</button>
</div>
<div className="pass-checks">
{issues.map(i => (
<span key={i.t} className={i.ok ? 'ok' : ''}>✓ {i.t}</span>
))}
</div>
<label className="lbl">Подтверждение пароля<span className="required-star">*</span></label>
<input
className="inp"
type="password"
autoComplete="new-password"
value={reg.pass2}
onChange={e => setReg({ ...reg, pass2: e.target.value })}
/>
</>
)}
{err && <div className="login-err">{err}</div>}
<button className="btn primary big" type="submit" disabled={busy}>
{busy ? 'Выполняется вход…'
: mode === 'login' ? 'Войти'
: mode === 'register' ? 'Зарегистрироваться'
: 'Отправить ссылку'}
</button>
{mode === 'login' && (
<>
<div className="login-links">
<button type="button" className="link" onClick={() => switchMode('forgot')}>
Забыли пароль?
</button>
<span className="link-sep">|</span>
<button type="button" className="link" onClick={() => switchMode('register')}>
Регистрация
</button>
</div>
<div className="demo-title">Демо-доступы - клик сразу выполняет вход</div>
<div className="demo-grid">
{[
{ l: 'sergey.adminov@hor.ru', p: 'Admin2026!', t: 'Суперадминистратор' },
{ l: 'aleksey.gendirov@hor.ru', p: 'Director2026!', t: 'Генеральный директор' },
{ l: 'ivan.konstruktorov@hor.ru',p: 'KbLa2026!', t: 'Гл. конструктор КБ «ЛА»' },
{ l: 'mikhail.dvigatelev@hor.ru',p: 'KbAd2026!', t: 'Гл. конструктор КБ «АД»' },
{ l: 'erik.ekonomistov@hor.ru', p: 'Econ2026!', t: 'Главный экономист' },
{ l: 'maria.buhgalterova@hor.ru',p: 'Buh2026!', t: 'Бухгалтер' },
{ l: 'olga.personalova@hor.ru', p: 'Hr2026!', t: 'HR-менеджер' },
{ l: 'mikhail.otdelov@hor.ru', p: 'Head2026!', t: 'Руководитель отделов' },
{ l: 'nikolay.managerov@hor.ru', p: 'Pm2026!', t: 'Менеджер проектов' },
{ l: 'kirill.proektov@hor.ru', p: 'Pm2026!', t: 'Ответственный по проекту' },
{ l: 'isaev@hor.ru', p: 'Exec2026!', t: 'Исполнитель' },
].map(d => (
<button
key={d.l}
type="button"
className="demo-chip"
disabled={busy}
onClick={() => {
setLg(d.l);
setPw(d.p);
setErr(null);
setLoginEmailError(null);
doLogin(d.l, d.p);
}}
>
<span className="demo-login">{d.l}</span>
<span className="demo-role">{d.t}</span>
</button>
))}
</div>
</>
)}
{mode !== 'login' && (
<div className="login-links">
<button type="button" className="link" onClick={() => switchMode('login')}>
← Назад ко входу
</button>
</div>
)}
</form>
</div>
</div>
);
}
+384
View File
@@ -0,0 +1,384 @@
// src/components/MainLayout.jsx
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useStore, useSelector } from '../context/StoreContext';
import { useToast } from '../context/ToastContext';
import { useModals } from '../hooks/useModals';
import { useHashRoute } from '../hooks/useHashRoute';
import ModalRenderer from './ModalRenderer';
import NotifBell from './NotifBell';
import { Ic, ICONS } from './Icons';
import { SubstitutionBadge } from './SubstitutionBadge';
import TasksView from './views/TasksView';
import DraftsView from './views/DraftsView';
import Gantt from './Gantt';
import Calendar from './Calendar';
import ProjectsView from './views/ProjectsView';
import TemplatesView from './views/TemplatesView';
import CabinetView from './views/CabinetView';
import StaffView from './views/StaffView';
import ReportsView from './views/ReportsView';
import ArchiveView from './views/ArchiveView';
import { RequestsView } from './views/RequestsView';
import JournalView from './views/JournalView';
import {
hasRole, canExport, canAccessView,
computeScope, taskVisible, projectVisible,
} from '../utils/permissions';
import { ROUTE, VIEWS, DEFAULT_TAB, buildRoute } from '../utils/routes';
import { findFileById, findFolderById } from '../utils/fileLinks';
import { ThemeToggle } from './ThemeToggle';
const REQUESTS_TAB_BY_NOTIF_TYPE = Object.freeze({
delegation: 'rd',
registration: 'reg',
assignmentApproval: 'assignments',
});
// NEW (7.1–7.3): период автоматической синхронизации замещений.
// Раз в час — компромисс между точностью активации в день start и
// отсутствием «тикающего» каждую минуту таймера. Публичный метод
// DataStore.syncSubstitutions гоняет syncAll целиком: onExpire +
// onActivate + пересборка кэша.
const SYNC_SUBSTITUTIONS_INTERVAL_MS = 60 * 60 * 1000;
function MainLayout({ store, user }) {
const { logout } = useStore();
const { showToast } = useToast();
const tasks = useSelector(s => s.tasks);
const projects = useSelector(s => s.projects);
const employees = useSelector(s => s.employees);
const departments = useSelector(s => s.departments);
const assignmentApprovals = useSelector(s => s.assignmentApprovals || []);
const [view, setView] = useState('tasks');
const [requestsTab, setRequestsTab] = useState('hours');
const {
modal, openTask, openProject, openProjectAccess, openChangeReq,
openRoles, openDepts, openVacation, openDelegation, openVacNow,
openYearCalendar, openEmployeeTasks, openCopyTask, openCopyProject,
openTemplateFromTask, openTemplateFromProject, closeModal, setModalTab,
openAtFile, openAtFolder,
} = useModals({ store, user });
const { route, navigate } = useHashRoute();
const dbForScope = useMemo(() => ({
tasks, projects, employees, departments,
}), [tasks, projects, employees, departments]);
const scope = useMemo(() => computeScope(user, dbForScope), [user, dbForScope]);
// NEW (7.1–7.3): периодическая синхронизация замещений.
// Таймер живёт только при залогиненной сессии: MainLayout рендерится
// исключительно под user (см. AppContent). Размонтирование — logout
// или закрытие вкладки — снимает интервал через cleanup.
//
// Стартовый syncAll уже сделан в конструкторе DataStore; здесь —
// последующие тики, чтобы уже открытая сессия ловила наступление
// start без перезагрузки страницы.
//
// store — синглтон, ссылка стабильна, пересоздания интервала не будет.
useEffect(() => {
const id = setInterval(
() => store.syncSubstitutions(),
SYNC_SUBSTITUTIONS_INTERVAL_MS,
);
return () => clearInterval(id);
}, [store]);
const navItems = useMemo(() => [
{ id: 'tasks', label: 'Задачи', icon: ICONS.tasks },
{ id: 'drafts', label: 'Черновики', icon: ICONS.edit },
{ id: 'projects', label: 'Проекты', icon: ICONS.folder },
{ id: 'calendar', label: 'Календарь', icon: ICONS.cal },
{ id: 'gantt', label: 'Диаграмма Ганта', icon: ICONS.gantt },
{ id: 'templates', label: 'Шаблоны', icon: ICONS.bookmark },
{ id: 'staff', label: 'Персонал', icon: ICONS.users },
...(canExport(user) || hasRole(user, 'kb_chief', 'head', 'project_lead', 'hr')
? [{ id: 'reports', label: 'Отчёты', icon: ICONS.chart }] : []),
{ id: 'archive', label: 'Архив', icon: ICONS.archive },
{ id: 'requests', label: 'Запросы и заявки', icon: ICONS.inbox },
...(hasRole(user, 'admin', 'director')
? [{ id: 'journal', label: 'Журнал аудита', icon: ICONS.book }] : []),
], [user]);
const handleNotificationNavigate = useCallback((notification) => {
const { targetType, targetId, targetTab } = notification;
if (!targetType || !targetId) return;
store.markNotificationRead(notification.id);
if (targetType === 'changeRequest') {
setRequestsTab(targetTab || 'hours');
setView('requests');
return;
}
const requestsTabForType = REQUESTS_TAB_BY_NOTIF_TYPE[targetType];
if (requestsTabForType) {
setRequestsTab(requestsTabForType);
setView('requests');
return;
}
switch (targetType) {
case 'task': openTask(targetId, targetTab || 'form'); break;
case 'project': openProject(targetId, targetTab || 'info'); break;
case 'vacation': openVacation(targetId); break;
}
}, [store, openTask, openProject, openVacation]);
const handleNavClick = useCallback((id) => {
if (id === 'requests') {
const hasPendingForMe = assignmentApprovals.some(
a => a.status === 'pending' && a.approverId === user.id,
);
setRequestsTab(hasPendingForMe ? 'assignments' : 'hours');
}
navigate(`#/view/${id}`);
}, [navigate, assignmentApprovals, user.id]);
const desiredHash = useMemo(() => {
if (modal?.type === 'task' && modal.taskId) {
return buildRoute({ kind: ROUTE.TASK, id: modal.taskId, tab: modal.initialTab || DEFAULT_TAB[ROUTE.TASK] });
}
if (modal?.type === 'project' && modal.projectId) {
return buildRoute({ kind: ROUTE.PROJECT, id: modal.projectId, tab: modal.initialTab || DEFAULT_TAB[ROUTE.PROJECT] });
}
if (modal) return '';
return buildRoute({ kind: ROUTE.VIEW, id: view });
}, [modal, view]);
const viewRef = useRef(view);
useEffect(() => { viewRef.current = view; }, [view]);
const modalRef = useRef(modal);
useEffect(() => { modalRef.current = modal; }, [modal]);
const denyAccess = useCallback(() => {
showToast('Доступ запрещён', 'error');
closeModal();
const fallbackView = canAccessView(user, viewRef.current) ? viewRef.current : 'tasks';
const fallback = buildRoute({ kind: ROUTE.VIEW, id: fallbackView });
if (fallback && window.location.hash !== fallback) navigate(fallback);
}, [showToast, closeModal, navigate, user]);
const firstRouteWriteRef = useRef(true);
const routeSyncPendingRef = useRef(false);
useEffect(() => {
if (!route) return;
if (route.kind === ROUTE.VIEW) {
if (!VIEWS.includes(route.id)) return;
if (!canAccessView(user, route.id)) { denyAccess(); return; }
let changed = false;
if (view !== route.id) { setView(route.id); changed = true; }
if (modalRef.current) { closeModal(); changed = true; }
if (changed) routeSyncPendingRef.current = true;
return;
}
if (route.kind === ROUTE.TASK) {
const task = tasks.find(t => t.id === route.id);
if (!task) return;
if (!taskVisible(user, scope, task, dbForScope)) { denyAccess(); return; }
const m = modalRef.current;
const mTab = m?.initialTab || DEFAULT_TAB[ROUTE.TASK];
const routeTab = route.tab || DEFAULT_TAB[ROUTE.TASK];
if (m?.type === 'task' && m.taskId === route.id && mTab === routeTab) return;
routeSyncPendingRef.current = true;
openTask(route.id, route.tab);
return;
}
if (route.kind === ROUTE.PROJECT) {
const project = projects.find(p => p.id === route.id);
if (!project) return;
if (!projectVisible(user, scope, project)) { denyAccess(); return; }
const m = modalRef.current;
const mTab = m?.initialTab || DEFAULT_TAB[ROUTE.PROJECT];
const routeTab = route.tab || DEFAULT_TAB[ROUTE.PROJECT];
if (m?.type === 'project' && m.projectId === route.id && mTab === routeTab) return;
routeSyncPendingRef.current = true;
openProject(route.id, route.tab);
return;
}
if (route.kind === ROUTE.FILE) {
const found = findFileById(dbForScope, route.id);
if (!found) { denyAccess(); return; }
if (found.owner === 'task') {
if (!taskVisible(user, scope, found.entity, dbForScope)) { denyAccess(); return; }
routeSyncPendingRef.current = true;
openAtFile('task', found.entity.id, route.id);
return;
}
if (!projectVisible(user, scope, found.entity)) { denyAccess(); return; }
routeSyncPendingRef.current = true;
openAtFile('project', found.entity.id, route.id);
return;
}
if (route.kind === ROUTE.FOLDER) {
const found = findFolderById(dbForScope, route.id);
if (!found) { denyAccess(); return; }
if (found.owner === 'task') {
if (!taskVisible(user, scope, found.entity, dbForScope)) { denyAccess(); return; }
routeSyncPendingRef.current = true;
openAtFolder('task', found.entity.id, route.id);
return;
}
if (!projectVisible(user, scope, found.entity)) { denyAccess(); return; }
routeSyncPendingRef.current = true;
openAtFolder('project', found.entity.id, route.id);
return;
}
}, [route]);
useEffect(() => {
if (firstRouteWriteRef.current) { firstRouteWriteRef.current = false; return; }
if (routeSyncPendingRef.current) { routeSyncPendingRef.current = false; return; }
if (desiredHash) navigate(desiredHash);
}, [desiredHash, navigate]);
useEffect(() => {
if (!modal) return;
if (modal.type === 'task' && modal.taskId) {
const task = tasks.find(t => t.id === modal.taskId);
if (!task) return;
if (!taskVisible(user, scope, task, dbForScope)) denyAccess();
return;
}
if (modal.type === 'project' && modal.projectId) {
const project = projects.find(p => p.id === modal.projectId);
if (!project) return;
if (!projectVisible(user, scope, project)) denyAccess();
}
}, [modal, tasks, projects, scope, user, dbForScope, denyAccess]);
const renderView = () => {
const common = { ur: user, openTask, openProject, store };
switch (view) {
case 'tasks':
return <TasksView {...common} openCopyTask={openCopyTask} openTemplateFromTask={openTemplateFromTask} />;
case 'drafts':
return <DraftsView ur={user} openTask={openTask} openProject={openProject} />;
case 'gantt':
return <Gantt {...common} />;
case 'calendar':
return <Calendar {...common} openCopyTask={openCopyTask} openTemplateFromTask={openTemplateFromTask} />;
case 'projects':
return <ProjectsView {...common} openCopyProject={openCopyProject} openTemplateFromProject={openTemplateFromProject} openProjectAccess={openProjectAccess} />;
case 'templates':
return <TemplatesView />;
case 'cabinet':
// openEmployeeTasks больше не передаётся: вкладка «Загрузка»,
// которая его использовала, перенесена в раздел «Персонал».
return <CabinetView store={store} user={user} openTask={openTask} openVacation={openVacation} openDelegation={openDelegation} />;
case 'staff':
// openEmployeeTasks нужен вкладке «Загрузка» внутри StaffView.
return <StaffView store={store} ur={user} openRoles={openRoles} openDepts={openDepts} openVacation={openVacation} openEmployeeTasks={openEmployeeTasks} />;
case 'reports':
return <ReportsView ur={user} />;
case 'archive':
return <ArchiveView ur={user} openTask={openTask} openProject={openProject} setArchiveMonths={store.setArchiveMonths} store={store} />;
case 'requests':
return <RequestsView key={requestsTab} ur={user} initialTab={requestsTab} />;
case 'journal':
return <JournalView ur={user} />;
default:
return null;
}
};
return (
<div className="shell">
<aside className="sidebar">
<div className="logo">
<div className="logo-mark">АГ</div>
<div>
<div className="logo-name">АвиаГоризонт</div>
<div className="logo-sub">планирование и учёт времени</div>
</div>
</div>
<nav className="nav">
{navItems.map(n => (
<button
key={n.id}
className={`nav-item${view === n.id ? ' on' : ''}`}
onClick={() => handleNavClick(n.id)}
>
<Ic d={n.icon} /> <span className="nav-lbl">{n.label}</span>
</button>
))}
</nav>
<div className="side-foot">
<div className="user-card cursor-pointer" onClick={() => navigate('#/view/cabinet')}>
<div className="avatar">
{user.photo
? <img src={user.photo} alt="Аватар" className="user-card-photo" />
: user.last[0] + user.first[0]}
</div>
<div className="user-meta">
<div className="user-name">{user.last} {user.first}</div>
<div className="user-roles">{user.roles.join(' · ')}</div>
<SubstitutionBadge user={user} empName={store.empName} />
</div>
<button
className="icon-btn dark"
onClick={(e) => { e.stopPropagation(); logout(); }}
title="Выйти"
>
<Ic d={ICONS.out} size={16} />
</button>
</div>
</div>
</aside>
<main className="main">
<header className="topbar">
<div>
<h1 className="page-title">
{navItems.find(n => n.id === view)?.label || 'Личный кабинет'}
</h1>
<div className="page-sub">
{new Date().toLocaleDateString('ru-RU', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
})} · вы вошли как {user.last} {user.first}
</div>
</div>
<div className="top-tools">
<button className="btn ghost" onClick={openVacNow}>
<Ic d={ICONS.beach} size={15} /> Сотрудники в отпусках
</button>
<button className="btn ghost" onClick={openYearCalendar}>
<Ic d={ICONS.cal} size={15} /> Производственный календарь
</button>
<ThemeToggle />
<NotifBell user={user} store={store} onNavigate={handleNotificationNavigate} />
</div>
</header>
<div className="content">{renderView()}</div>
</main>
{modal && (
<ModalRenderer
key={modal._seq}
modal={modal}
onClose={closeModal}
ur={user}
store={store}
openTask={openTask}
openProject={openProject}
openChangeReq={openChangeReq}
openRoles={openRoles}
openDepts={openDepts}
openVacation={openVacation}
openDelegation={openDelegation}
openEmployeeTasks={openEmployeeTasks}
openCopyTask={openCopyTask}
openCopyProject={openCopyProject}
onTabChange={setModalTab}
toast={showToast}
/>
)}
</div>
);
}
export default MainLayout;
+76
View File
@@ -0,0 +1,76 @@
// src/components/Modal.jsx
import { useEffect, useLayoutEffect, useRef } from 'react';
import { Ic, ICONS } from './Icons';
/**
* Базовое модальное окно.
*
* Внутри - оверлей, шапка (headerBefore + заголовок с опциональным
* подзаголовком + headerAfter + крестик), тело и футер. Специализации
* (ModalShell с кнопками «Отмена / Сохранить», TaskModal, ProjectModal)
* строятся поверх.
*
* subtitle - необязательная вторая строка под h3: метаданные сущности
* («Задачу составил…», «Создан 15.01.2026» и т.п.). Вынесен в h3-блок
* отдельным элементом, а не склеен в один title строкой: заголовок -
* крупный и тёмный, подзаголовок - мелкий и приглушённый, стили разные.
*
* Если subtitle не передан (null / undefined / пусто) - разметка не
* меняется, h3 остаётся как есть.
*/
export const Modal = ({
title,
subtitle = null,
onClose,
children,
footer = null,
width = 640,
className = '',
headerBefore = null,
headerAfter = null,
bodyRef = null,
}) => {
const rootRef = useRef(null);
useLayoutEffect(() => {
rootRef.current?.style.setProperty('--modal-max-w', `${width}px`);
}, [width]);
useEffect(() => {
const handler = (e) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onClose]);
return (
<div
className="overlay"
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div ref={rootRef} className={`modal ${className}`}>
<div className="modal-head">
{headerBefore && <div className="modal-header-before">{headerBefore}</div>}
<div className="modal-title-block">
<h3>{title}</h3>
{subtitle && <div className="modal-subtitle">{subtitle}</div>}
</div>
{headerAfter && <div className="modal-header-after">{headerAfter}</div>}
<button className="icon-btn" onClick={onClose}>
<Ic d={ICONS.x} size={16} />
</button>
</div>
<div className="modal-body" ref={bodyRef}>
{children}
</div>
{footer}
</div>
</div>
);
};
+392
View File
@@ -0,0 +1,392 @@
// src/components/ModalRenderer.jsx
import { useCallback, useMemo } from 'react';
import { useSelector } from '../context/StoreContext';
import {
TaskModal,
ProjectModal,
ChangeRequestModal,
RolesModal,
DeptsModal,
VacationModal,
DelegationModal,
VacNowModal,
CreateEmployeeModal,
EditEmployeeModal,
YearCalendarModal,
EmployeeTasksModal,
} from './Modals';
import { TemplateModal } from './Templates';
import { collectTaskPayloads } from '../utils/templateNesting';
export default function ModalRenderer({
modal,
onClose,
ur,
store,
openTask,
openProject,
openChangeReq,
openEmployeeTasks,
openCopyTask,
openCopyProject,
onTabChange,
toast,
}) {
const tasks = useSelector(s => s.tasks);
const projects = useSelector(s => s.projects);
const employees = useSelector(s => s.employees);
const vacations = useSelector(s => s.vacations);
const departments = useSelector(s => s.departments);
const kbs = useSelector(s => s.kbs);
const roleDelegations = useSelector(s => s.roleDelegations);
const taskDb = useMemo(
() => ({ tasks, projects, employees, departments, vacations }),
[tasks, projects, employees, departments, vacations],
);
const projectDb = useMemo(
() => ({ tasks, projects, employees, kbs, departments }),
[tasks, projects, employees, kbs, departments],
);
// Мини-db под модалку запроса изменения: ей нужны только целевые
// сущности, чтобы показать текущее значение.
const changeReqDb = useMemo(
() => ({ tasks, projects }),
[tasks, projects],
);
const vacationDb = useMemo(
() => ({ employees, departments, kbs, vacations }),
[employees, departments, kbs, vacations],
);
const delegationDb = useMemo(
() => ({ employees }),
[employees],
);
// VacNowModal вычисляет колонку «Замещает» доменным правилом
// findVacationSubstitutes (utils/substitution.js). Правило принимает
// массив roleDelegations — без него всегда возвращался бы пустой
// список, и в колонке всегда стояло бы «—».
const vacNowDb = useMemo(
() => ({ employees, departments, kbs, vacations, roleDelegations }),
[employees, departments, kbs, vacations, roleDelegations],
);
const employeeTasksDb = useMemo(
() => ({ employees, tasks, projects }),
[employees, tasks, projects],
);
const handleError = useCallback((error) => {
console.error(error);
if (toast) toast(error.message || 'Произошла ошибка', 'error');
}, [toast]);
const reportTemplateResult = useCallback((label, res) => {
if (!res) return;
if (res.failed > 0) {
toast?.(`${label}: создано ${res.created}, с ошибками ${res.failed}`, 'warning');
} else if (res.created > 0) {
toast?.(`${label}: создано ${res.created}`, 'success');
}
}, [toast]);
const closeTaskWithReturn = useCallback(() => {
if (modal.returnToEmployeeTasksId) {
openEmployeeTasks(modal.returnToEmployeeTasksId);
return;
}
if (modal.returnToTaskId) {
openTask(modal.returnToTaskId, modal.returnToTaskTab || 'subtasks');
return;
}
if (modal.returnToProjectId) {
openProject(modal.returnToProjectId, modal.returnToProjectTab || 'info');
return;
}
onClose();
}, [modal, onClose, openEmployeeTasks, openTask, openProject]);
const closeProjectWithReturn = useCallback(() => {
if (modal.returnToProjectId) {
openProject(modal.returnToProjectId, modal.returnToProjectTab || 'info');
return;
}
onClose();
}, [modal, onClose, openProject]);
const copySourceType = modal?.type;
const handleCopyFromModal = useCallback((sourceId) => {
if (copySourceType === 'task') {
openCopyTask(sourceId, { returnToModal: true });
} else if (copySourceType === 'project') {
openCopyProject(sourceId, { returnToModal: true });
}
}, [copySourceType, openCopyTask, openCopyProject]);
const handleTaskSave = useCallback(async (task, isNew, extras = {}) => {
try {
await store.upsertTask(task);
if (extras.templateSubtasks?.length) {
const res = store.instantiateTemplateSubtasks(task.id, extras.templateSubtasks);
reportTemplateResult('Подзадачи из шаблона', res);
}
closeTaskWithReturn();
} catch (error) {
handleError(error);
}
}, [store, reportTemplateResult, closeTaskWithReturn, handleError]);
const handleTaskDelete = useCallback(async (id) => {
try {
// Порядок важен: MainLayout полагается на то, что к моменту,
// когда route-эффект увидит route ещё указывающим на удалённую
// задачу, её уже не будет в tasks (см. комментарий в
// MainLayout.jsx над обработкой ROUTE.TASK). Если сначала
// переключить модалку на родителя, а task ещё жива в tasks,
// route-эффект застаёт modal=P, route=S, tasks содержит S -
// и вызывает openTask(S, ...) заново, открывая уже удаляемую
// подзадачу. Это гоняет desiredHash/route друг за другом и
// даёт Maximum update depth exceeded.
await store.deleteTask(id);
closeTaskWithReturn();
} catch (error) {
handleError(error);
}
}, [store, closeTaskWithReturn, handleError]);
const handleProjectSave = useCallback(async (p, isNew, extras = {}) => {
try {
await store.upsertProject(p);
if (extras.templateTasks?.length) {
const res = store.instantiateTemplateTasks(p.id, extras.templateTasks);
reportTemplateResult('Задачи из шаблона', res);
}
closeProjectWithReturn();
} catch (error) {
handleError(error);
}
}, [store, reportTemplateResult, closeProjectWithReturn, handleError]);
const handleProjectDelete = useCallback(async (id) => {
try {
await store.deleteProject(id);
closeProjectWithReturn();
} catch (error) {
handleError(error);
}
}, [store, closeProjectWithReturn, handleError]);
/**
* Создание запроса на изменение.
*
* Получатели (директора и админы) собираются здесь и передаются в
* store.addChangeRequest вторым аргументом — сервис отправит им
* уведомление. Раньше этот шаг вычислялся, но до сервиса не доходил:
* директор узнавал о запросе, только если сам открывал раздел.
*/
const handleChangeReqSubmit = useCallback(async (r) => {
try {
const recipientIds = employees
.filter(e =>
!e.fired && (e.roles.includes('director') || e.roles.includes('admin'))
)
.map(e => e.id);
store.addChangeRequest(r, recipientIds);
onClose();
} catch (error) {
handleError(error);
}
}, [employees, store, onClose, handleError]);
const handleVacationSave = useCallback(async (v) => {
try {
await store.upsertVacation(v);
onClose();
} catch (error) {
handleError(error);
}
}, [store, onClose, handleError]);
const handleVacationDelete = useCallback(async (id) => {
store.deleteVacation(id);
}, [store]);
const handleDelegationSubmit = useCallback(async (rd) => {
try {
await store.upsertRoleDelegation(rd);
store.notifyRoleDelegationCreated(rd);
onClose();
} catch (error) {
handleError(error);
}
}, [store, onClose, handleError]);
if (!modal) return null;
switch (modal.type) {
case 'task':
return (
<TaskModal
db={taskDb}
ur={ur}
taskId={modal.taskId}
copyFromId={modal.copyFromId}
initialTab={modal.initialTab || 'form'}
parentTaskId={modal.parentTaskId}
initialProjectId={modal.initialProjectId}
returnToProjectId={modal.returnToProjectId}
returnToTaskId={modal.returnToTaskId}
returnToEmployeeTasksId={modal.returnToEmployeeTasksId}
highlightFileId={modal.highlightFileId || null}
highlightFolderId={modal.highlightFolderId || null}
onClose={closeTaskWithReturn}
onCopy={handleCopyFromModal}
onTabChange={onTabChange}
onSave={handleTaskSave}
onDelete={handleTaskDelete}
onChangeReq={openChangeReq}
store={store}
openTask={openTask}
toast={toast}
/>
);
case 'project':
return (
<ProjectModal
db={projectDb}
ur={ur}
projectId={modal.projectId}
copyFromId={modal.copyFromId}
returnToProjectId={modal.returnToProjectId}
initialTab={modal.initialTab || 'info'}
highlightFileId={modal.highlightFileId || null}
highlightFolderId={modal.highlightFolderId || null}
onClose={closeProjectWithReturn}
onCopy={handleCopyFromModal}
onTabChange={onTabChange}
onSave={handleProjectSave}
onDelete={handleProjectDelete}
store={store}
openTask={openTask}
toast={toast}
/>
);
case 'changeReq':
return (
<ChangeRequestModal
db={changeReqDb}
ur={ur}
changeKind={modal.changeKind}
targetType={modal.targetType}
targetId={modal.targetId}
onClose={onClose}
onSubmit={handleChangeReqSubmit}
toast={toast}
/>
);
case 'roles':
return (
<RolesModal store={store} empId={modal.empId} onClose={onClose} toast={toast} />
);
case 'depts':
return (
<DeptsModal store={store} empId={modal.empId} onClose={onClose} toast={toast} />
);
case 'vacation':
return (
<VacationModal
db={vacationDb}
ur={ur}
vacationId={modal.vacationId}
forEmpId={modal.forEmpId || null}
onClose={onClose}
onSave={handleVacationSave}
onDelete={handleVacationDelete}
toast={toast}
/>
);
case 'delegation':
return (
<DelegationModal
db={delegationDb}
ur={ur}
onClose={onClose}
onSubmit={handleDelegationSubmit}
toast={toast}
/>
);
case 'vacnow':
return <VacNowModal db={vacNowDb} onClose={onClose} toast={toast} />;
case 'yearCalendar':
return <YearCalendarModal store={store} onClose={onClose} />;
case 'employeeTasks':
return (
<EmployeeTasksModal
db={employeeTasksDb}
employeeId={modal.empId}
openTask={openTask}
onClose={onClose}
/>
);
case 'templateFromTask': {
const task = tasks.find(t => t.id === modal.taskId);
if (!task) return null;
return (
<TemplateModal
mode="create"
kind="task"
source={task}
nested={collectTaskPayloads(tasks, task.id)}
onClose={onClose}
toast={toast}
/>
);
}
case 'templateFromProject': {
const project = projects.find(p => p.id === modal.projectId);
if (!project) return null;
return (
<TemplateModal
mode="create"
kind="project"
source={project}
nested={collectTaskPayloads(
tasks.filter(t => t.projectId === project.id && !t.archived)
)}
onClose={onClose}
toast={toast}
/>
);
}
case 'createEmployee':
return (
<CreateEmployeeModal store={store} ur={ur} onClose={onClose} toast={toast} />
);
case 'editEmployee':
return (
<EditEmployeeModal
store={store}
ur={ur}
employeeId={modal.employeeId}
onClose={onClose}
toast={toast}
/>
);
default:
return null;
}
}
+74
View File
@@ -0,0 +1,74 @@
// src/components/ModalShell.jsx
import { Modal } from './Modal';
import { Ic, ICONS } from './Icons';
/**
* Каркас модалки: шапка, тело, подвал.
*
* Футер собирается одним из трёх способов:
*
* 1. `footer` передан явно - используется как есть.
* 2. `actions` передан - стандартный футер с массивом кнопок слева
* от spacer: «Отмена» и «Сохранить» на месте, слева добавляются
* переданные действия.
* 3. Ни то, ни другое - стандартный футер только с «Отмена/Сохранить».
*
* subtitle - вторая строка под заголовком. Пробрасывается в Modal,
* который отвечает за разметку. Подзаголовок не участвует в футере
* или действиях, не влияет на showSave / saveDisabled - это чистая
* метаинформация шапки.
*/
export const ModalShell = ({
title,
subtitle = null,
onClose,
children,
onSave,
saveLabel = 'Сохранить',
width = 640,
className = '',
footer = null,
actions = null,
showSave = true,
saveDisabled = false,
showBack = false,
backLabel = 'Назад',
onBack = null,
headerAfter = null,
bodyRef = null,
}) => {
const footerEl = footer !== null
? footer
: (showSave ? (
<div className="modal-foot">
{actions}
<div className="spacer" />
<button className="btn ghost" onClick={onClose}>Отмена</button>
<button className="btn primary" onClick={onSave} disabled={saveDisabled}>
{saveLabel}
</button>
</div>
) : null);
const backButton = showBack ? (
<button type="button" className="btn ghost sm" onClick={onBack || onClose}>
<Ic d={ICONS.left} size={14} /> {backLabel}
</button>
) : null;
return (
<Modal
title={title}
subtitle={subtitle}
onClose={onClose}
width={width}
className={className}
headerBefore={backButton}
headerAfter={headerAfter}
footer={footerEl}
bodyRef={bodyRef}
>
{children}
</Modal>
);
};
@@ -0,0 +1,165 @@
// src/components/Modals/ChangeRequestModal.jsx
import { useCallback, useMemo } from 'react';
import { ModalShell } from '../ModalShell';
import { FormField } from '../FormField';
import { useForm } from '../../hooks/useForm';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { uid } from '../../utils/date';
import { requireChangeKind } from '../../utils/changeKinds';
/**
* Единая модалка запроса изменения. Вид изменения (часы / срок / …)
* полностью параметризует форму через реестр utils/changeKinds:
* - label — заголовок окна;
* - inputType — тип поля «Новое значение»;
* - currentOf — как показать текущее значение;
* - validateNew — правило валидации нового значения;
* - formatValue — как форматировать значения в UI;
* - targetTypes — к каким типам сущностей вид применим.
*
* Ни одна ветка по changeKind здесь не хардкожена: добавление нового
* вида (например, «изменение приоритета задачи») сводится к записи
* в реестре, без правок этого файла.
*
* Ранняя проверка совместимости (changeKind, targetType) — до рендера
* формы. В нормальной работе UI её гарантирует (кнопка запроса
* показывается только в подходящем контексте: у задачи — срок, у
* задачи или проекта — часы). Проверка нужна как страховка от прямого
* вызова openChangeReq из консоли или от нового кода, забывшего про
* правило реестра: вместо исключения из ChangeRequestService
* пользователь увидит понятное сообщение и одна кнопка «Закрыть».
*
* Хуки (useMemo, useForm, useAsyncSubmit) выполняются безусловно —
* правило Hooks не даёт права «пропустить» их при раннем возврате.
* Соответственно, все значения, от которых зависят хуки, вычисляются
* до проверки isApplicable; сама проверка — уже после них.
*/
export const ChangeRequestModal = ({
db, ur, changeKind, targetType, targetId, onClose, onSubmit, toast,
}) => {
const kind = requireChangeKind(changeKind);
const isApplicable = kind.targetTypes.includes(targetType);
const target = targetType === 'task'
? db.tasks.find(t => t.id === targetId)
: db.projects.find(p => p.id === targetId);
const current = target ? kind.currentOf(target, targetType) : null;
const fields = useMemo(() => Object.freeze(['newValue', 'reason']), []);
const { values, handleChange, handleSubmit, errors, touched } = useForm(
{ newValue: current ?? '', reason: '' },
useCallback((vals) => {
// Форма не будет показана, если isApplicable === false; возвращать
// осмысленные ошибки тут нечего — пустой объект, чтобы isValid
// остался true и никакие побочные эффекты не сработали.
if (!isApplicable) return {};
const errs = {};
const newErr = kind.validateNew(vals.newValue, current);
if (newErr) errs.newValue = newErr;
if (!vals.reason.trim()) errs.reason = 'Укажите обоснование';
return errs;
}, [kind, current, isApplicable]),
{ fields },
);
const { submit: save, isSubmitting } = useAsyncSubmit(
useCallback(async (vals) => {
// Симметрично validate: при несовместимой паре не должно быть
// возможности отправить запрос. Сюда не дойдёт — кнопка
// «Сохранить» в этом режиме не рендерится, — но защита бесплатна.
if (!isApplicable) return;
await onSubmit({
id: uid(),
changeKind,
targetType,
targetId,
oldValue: current,
newValue: kind.normalizeValue(vals.newValue),
reason: vals.reason.trim(),
reqId: ur.id,
status: 'pending',
rejectionReason: null,
ts: Date.now(),
});
onClose();
}, [isApplicable, changeKind, targetType, targetId, current, kind, ur, onSubmit, onClose]),
(error) => {
toast(error.message || 'Ошибка отправки запроса', 'error');
},
);
// ── Ранний возврат: несовместимая пара (changeKind, targetType) ─────
if (!isApplicable) {
return (
<ModalShell
title="Запрос недоступен"
onClose={onClose}
width={480}
className="modal-hours"
showSave={false}
footer={
<div className="modal-foot">
<div className="spacer" />
<button type="button" className="btn ghost" onClick={onClose}>
Закрыть
</button>
</div>
}
>
<p className="mut">
Вид изменения «{kind.label}» не применим к типу целевой
сущности «{targetType}». Запрос отменён — это защита от
некорректного сочетания, а не ошибка ваших данных.
</p>
</ModalShell>
);
}
// ── Основной режим: форма запроса ────────────────────────────────────
const targetLabel = targetType === 'task' ? target?.title : target?.name;
return (
<ModalShell
title={`Запрос: ${kind.label.toLowerCase()}`}
onClose={onClose}
onSave={handleSubmit(save)}
saveLabel="Отправить запрос"
width={480}
className="modal-hours"
saveDisabled={isSubmitting}
showBack
>
<p className="mut sm">
{targetLabel}. Запрос будет направлен генеральному директору.
</p>
<div className="project-info-fields">
<FormField
label="Текущее значение"
disabled
value={kind.formatValue(current)}
/>
<FormField
label="Новое значение"
required
type={kind.inputType}
{...kind.inputProps}
value={values.newValue ?? ''}
onChange={(v) => handleChange('newValue', v)}
error={touched.newValue && errors.newValue}
/>
<FormField
label="Обоснование"
required
type="textarea"
rows={3}
value={values.reason}
onChange={(v) => handleChange('reason', v)}
error={touched.reason && errors.reason}
placeholder="Почему требуется изменение…"
/>
</div>
</ModalShell>
);
};
@@ -0,0 +1,117 @@
// src/components/Modals/CreateEmployeeModal.jsx
import { useCallback } from 'react';
import { ModalShell } from '../ModalShell';
import { EmployeeFormFields } from '../EmployeeFormFields';
import { useModalForm } from '../../hooks/useModalForm';
import { uid } from '../../utils/date';
import { ROLES } from '../../utils/constants';
import { FormField } from '../FormField';
const FIELDS = Object.freeze([
'last',
'first',
'email',
'pass',
'position',
'extension',
'tab',
'roles',
]);
export const CreateEmployeeModal = ({ store, ur, onClose, toast }) => {
const initialValues = {
last: '',
first: '',
email: '',
pass: '',
position: 'Сотрудник',
extension: '',
tab: String(1000 + Math.floor(Math.random() * 8999)),
roles: ['executor'],
};
const validate = useCallback((values) => {
const errors = {};
if (!values.last.trim()) errors.last = 'Фамилия обязательна';
if (!values.first.trim()) errors.first = 'Имя обязательно';
if (!values.email.trim()) errors.email = 'E-mail обязателен';
if (!values.pass.trim()) errors.pass = 'Пароль обязателен';
if (values.pass.length < 8) errors.pass = 'Пароль должен быть не менее 8 символов';
const employees = store.data.employees;
if (employees.some(e => e.email === values.email)) {
errors.email = 'Сотрудник с таким email уже существует';
}
return errors;
}, [store]);
const saveAsync = useCallback(async (vals) => {
const newEmp = {
id: 'e_' + uid(),
last: vals.last.trim(),
first: vals.first.trim(),
email: vals.email.trim(),
pass: vals.pass,
position: vals.position.trim() || 'Сотрудник',
departments: [],
roles: vals.roles,
kbIds: [],
headDeptIds: [],
extension: vals.extension || '',
tab: vals.tab || String(1000 + Math.floor(Math.random() * 8999)),
notif: {
deadlineEmail: true,
overdueDigest: false,
commentSub: true,
},
failed: 0,
lockUntil: 0,
fired: false,
photo: null,
passwordHistory: [],
substitutions: [],
};
await store.upsertEmployee(newEmp);
store.addAudit('Создание сотрудника', `${newEmp.last} ${newEmp.first}`);
store.addNotification(ur.id, `Создан сотрудник ${newEmp.last} ${newEmp.first}`, { targetType: 'employee', targetId: newEmp.id });
onClose();
}, [store, ur, onClose]);
const {
values, handleChange, errors, touched,
handleSubmit, isSubmitting,
} = useModalForm(initialValues, validate, saveAsync, (error) => {
toast(error.message || 'Ошибка создания сотрудника', 'error');
}, { fields: FIELDS });
const roleOptions = Object.entries(ROLES).map(([k, v]) => ({ value: k, label: v.label }));
return (
<ModalShell
title="Добавить сотрудника"
onClose={onClose}
onSave={handleSubmit()}
saveLabel="Создать"
width={560}
className="modal-employee"
saveDisabled={isSubmitting}
>
<EmployeeFormFields
values={values}
onChange={handleChange}
errors={errors}
touched={touched}
passwordRequired
/>
<div className="mt-3">
<FormField
label="Роли (по умолчанию исполнитель)"
type="select"
options={roleOptions}
value={values.roles}
onChange={(v) => handleChange('roles', v)}
multiple
/>
</div>
</ModalShell>
);
};
+330
View File
@@ -0,0 +1,330 @@
// src/components/Modals/DelegationModal.jsx
import { useCallback, useMemo } from "react";
import { ModalShell } from "../ModalShell";
import { FormField } from "../FormField";
import { RecipientSelect, TaskStatusPicker } from "../DelegationFields";
import { useNestedModalEscape } from "../Templates/useNestedModalEscape";
import { useForm } from "../../hooks/useForm";
import { useAsyncSubmit } from "../../hooks/useAsyncSubmit";
import { getDelegableRoles, getSubstituteCandidates } from "../../utils/permissions";
import {
TRANSFER_MODES,
TRANSFER_MODE_OPTIONS,
SUBSTITUTION_KIND_OPTIONS
} from "../../utils/transferModes";
import { ROLES, SUBSTITUTION_STATUS_VALUES } from "../../utils/constants";
import { TODAY, addDays, iso, uid } from "../../utils/date";
const FIELDS = Object.freeze([
"mode",
"kind",
"toId",
"roles",
"start",
"end",
"openEnd",
"reasonText",
"delegateTasks",
"taskStatuses"
]);
function buildInitialValues() {
return {
mode: TRANSFER_MODES.delegation.id,
kind: SUBSTITUTION_KIND_OPTIONS[0]?.value || "business_trip",
toId: "",
roles: [],
start: TODAY,
end: iso(addDays(new Date(), 14)),
openEnd: false,
reasonText: "",
delegateTasks: true,
taskStatuses: [...SUBSTITUTION_STATUS_VALUES]
};
}
/**
* Единая модалка передачи полномочий: делегирование ролей либо замещение.
* Переключатель режима параметризует форму через TRANSFER_MODES: какие
* поля показывать, как собрать итоговый kind.
*
* Отпуск — обычный повод замещения, наравне с командировкой и ротацией.
* Отдельного restricted-режима для «замещения на период отпуска» больше
* нет: отпуск и замещение — независимые сущности, замещение всегда
* создаётся этой модалкой вручную.
*
* Поле «Передаваемые роли» — многозначный Select. В этой модалке оно
* стоит так, что попап вниз перекрывает «Пояснение» и блок «Задачи».
* Авто-детект по свободному месту не спасает: снизу места формально
* много, попап открывается вниз и накрывает поля. Поэтому здесь
* placement="top" — открываем строго вверх.
*/
export function DelegationModal({ db, ur, onClose, onSubmit, toast }) {
useNestedModalEscape(onClose);
const candidates = useMemo(
() => getSubstituteCandidates(ur, db),
[ur, db]
);
const recipientOptions = useMemo(
() =>
candidates.map((e) => ({
value: e.id,
label: `${e.last} ${e.first}`
})),
[candidates]
);
const roleOptions = useMemo(
() =>
getDelegableRoles(ur).map((r) => ({
value: r,
label: ROLES[r]?.label || r
})),
[ur]
);
const initialValues = useMemo(() => buildInitialValues(), []);
const validate = useCallback(
(values) => {
const errs = {};
if (!values.toId) errs.toId = "Выберите получателя";
if (!values.roles.length) {
errs.roles = "Выберите хотя бы одну роль";
}
if (!values.reasonText.trim()) {
errs.reasonText = "Укажите обоснование";
}
if (!values.start) errs.start = "Дата начала обязательна";
else if (values.start < TODAY) {
errs.start = "Дата начала не может быть в прошлом";
}
if (!values.openEnd) {
if (!values.end) errs.end = "Дата окончания обязательна";
else if (values.end < values.start) {
errs.end = "Дата окончания не может быть раньше даты начала";
}
}
return errs;
},
[]
);
const { values, handleChange, handleSubmit, errors, touched } = useForm(
initialValues,
validate,
{ fields: FIELDS }
);
const mode = TRANSFER_MODES[values.mode];
const saveAsync = useCallback(
async (v) => {
const from = db.employees.find((e) => e.id === ur.id);
if (!from) throw new Error("Пользователь не найден");
const activeMode = TRANSFER_MODES[v.mode];
const delegateTasks = activeMode.resolveDelegateTasks(v);
await onSubmit({
id: "rd_" + uid(),
kind: activeMode.resolveKind(v),
vacationId: null,
fromId: ur.id,
toId: v.toId,
start: v.start,
end: v.openEnd ? null : v.end,
reasonText: v.reasonText.trim(),
roles: [...v.roles],
kbIds: [...(from.kbIds || [])],
headDeptIds: [...(from.headDeptIds || [])],
delegateTasks,
taskStatuses: delegateTasks ? v.taskStatuses : [],
tasksState: null,
status: "pending",
requestedBy: ur.id,
approvedBy: null,
rejectionReason: null,
createdAt: Date.now(),
decidedAt: null
});
onClose();
},
[db.employees, ur.id, onSubmit, onClose]
);
const { submit, isSubmitting } = useAsyncSubmit(saveAsync, (err) => {
toast(err.message || "Не удалось создать запись", "error");
});
const hasCandidates = candidates.length > 0;
const hasRoles = roleOptions.length > 0;
const showTasksToggle = mode.shows.tasks;
const showTaskStatusPicker = mode.shows.tasks && values.delegateTasks;
const canSubmit =
hasCandidates &&
hasRoles &&
!isSubmitting;
const title = "Передача полномочий";
const saveLabel = "Отправить на согласование";
return (
<ModalShell
title={title}
onClose={onClose}
onSave={handleSubmit(submit)}
saveLabel={saveLabel}
width={640}
saveDisabled={!canSubmit}
>
<p className="mut sm">
Получатель вступает в полномочия после согласования: уведомление
придёт именно ему, и утвердить или отклонить запрос может только он.
</p>
{!hasCandidates && (
<div className="info-box">
Нет сотрудников, доступных для получения полномочий.
Уточните состав подразделения у администратора или HR.
</div>
)}
{!hasRoles && (
<div className="info-box">
У вас нет ролей, которые можно передать.
</div>
)}
<div className="project-info-fields">
{/*
* .seg — прямой flex-ребёнок .field-row, без обёртки .flex-1:
* обёртка с flex: 1 растянула бы сегмент на всю строку.
*/}
<div className="field-row">
<label className="field-label">Тип</label>
<div className="seg">
{TRANSFER_MODE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={`seg-btn${values.mode === opt.value ? " on" : ""}`}
disabled={isSubmitting}
onClick={() => handleChange("mode", opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
{mode.shows.kind && (
<FormField
label="Причина"
required
type="select"
options={SUBSTITUTION_KIND_OPTIONS}
value={values.kind}
onChange={(v) => handleChange("kind", v)}
disabled={isSubmitting}
/>
)}
<RecipientSelect
value={values.toId}
onChange={(v) => handleChange("toId", v)}
options={recipientOptions}
error={touched.toId && errors.toId}
disabled={isSubmitting || !hasCandidates}
/>
{/* placement="top" — попап всегда открывается вверх,
не накрывая «Пояснение» и блок «Задачи». */}
<FormField
label="Передаваемые роли"
required
type="select"
multiple
placement="top"
options={roleOptions}
value={values.roles}
onChange={(v) => handleChange("roles", v)}
error={touched.roles && errors.roles}
disabled={isSubmitting || !hasRoles}
/>
<div className="fields-row fields-row--stacked">
<FormField
label="Дата начала"
required
type="date"
inline
value={values.start}
onChange={(v) => handleChange("start", v)}
error={touched.start && errors.start}
disabled={isSubmitting}
/>
<FormField
label="Дата окончания"
type="date"
inline
value={values.end}
onChange={(v) => handleChange("end", v)}
error={touched.end && errors.end}
disabled={isSubmitting || values.openEnd}
below={
<label className="dept-pick mt-2">
<input
type="checkbox"
checked={values.openEnd}
disabled={isSubmitting}
onChange={(e) =>
handleChange("openEnd", e.target.checked)
}
/>
до отмены
</label>
}
/>
</div>
<FormField
label="Пояснение"
required
type="textarea"
rows={2}
value={values.reasonText}
onChange={(v) => handleChange("reasonText", v)}
error={touched.reasonText && errors.reasonText}
placeholder="Например: командировка в филиал, отпуск, обучение, ротация…"
/>
{showTasksToggle && (
<div className="field-row">
<label className="field-label">Задачи</label>
<div className="flex-1">
<label className="dept-pick">
<input
type="checkbox"
checked={values.delegateTasks}
disabled={isSubmitting}
onChange={(e) =>
handleChange("delegateTasks", e.target.checked)
}
/>
<span>Передать задачи получателю</span>
</label>
</div>
</div>
)}
{showTaskStatusPicker && (
<TaskStatusPicker
value={values.taskStatuses}
onChange={(v) => handleChange("taskStatuses", v)}
disabled={isSubmitting}
/>
)}
</div>
</ModalShell>
);
}
export default DelegationModal;
+222
View File
@@ -0,0 +1,222 @@
// src/components/Modals/DeptsModal.jsx
import { useState, useCallback, useMemo } from 'react';
import { ModalShell } from '../ModalShell';
import { FormField } from '../FormField';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
export const DeptsModal = ({ store, empId, onClose, toast }) => {
const db = store.data;
const emp = db.employees.find(e => e.id === empId);
const isKbChief = useMemo(
() => !!emp && Array.isArray(emp.roles) && emp.roles.includes('kb_chief'),
[emp],
);
const [selections, setSelections] = useState(() => {
if (!emp) return [];
return emp.departments.map(d => {
const isPrimary = !isKbChief && !!d.primary;
const entry = {
deptId: d.deptId ?? null,
primary: isPrimary,
position: d.position ?? '',
};
if (!isPrimary) entry.rate = d.rate ?? 1;
return entry;
});
});
const toggleDept = (deptId) => {
setSelections(prev => {
const exists = prev.some(x => x.deptId === deptId);
if (exists) return prev.filter(x => x.deptId !== deptId);
return [...prev, { deptId, primary: false, position: '', rate: 1 }];
});
};
const toggleExternal = () => {
setSelections(prev => {
const exists = prev.some(x => x.deptId === null);
if (exists) return prev.filter(x => x.deptId !== null);
return [...prev, { deptId: null, primary: false, position: '', rate: 1 }];
});
};
const setPrimary = (deptId) => {
if (isKbChief) return;
setSelections(prev => {
const cur = prev.find(x => x.deptId === deptId);
if (cur?.primary) {
return prev.map(x => x.deptId === deptId ? { ...x, primary: false } : x);
}
return prev.map(x => ({ ...x, primary: x.deptId === deptId }));
});
};
const setPosition = (deptId, pos) => {
setSelections(prev => prev.map(x => x.deptId === deptId ? { ...x, position: pos } : x));
};
const setRate = (deptId, rate) => {
setSelections(prev => prev.map(x => x.deptId === deptId ? { ...x, rate } : x));
};
const externalEntry = selections.find(x => x.deptId === null);
const hasExternal = !!externalEntry;
const saveAsync = useCallback(async () => {
if (!emp) throw new Error('Сотрудник не найден');
if (!selections.length) throw new Error('Выберите хотя бы одно подразделение');
const departments = selections.map(({ deptId, position, rate }) => {
const isPrimary = !isKbChief && !!selections.find(s => s.deptId === deptId)?.primary;
const entry = {
deptId,
primary: isPrimary,
position: position?.trim() || '',
};
if (!isPrimary) {
const numRate = Number(rate);
if (!Number.isFinite(numRate) || numRate < 0.1 || numRate > 1) {
throw new Error('Ставка должна быть от 0.1 до 1');
}
entry.rate = numRate;
}
return entry;
});
await store.upsertEmployee({ ...emp, departments });
onClose();
}, [selections, emp, isKbChief, store, onClose]);
const { submit: save, isSubmitting } = useAsyncSubmit(saveAsync, (error) => {
toast(error.message || 'Ошибка сохранения подразделений', 'error');
});
if (!emp) return null;
return (
<ModalShell
title={`Подразделения - ${emp.last} ${emp.first}`}
onClose={onClose}
onSave={save}
width={560}
saveDisabled={isSubmitting}
>
{isKbChief ? (
<p className="mut sm">
Сотрудник - главный конструктор. Его основное место работы - КБ,
поэтому все выбранные ниже отделы считаются совмещениями.
Для каждого совмещаемого отдела можно указать отдельную должность
и ставку.
</p>
) : (
<p className="mut sm">
Сотрудник может числиться в нескольких отделах. Отметьте основное
подразделение, если оно есть. Для дополнительных (совмещаемых)
отделов укажите отдельную должность и ставку.
</p>
)}
<div className="roles-list">
{db.departments.map(d => {
const cur = selections.find(x => x.deptId === d.id);
const kb = db.kbs.find(k => k.id === d.kbId);
const isPrimary = !isKbChief && cur && cur.primary;
const isExtra = cur && !isPrimary;
return (
<div key={d.id} className="mb-8">
<label className="roles-item roles-item-clean">
<input type="checkbox" checked={!!cur} onChange={() => toggleDept(d.id)} />
<span style={{ flex: 1 }}>
{d.name}{' '}
<span className="mut sm">
{kb ? `· ${kb.name}` : '· вне КБ'}
</span>
</span>
{!isKbChief && cur && (
<button
type="button"
className={'btn ghost sm' + (isPrimary ? ' prim-btn' : '')}
onClick={(e) => { e.preventDefault(); setPrimary(d.id); }}
>
{isPrimary ? 'основное ✓' : 'сделать основным'}
</button>
)}
</label>
{isExtra && (
<>
<div className="extra-position-field">
<label className="lbl" style={{ margin: 0, fontSize: 12 }}>
Должность в этом отделе (дополнительная):
</label>
<input
className="inp extra-position-input"
value={cur.position || ''}
onChange={(e) => setPosition(d.id, e.target.value)}
placeholder="Например: Ведущий инженер (совмещение)"
/>
</div>
<div className="extra-position-field">
<FormField
label="Ставка"
type="number"
min="0.1"
max="1"
step="0.1"
value={cur.rate ?? 1}
onChange={(v) => setRate(d.id, v)}
inline
/>
</div>
</>
)}
</div>
);
})}
<div className="mb-8">
<label className="roles-item roles-item-clean">
<input
type="checkbox"
checked={hasExternal}
onChange={toggleExternal}
/>
<span style={{ flex: 1 }}>
Вне отделов{' '}
<span className="mut sm">· должность без привязки к отделу</span>
</span>
</label>
{hasExternal && (
<>
<div className="extra-position-field">
<label className="lbl" style={{ margin: 0, fontSize: 12 }}>
Должность:
</label>
<input
className="inp extra-position-input"
value={externalEntry.position || ''}
onChange={(e) => setPosition(null, e.target.value)}
placeholder="Например: Руководитель проектов"
/>
</div>
<div className="extra-position-field">
<FormField
label="Ставка"
type="number"
min="0.1"
max="1"
step="0.1"
value={externalEntry.rate ?? 1}
onChange={(v) => setRate(null, v)}
inline
/>
</div>
</>
)}
</div>
</div>
</ModalShell>
);
};
+102
View File
@@ -0,0 +1,102 @@
// src/components/Modals/EditEmployeeModal.jsx
import { useCallback } from 'react';
import { ModalShell } from '../ModalShell';
import { EmployeeFormFields } from '../EmployeeFormFields';
import { useModalForm } from '../../hooks/useModalForm';
import { hasRole } from '../../utils/permissions';
import { effectiveEmployeeRate } from '../../utils/employeeRate';
const FIELDS = Object.freeze([
'last', 'first', 'email', 'position', 'extension', 'tab', 'newPass',
]);
export const EditEmployeeModal = ({ store, ur, employeeId, onClose, toast }) => {
const db = store.data;
const emp = db.employees.find(e => e.id === employeeId);
if (!emp) return null;
const initialValues = {
last: emp.last || '',
first: emp.first || '',
email: emp.email || '',
position: emp.position || 'Сотрудник',
extension: emp.extension || '',
tab: emp.tab || '',
newPass: '',
};
const validate = useCallback((values) => {
const errors = {};
if (!values.last.trim()) errors.last = 'Фамилия обязательна';
if (!values.first.trim()) errors.first = 'Имя обязательно';
if (!values.email.trim()) errors.email = 'E-mail обязателен';
if (values.newPass && values.newPass.length < 8) errors.newPass = 'Пароль должен быть не менее 8 символов';
const employees = store.data.employees;
if (employees.some(e => e.email === values.email && e.id !== employeeId)) {
errors.email = 'Этот email уже используется другим сотрудником';
}
if (values.newPass && emp.passwordHistory?.some(p => p === values.newPass)) {
errors.newPass = 'Этот пароль уже использовался';
}
return errors;
}, [emp, employeeId, store]);
const isAdmin = hasRole(ur, 'admin');
const saveAsync = useCallback(async (vals) => {
const updatedEmp = {
...emp,
last: vals.last.trim(),
first: vals.first.trim(),
email: vals.email.trim(),
position: vals.position.trim() || 'Сотрудник',
extension: vals.extension || '',
tab: vals.tab || '',
};
if (isAdmin && vals.newPass.trim()) {
updatedEmp.pass = vals.newPass.trim();
const history = emp.passwordHistory || [];
updatedEmp.passwordHistory = [...history.slice(-4), vals.newPass.trim()];
}
await store.upsertEmployee(updatedEmp);
store.addAudit('Редактирование сотрудника', `${updatedEmp.last} ${updatedEmp.first}`);
onClose();
}, [emp, isAdmin, store, onClose]);
const {
values, handleChange, errors, touched,
handleSubmit, isSubmitting,
} = useModalForm(initialValues, validate, saveAsync, (error) => {
toast(error.message || 'Ошибка редактирования сотрудника', 'error');
}, { fields: FIELDS });
// Эффективная ставка — read-only. Считается от departments
// (primary даёт 1; при отсутствии primary — сумма совмещений,
// обрезанная до 1). Строку показываем только при ≠ 1.
const effRate = effectiveEmployeeRate(emp);
return (
<ModalShell
title={`Редактирование сотрудника - ${emp.last} ${emp.first}`}
onClose={onClose}
onSave={handleSubmit()}
saveLabel="Сохранить"
width={560}
className="modal-employee"
saveDisabled={isSubmitting}
>
<EmployeeFormFields
values={values}
onChange={handleChange}
errors={errors}
touched={touched}
showPassword={isAdmin}
/>
{effRate !== 1 && (
<div className="info-box mt-3">
Эффективная ставка: <b>{effRate} ст.</b>
</div>
)}
</ModalShell>
);
};
+186
View File
@@ -0,0 +1,186 @@
// src/components/Modals/EmployeeCardModal.jsx
import { Fragment, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { ModalShell } from '../ModalShell';
import { FormField } from '../FormField';
import { DOMAIN, substitutionKindLabel } from '../../utils/constants';
import { buildEmployeePositions } from '../../utils/helpers';
import { getSubstitutionInfo } from '../../utils/substitution';
import { fmtDMY } from '../../utils/date';
import { useNestedModalEscape } from '../Templates/useNestedModalEscape';
const NOOP = () => {};
/**
* Read-only карточка сотрудника.
*
* Справочная модалка: ФИО, должности (основная + совмещения),
* подразделения (основное + совмещения), внутренний телефон, e-mail.
* Ничего не редактирует, не сохраняет, не знает о сторе и о правах.
*
* Одна на два сценария: клик по строке в «Персонале» и клик по
* аватару в любом месте приложения.
*
* MOD: у каждой не-primary должности рядом с её названием рендерится
* бейдж .pos-tag с эффективной ставкой записи («0.5 ст.»), если ставка
* ≠ 1. У primary бейдж не рендерится: ставка primary = 1 по доменному
* правилу, показывать её шумом. Бейдж ставится под FormField через
* проп below — так он остаётся внутри той же колонки, что и поле,
* и не разъезжается с разметкой строки.
*/
export const EmployeeCardModal = ({ employee, db, onClose }) => {
useNestedModalEscape(onClose);
const activeSubs = useMemo(() => {
if (!employee) return [];
return getSubstitutionInfo(employee)
.slice()
.sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : 0));
}, [employee]);
if (!employee) return null;
const positions = buildEmployeePositions(employee, db);
const primary = positions.find(p => p.kind === 'primary') || null;
const extras = positions.filter(p => p.kind === 'extra');
const emailFull = employee.email ? `${employee.email}@${DOMAIN}` : '';
const substitutionRows = activeSubs.map((sub) => {
const fromEmp = db?.employees?.find((e) => e.id === sub.fromEmpId);
const fromName = fromEmp ? `${fromEmp.last} ${fromEmp.first}` : sub.fromEmpId;
const periodLabel = sub.end
? `${fmtDMY(sub.start)} – ${fmtDMY(sub.end)}`
: `${fmtDMY(sub.start)} – до отмены`;
return {
id: sub.id,
fromName,
kindLabel: substitutionKindLabel(sub.kind),
periodLabel,
tasksLabel: sub.delegateTasks ? 'переданы' : 'только роли',
};
});
const substitutionBlock = substitutionRows.length > 0 ? (
<>
<div className="rep-panel-title">Замещение и делегирование</div>
{substitutionRows.map((row) => (
<Fragment key={row.id}>
<FormField label="Замещает" value={row.fromName} onChange={NOOP} disabled inline />
<FormField label="Повод" value={row.kindLabel} onChange={NOOP} disabled inline />
<FormField label="Период" value={row.periodLabel} onChange={NOOP} disabled inline />
<FormField label="Задачи" value={row.tasksLabel} onChange={NOOP} disabled inline />
</Fragment>
))}
</>
) : null;
const footer = (
<div className="modal-foot">
<div className="spacer" />
<button type="button" className="btn ghost" onClick={onClose}>
Закрыть
</button>
</div>
);
const modal = (
<ModalShell
title="Карточка сотрудника"
subtitle={employee.fired ? 'Уволен' : null}
onClose={onClose}
width={560}
footer={footer}
showSave={false}
>
<div className="project-info-fields">
<FormField
label="Фамилия"
value={employee.last || '—'}
onChange={NOOP}
disabled
inline
/>
<FormField
label="Имя"
value={employee.first || '—'}
onChange={NOOP}
disabled
inline
/>
<FormField
label="Основная должность"
value={primary?.position || employee.position || '—'}
onChange={NOOP}
disabled
inline
/>
<FormField
label="Основное подразделение"
value={primary?.orgName || '—'}
onChange={NOOP}
disabled
inline
/>
{extras.map((p, i) => {
const suffix = i === 0 ? '' : ` ${i + 1}`;
// Бейдж .pos-tag с эффективной ставкой записи. У primary
// ставки нет по правилу; extras без явного rate в
// buildEmployeePositions получают 1, и тогда бейдж не рисуем.
const rateBadge = p.rate != null && p.rate !== 1
? <span className="pos-tag">{p.rate} ст.</span>
: null;
return (
<Fragment key={i}>
<FormField
label={`Должность совм${suffix}`}
value={p.position || '—'}
onChange={NOOP}
disabled
inline
below={rateBadge}
/>
<FormField
label={`Подразделение совм${suffix}`}
value={p.orgName || '—'}
onChange={NOOP}
disabled
inline
/>
</Fragment>
);
})}
<FormField
label="Внутренний телефон"
value={employee.extension || '—'}
onChange={NOOP}
disabled
inline
/>
<FormField
label="E-mail"
value={emailFull || '—'}
onChange={NOOP}
disabled
inline
/>
{substitutionBlock}
</div>
</ModalShell>
);
return createPortal(
<div
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{modal}
</div>,
document.body,
);
};
export default EmployeeCardModal;
@@ -0,0 +1,96 @@
// src/components/Modals/EmployeeTasksModal.jsx
import { useMemo } from 'react';
import { ModalShell } from '../ModalShell';
import { useDataHelpers } from '../../hooks';
import { isCountableTask } from '../../utils/workloadFilters';
import { fmtDMY } from '../../utils/date';
/**
* Модалка со списком задач, назначенных сотруднику.
*
* Открывается через useModals.openEmployeeTasks(empId). Локального
* состояния не имеет - состояние «какая модалка открыта» живёт в
* useModals/ModalRenderer, как для всех остальных модалок.
*
* Отбор задач - через isCountableTask (utils/workloadFilters): тот же
* предикат, что в DataStore.getWorkload. Цифры в модалке и в строке
* дашборда всегда сходятся, потому что правило одно.
*
* Клик по задаче открывает полную карточку. Карточка знает про возврат
* сюда через returnToEmployeeTasksId - при её закрытии ModalRenderer
* снова вызовет openEmployeeTasks с тем же empId.
*/
export default function EmployeeTasksModal({ db, employeeId, openTask, onClose }) {
const { getTaskSpent } = useDataHelpers(db);
const employee = useMemo(
() => db.employees.find(e => e.id === employeeId) || null,
[db.employees, employeeId],
);
const tasks = useMemo(
() => db.tasks.filter(
t => t.assigneeId === employeeId && isCountableTask(t)
),
[db.tasks, employeeId],
);
const projectName = (projectId) =>
db.projects.find(p => p.id === projectId)?.name || '-';
const handleTaskClick = (taskId) => {
openTask(
taskId,
'form',
null, null, null, null, null, null,
employeeId, // returnToEmployeeTasksId
);
};
return (
<ModalShell
title={`Задачи сотрудника: ${employee ? `${employee.last} ${employee.first}` : '-'}`}
onClose={onClose}
width={880}
showSave={false}
>
{tasks.length === 0 ? (
<div className="empty-note p-4">Нет активных задач</div>
) : (
<div className="w-full overflow-x-auto">
<table className="tbl employee-tasks-table">
<thead>
<tr>
<th className="text-left">Название задачи</th>
<th className="text-left">Проект</th>
<th className="text-center">План</th>
<th className="text-center">Факт</th>
<th className="text-center">Срок</th>
</tr>
</thead>
<tbody>
{tasks.map(t => (
<tr key={t.id}>
<td className="text-left">
<button
type="button"
className="link"
onClick={() => handleTaskClick(t.id)}
title="Открыть задачу"
>
{t.title}
</button>
</td>
<td className="text-left">{projectName(t.projectId)}</td>
<td className="text-center">{t.plannedHours ?? '-'}</td>
<td className="text-center">{getTaskSpent(t)}</td>
<td className="text-center">{t.deadline ? fmtDMY(t.deadline) : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</ModalShell>
);
}
+98
View File
@@ -0,0 +1,98 @@
// src/components/Modals/NoteEditorModal.jsx
import { useState } from 'react';
import { ModalShell } from '../ModalShell';
import { FormField } from '../FormField';
import { Ic, ICONS } from '../Icons';
import { useConfirm } from '../../context/ConfirmContext';
import { DIALOGS } from '../../utils/constants';
import { useNestedModalEscape } from '../Templates/useNestedModalEscape';
/**
* Модалка редактора личной заметки. Открывается поверх TaskModal при
* клике на плитку заметки (или на кнопку «Новая заметка»). Это вложенная
* модалка, а не сущность уровня приложения, - поэтому она не проходит
* через useModals/ModalRenderer, а живёт локальным состоянием TaskModal.
*
* useNestedModalEscape гарантирует, что Escape закроет именно этот
* редактор, а не TaskModal, лежащий под ним: обработчик ставится в
* capture-фазе и гасит событие до того, как оно дойдёт до bubble-обработчика
* Modal.jsx.
*
* Кнопка «Назад» - через showBack у ModalShell: то же поведение, что
* у TaskModal и ProjectModal, тот же внешний вид, никакого дублирования
* разметки.
*/
export default function NoteEditorModal({ note, onSave, onDelete, onClose }) {
useNestedModalEscape(onClose);
const { confirm } = useConfirm();
const isExisting = !!note?.id;
const [title, setTitle] = useState(note?.title || '');
const [text, setText] = useState(note?.text || '');
const canSave = !!(title.trim() || text.trim());
const handleSave = () => {
if (!canSave) return;
onSave({
id: note?.id,
title: title.trim(),
text: text.trim(),
});
};
const handleDelete = async () => {
if (!isExisting) return;
const ok = await confirm(DIALOGS.deleteNote);
if (!ok) return;
onDelete(note.id);
};
const footer = (
<div className="modal-foot">
{isExisting && (
<button type="button" className="btn danger" onClick={handleDelete}>
<Ic d={ICONS.trash} size={14} /> Удалить
</button>
)}
<div className="spacer" />
<button type="button" className="btn ghost" onClick={onClose}>Отмена</button>
<button
type="button"
className="btn primary"
onClick={handleSave}
disabled={!canSave}
>
Сохранить
</button>
</div>
);
return (
<ModalShell
title={isExisting ? 'Заметка' : 'Новая заметка'}
onClose={onClose}
width={560}
footer={footer}
showBack
>
<div className="project-info-fields">
<FormField
label="Заголовок"
value={title}
onChange={setTitle}
inline
/>
<FormField
label="Текст"
type="textarea"
rows={8}
value={text}
onChange={setText}
inline
/>
</div>
</ModalShell>
);
}
@@ -0,0 +1,153 @@
// src/components/Modals/ProjectAccessModal.jsx
import { useCallback, useMemo, useState } from 'react';
import { ModalShell } from '../ModalShell';
import Avatar from '../Avatar';
import { SearchBox } from '../SearchBox';
import { canSeeProjectByDefault } from '../../utils/permissions';
import { useNestedModalEscape } from '../Templates/useNestedModalEscape';
/**
* Окно управления персональным доступом к проекту.
*
* Сотрудники, которые видят проект по своим ролям, задачам или КБ
* (canSeeProjectByDefault), отмечены и защищены от снятия: это «доступ
* по роли», он управляется ролями сотрудника, а не этим окном. Остальные
* сотрудники - обычные чекбоксы, чьё состояние хранится в
* project.access.userIds.
*
* Модалка не дублирует логику прав: всё, что нужно для разграничения
* «locked / editable», берётся из canSeeProjectByDefault. Запись идёт
* через store.setProjectAccess - сервис сам проверит право на запись
* тем же предикатом, что использовался для показа кнопки «Доступ».
*/
export const ProjectAccessModal = ({ db, projectId, onClose, toast, store }) => {
useNestedModalEscape(onClose);
const project = db.projects.find(p => p.id === projectId);
const [userIds, setUserIds] = useState(() => {
const initial = project?.access?.userIds;
return Array.isArray(initial) ? [...initial] : [];
});
const [query, setQuery] = useState('');
const activeEmployees = useMemo(
() => db.employees.filter(e => !e.fired),
[db.employees]
);
// Один проход по сотрудникам - определяем, у кого доступ уже есть
// по роли/КБ/задачам. Дальше в рендере - только проверка по Set.
const lockedIds = useMemo(() => {
if (!project) return new Set();
const set = new Set();
for (const emp of activeEmployees) {
if (canSeeProjectByDefault(emp, project, db)) set.add(emp.id);
}
return set;
}, [activeEmployees, project, db]);
// Поиск по ФИО и должности. Фильтруем только для отображения -
// lockedIds и userIds остаются полными: снятие галочки с найденного
// сотрудника невозможно, а состояние ненайденных не теряется.
const visibleEmployees = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return activeEmployees;
return activeEmployees.filter(emp =>
`${emp.last} ${emp.first}`.toLowerCase().includes(q) ||
(emp.position || '').toLowerCase().includes(q)
);
}, [activeEmployees, query]);
const toggleUser = useCallback((id) => {
setUserIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
}, []);
const save = useCallback(() => {
try {
const prevIds = Array.isArray(project?.access?.userIds)
? project.access.userIds
: [];
const namesOf = (ids) => ids
.map(id => db.employees.find(e => e.id === id))
.filter(Boolean)
.map(e => `${e.last} ${e.first}`);
const addedNames = namesOf(userIds.filter(id => !prevIds.includes(id)));
const removedNames = namesOf(prevIds.filter(id => !userIds.includes(id)));
store.setProjectAccess(projectId, { userIds });
// Один тост на все случаи. Если пользователь и открыл, и закрыл
// доступ в одном сохранении - обе части уходят в одну строку через
// "; ". Два отдельных тоста подряд здесь были бы шумом: пользователь
// совершил одно действие - одно подтверждение.
const parts = [];
if (addedNames.length) parts.push(`открыт для ${addedNames.join(', ')}`);
if (removedNames.length) parts.push(`закрыт для ${removedNames.join(', ')}`);
toast(
parts.length
? `Доступ к проекту ${parts.join('; ')}`
: 'Доступ к проекту обновлён',
'success',
);
onClose();
} catch (error) {
toast(error.message || 'Не удалось обновить доступ', 'error');
}
}, [projectId, userIds, project, db.employees, store, toast, onClose]);
if (!project) return null;
return (
<ModalShell
title={`Доступ к проекту: ${project.name}`}
onClose={onClose}
onSave={save}
saveLabel="Сохранить"
width={560}
className="modal-access"
>
<div className="access-section">
<div className="access-section-head">
<span className="access-section-title">Сотрудники</span>
<span className="access-section-count">{userIds.length} с явным доступом</span>
</div>
<SearchBox
value={query}
onChange={setQuery}
placeholder="Поиск по ФИО или должности…"
className="access-search"
/>
<div className="access-list">
{visibleEmployees.length === 0 ? (
<div className="access-empty">Ничего не найдено</div>
) : (
visibleEmployees.map(emp => {
const locked = lockedIds.has(emp.id);
const checked = locked || userIds.includes(emp.id);
return (
<label key={emp.id} className="access-item">
<input
type="checkbox"
checked={checked}
disabled={locked}
onChange={() => toggleUser(emp.id)}
/>
<Avatar employee={emp} size="xs" />
<span className="access-item-name">{emp.last} {emp.first}</span>
<span className="access-item-note">
{locked ? 'доступ по роли' : emp.position}
</span>
</label>
);
})
)}
</div>
</div>
</ModalShell>
);
};
@@ -0,0 +1,95 @@
// src/components/Modals/ProjectFooterActions.jsx
import { Ic, ICONS } from '../Icons';
import { TemplateActions } from '../Templates';
import { collectTaskPayloads } from '../../utils/templateNesting';
import { hasRole } from '../../utils/permissions';
/**
* Левый блок футера карточки проекта: удаление, копирование, «В шаблон»,
* управление доступом, «В черновики».
*
* Правая часть (spacer + «Отмена» + «Создать/Сохранить») собирается в
* ProjectModal.
*
* Показывает только действия; сами предикаты (hasRole, canXxx) считаются
* в ProjectModal и приходят готовыми булевыми флагами. Так футер не знает
* ни о ролях, ни о структуре db.
*/
export function ProjectFooterActions({
readOnly,
existing,
ur,
canCreateFromProject,
canManageAccess,
values,
db,
toast,
onDelete,
onCopy,
onOpenAccess,
disabled = false,
onSaveAsDraft = null,
saveAsDraftDisabled = false,
}) {
return (
<>
{!readOnly && existing && hasRole(ur, 'admin') && (
<button
className="btn danger"
onClick={onDelete}
disabled={disabled}
>
<Ic d={ICONS.trash} size={14} /> Удалить проект
</button>
)}
{existing && onCopy && canCreateFromProject && (
<button
type="button"
className="btn ghost sm"
onClick={() => onCopy(existing.id)}
title="Создать новый проект на основе этого"
disabled={disabled}
>
<Ic d={ICONS.copy} size={13} /> Копировать
</button>
)}
{canCreateFromProject && (
<TemplateActions
kind="project"
source={values}
nested={existing ? collectTaskPayloads(
db.tasks.filter(t => t.projectId === existing.id && !t.archived),
) : []}
toast={toast}
disabled={disabled || !values.name?.trim()}
/>
)}
{canManageAccess && (
<button
type="button"
className="btn ghost sm"
onClick={onOpenAccess}
title="Управление доступом к проекту"
disabled={disabled}
>
<Ic d={ICONS.shield} size={13} /> Доступ
</button>
)}
{onSaveAsDraft && (
<button
type="button"
className="btn ghost sm"
onClick={onSaveAsDraft}
disabled={disabled || saveAsDraftDisabled}
title="Сохранить как личный черновик"
>
<Ic d={ICONS.edit} size={13} /> В черновики
</button>
)}
</>
);
}
+951
View File
@@ -0,0 +1,951 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "../../hooks/useForm";
import { useDataHelpers } from "../../hooks/useDataHelpers";
import { useConfirm } from "../../context/ConfirmContext";
import { useControlledTab } from "../../hooks/useControlledTab";
import { useStableModalHeight } from "../../hooks/useStableModalHeight";
import { useChatUnreadCount, useChatTotalCount } from "../../hooks/useChatStats";
import { normalizeFormStatus } from "../../hooks/useTaskFormState";
import { ModalShell } from "../ModalShell";
import { Tabs } from "../Tabs";
import { FormField } from "../FormField";
import { ProjectGallery } from "../ProjectGallery";
import { FileManager } from "../FileManager";
import { TaskTable } from "../TaskTable";
import { Lightbox } from "../Lightbox";
import { HistoryTab } from "../HistoryTab";
import { ProjectChat } from "../ProjectChat";
import { ProjectAccessModal } from "./ProjectAccessModal";
import { ProjectFooterActions } from "./ProjectFooterActions";
import { TemplateSelect } from "../Templates/TemplateSelect";
import {
canEditProjectFields,
canCreateProject,
canChangeProjectStatus,
canManageManager,
canManageProjectAccess,
canSeeEconomics,
canEditProjectField, // NEW
hasRole,
} from "../../utils/permissions";
import {
PROJECT_STATUSES,
PROJECT_TYPES,
FILE_LIMITS,
FILE_MESSAGES,
TOASTS,
DIALOGS,
priorityOptionsForProjectType,
withSyncedPriority,
} from "../../utils/constants";
import { TODAY, iso, addDays, fmtDMY, uid } from "../../utils/date";
import { isArchived } from "../../utils/entityState";
import { getProjectColor } from "../../utils/projectHelpers";
import { unitOptionsForPtype, sanitizeUnitIdsForPtype } from "../../utils/projectUnits";
import { collectTaskPayloads } from "../../utils/templateNesting";
import { applyTemplatePayload } from "../../utils/templateSchemas";
import { prepareAttachments } from "../../utils/fileUpload";
import { createFolder } from "../../utils/fileTree";
import { buildApprovalHint } from "../../utils/assignmentApproval";
const AIRCRAFT_TYPES = ["Су-57", "МиГ-35", "Ту-160", "Ил-76", "Ка-52", "Другой"];
const PROJECT_TYPE_OPTIONS = ["Ремонт", "Модификация", "КС", "ИКУ"];
const FORM_FIELDS = Object.freeze([
"name",
"code",
"desc",
"ptype",
"customer",
"aircraftType",
"projectType",
"priority",
"unitIds",
"managerId",
"start",
"end",
"plannedHours",
"status",
"longterm",
"contractNumber",
"hourlyRate",
]);
const readFileAsPhoto = (file, uploaderId) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (ev) =>
resolve({
id: uid(),
name: file.name,
url: ev.target.result,
uploadedBy: uploaderId,
uploadedAt: new Date().toISOString(),
isMain: false,
});
reader.onerror = () => reject(new Error("Ошибка чтения файла"));
reader.readAsDataURL(file);
});
const applyPtypeChange = (prev, nextPtype, db) => {
const next = withSyncedPriority(prev, nextPtype);
next.unitIds = sanitizeUnitIdsForPtype(next.unitIds, next.ptype, db);
if (next.ptype === "admin") {
next.contractNumber = "";
next.hourlyRate = "";
}
return next;
};
function buildInitialValues({ existing, isCopy, copySource, isNew, ur }) {
if (existing) {
return {
...existing,
status: normalizeFormStatus(existing, "active"),
contractNumber: existing.contractNumber ?? "",
hourlyRate: existing.hourlyRate ?? "",
};
}
if (isCopy) {
return {
...copySource,
id: "p_" + uid(),
managerId: "",
status: "active",
archived: false,
archivedAt: null,
closedAt: null,
creatorId: ur.id,
history: [{ ts: Date.now(), who: ur.id, text: `Скопирован из «${copySource.name}»` }],
access: { userIds: [] },
files: [],
folders: [],
photos: [],
contractNumber: "",
hourlyRate: "",
};
}
return {
id: "p_" + uid(),
code: "",
name: "",
desc: "",
unitIds: [],
managerId: "",
start: TODAY,
end: iso(addDays(new Date(), 30)),
status: "active",
plannedHours: 100,
contractNumber: "",
hourlyRate: "",
color: "#64748b",
ptype: "prod",
longterm: false,
archived: false,
archivedAt: null,
closedAt: null,
creatorId: ur.id,
customer: "",
aircraftType: "",
projectType: "",
priority: "NORM",
history: [{ ts: Date.now(), who: ur.id, text: "Проект создан" }],
access: { userIds: [] },
files: [],
folders: [],
photos: [],
};
}
export const ProjectModal = ({
db,
ur,
projectId,
initialTab = "info",
copyFromId,
returnToProjectId,
highlightFileId = null,
highlightFolderId = null,
onClose,
onSave,
onDelete,
onCopy,
toast,
openTask,
store,
onTabChange,
}) => {
const { empName, getTaskSpent } = useDataHelpers(db);
const { confirm } = useConfirm();
const existing = projectId ? db.projects.find((p) => p.id === projectId) : null;
const copySource = copyFromId ? db.projects.find((p) => p.id === copyFromId) : null;
const isCopy = !existing && !!copySource;
const isNew = !existing;
const readOnly = !!(existing && isArchived(existing));
const canEditFields = !readOnly && (existing ? canEditProjectFields(ur, existing) : canCreateProject(ur));
// NEW: отдельный предикат для пары contractNumber/hourlyRate.
// Для admin/director он совпадает с canEditFields; для бухгалтера —
// единственный путь сделать эти поля редактируемыми. Для нового
// проекта existing === null, поэтому false — бухгалтер не создаёт проекты.
const canEditEconomicsFields =
!readOnly && !!existing && canEditProjectField(ur, existing, "contractNumber");
const canChangeStatus = !readOnly && existing && canChangeProjectStatus(ur, existing, null);
const canChangeManager = !readOnly && (existing ? canManageManager(ur) : canCreateProject(ur));
const canCreateFromProject = canCreateProject(ur);
const canManageAccess = !readOnly && existing && canManageProjectAccess(ur, existing);
const isFinancier = canSeeEconomics(ur);
const [lightboxIndex, setLightboxIndex] = useState(null);
const [appliedTemplateName, setAppliedTemplateName] = useState(null);
const [accessOpen, setAccessOpen] = useState(false);
const [pendingTemplateTasks, setPendingTemplateTasks] = useState(() =>
isCopy ? collectTaskPayloads(db.tasks.filter((t) => t.projectId === copySource.id && !t.archived)) : []
);
const [isSaving, setIsSaving] = useState(false);
const savingRef = useRef(false);
const runningRef = useRef(false);
const valuesRef = useRef(null);
const runExclusive = useCallback(async (fn) => {
if (runningRef.current) return;
runningRef.current = true;
try {
return await fn();
} finally {
runningRef.current = false;
}
}, []);
const [activeTab, handleTabChange] = useControlledTab(initialTab, onTabChange);
const bodyRef = useStableModalHeight(activeTab);
const {
values,
handleChange: rawHandleChange,
handleSubmit,
errors,
touched,
setFieldValue,
setValues,
isValid,
isDirty,
} = useForm(
buildInitialValues({ existing, isCopy, copySource, isNew, ur }),
useCallback((values) => {
const errors = {};
if (!values.name?.trim()) errors.name = "Название обязательно";
if (!values.code?.trim()) errors.code = "Код обязателен";
if (!values.start) errors.start = "Дата начала обязательна";
if (!values.customer?.trim()) errors.customer = "Заказчик обязателен";
const isAdmin = values.ptype === "admin";
if (!isAdmin) {
if (!values.aircraftType) errors.aircraftType = "Выберите тип ВС";
if (!values.projectType) errors.projectType = "Выберите категорию";
if (!values.managerId) errors.managerId = "Ответственный обязателен";
if (!values.unitIds || values.unitIds.length === 0) errors.unitIds = "Выберите хотя бы одно КБ";
if (!values.end) errors.end = "Дата окончания обязательна";
}
const planFilled =
values.plannedHours !== "" && values.plannedHours !== null && values.plannedHours !== undefined;
if (isAdmin) {
if (planFilled && (isNaN(+values.plannedHours) || +values.plannedHours < 0 || +values.plannedHours > 1000000)) {
errors.plannedHours = "Плановые часы: число от 0 до 1 000 000";
}
} else if (!planFilled || isNaN(+values.plannedHours) || +values.plannedHours < 0 || +values.plannedHours > 1000000) {
errors.plannedHours = "Плановые часы обязательны (0 … 1 000 000)";
}
const contractRaw = String(values.contractNumber ?? "").trim();
if (contractRaw) {
if (contractRaw.length < 3) errors.contractNumber = "Номер договора — не короче 3 символов";
else if (!/^[A-Za-z0-9\-/.\s]+$/.test(contractRaw))
errors.contractNumber = "Недопустимые символы в номере договора";
}
if (values.hourlyRate !== "" && values.hourlyRate != null) {
const rate = Number(values.hourlyRate);
if (!Number.isFinite(rate) || rate < 0 || rate > 1000000) {
errors.hourlyRate = "Ставка — число от 0 до 1 000 000";
}
}
if (!values.priority) errors.priority = "Приоритет обязателен";
if (!values.status) errors.status = "Статус обязателен";
return errors;
}, []),
{ fields: FORM_FIELDS }
);
useEffect(() => {
valuesRef.current = values;
}, [values]);
const isAdminProject = values.ptype === "admin";
const handleChange = (field, value) => {
if (field === "ptype") {
setValues((prev) => applyPtypeChange(prev, value, db));
return;
}
rawHandleChange(field, value);
};
const chatUnread = useChatUnreadCount(ur.id, values.id, null);
const chatTotal = useChatTotalCount(values.id, null);
const draftTasks = useMemo(() => {
if (!isNew || pendingTemplateTasks.length === 0) return [];
return pendingTemplateTasks.map((node, idx) => ({
id: `draft_${idx}`,
_draft: true,
title: node.title || "Без названия",
assigneeId: null,
status: "new",
plannedHours: node.plannedHours ?? null,
priority: node.priority || "mid",
deadline: null,
logs: [],
projectId: values.id,
}));
}, [isNew, pendingTemplateTasks, values.id]);
const applyTemplate = useCallback(
(template) => {
if (!template) {
setAppliedTemplateName(null);
setPendingTemplateTasks([]);
return;
}
const { tasks: nestedTasks, ...projectFields } = applyTemplatePayload("project", template.payload);
Object.keys(projectFields).forEach((field) => setFieldValue(field, projectFields[field]));
setPendingTemplateTasks(Array.isArray(nestedTasks) ? nestedTasks : []);
setAppliedTemplateName(template.name);
},
[setFieldValue]
);
const handlePhotoUpload = useCallback(
(files, onDone) =>
runExclusive(async () => {
try {
const currentPhotos = valuesRef.current.photos || [];
const valid = [];
for (const file of files) {
if (!file.type.startsWith("image/")) {
toast(FILE_MESSAGES.notImage, "error");
continue;
}
if (file.size > FILE_LIMITS.image) {
toast(FILE_MESSAGES.imageTooLarge, "error");
continue;
}
valid.push(file);
}
if (valid.length === 0) return;
const newPhotos = await Promise.all(valid.map((file) => readFileAsPhoto(file, ur.id)));
if (currentPhotos.length === 0 && newPhotos.length > 0) newPhotos[0].isMain = true;
const updatedPhotos = [...currentPhotos, ...newPhotos];
setFieldValue("photos", updatedPhotos);
if (existing) store.patchProject(existing.id, { photos: updatedPhotos });
toast(`Загружено фото: ${newPhotos.length}`, "success");
} catch (err) {
toast(err.message || "Ошибка загрузки фото", "error");
} finally {
onDone?.();
}
}),
[runExclusive, existing, store, setFieldValue, toast, ur.id]
);
const handlePhotoDelete = useCallback(
(photoId) =>
runExclusive(async () => {
if (!(await confirm(DIALOGS.deleteProjectPhoto))) return;
const currentPhotos = valuesRef.current.photos || [];
const removed = currentPhotos.find((p) => p.id === photoId);
const updatedPhotos = currentPhotos.filter((p) => p.id !== photoId);
if (removed?.isMain && updatedPhotos.length > 0) {
updatedPhotos[0] = { ...updatedPhotos[0], isMain: true };
}
setFieldValue("photos", updatedPhotos);
if (existing) store.patchProject(existing.id, { photos: updatedPhotos });
toast(TOASTS.photoDeleted, "info");
}),
[runExclusive, existing, store, setFieldValue, toast, confirm]
);
const handleSetMain = useCallback(
(photoId) => {
const updatedPhotos = (valuesRef.current.photos || []).map((p) => ({ ...p, isMain: p.id === photoId }));
setFieldValue("photos", updatedPhotos);
if (existing) store.patchProject(existing.id, { photos: updatedPhotos });
toast("Главное фото обновлено", "success");
},
[existing, store, setFieldValue, toast]
);
const handleOpenLightbox = useCallback((index) => setLightboxIndex(index), []);
const handleCloseLightbox = useCallback(() => setLightboxIndex(null), []);
const handlePrevPhoto = useCallback(() => {
if (lightboxIndex === null || lightboxIndex === undefined) return;
const photos = valuesRef.current.photos || [];
setLightboxIndex((prev) => (prev === 0 ? photos.length - 1 : prev - 1));
}, [lightboxIndex]);
const handleNextPhoto = useCallback(() => {
if (lightboxIndex === null || lightboxIndex === undefined) return;
const photos = valuesRef.current.photos || [];
setLightboxIndex((prev) => (prev === photos.length - 1 ? 0 : prev + 1));
}, [lightboxIndex]);
const buildSavePayload = useCallback(
(vals, overrides = {}) => {
const isAdmin = vals.ptype === "admin";
const contractNumber = !isFinancier || isAdmin ? null : String(vals.contractNumber ?? "").trim() || null;
const hourlyRate =
!isFinancier || isAdmin
? null
: vals.hourlyRate === "" || vals.hourlyRate == null
? null
: Number(vals.hourlyRate);
return {
...vals,
color: getProjectColor(vals),
plannedHours: vals.plannedHours ? +vals.plannedHours : null,
contractNumber,
hourlyRate,
managerId: isAdmin ? "" : vals.managerId,
end: vals.end || null,
...overrides,
};
},
[isFinancier]
);
const saveHandler = useCallback(
(vals) => {
if (savingRef.current) return;
savingRef.current = true;
setIsSaving(true);
try {
onSave(buildSavePayload(vals), isNew, { templateTasks: pendingTemplateTasks });
} finally {
savingRef.current = false;
setIsSaving(false);
}
},
[buildSavePayload, isNew, onSave, pendingTemplateTasks]
);
const saveAsDraftHandler = useCallback(() => {
if (savingRef.current) return;
savingRef.current = true;
setIsSaving(true);
try {
onSave(buildSavePayload(valuesRef.current, { status: "draft", pendingApprovalId: null }), isNew, {});
} finally {
savingRef.current = false;
setIsSaving(false);
}
}, [buildSavePayload, isNew, onSave]);
const deleteHandler = useCallback(async () => {
if (savingRef.current) return;
savingRef.current = true;
setIsSaving(true);
try {
if (!(await confirm(DIALOGS.deleteProject(existing.name)))) return;
onDelete(existing.id);
} finally {
savingRef.current = false;
setIsSaving(false);
}
}, [existing, onDelete, confirm]);
const filesCount = values.files?.length || 0;
const taskList = useMemo(() => {
if (!existing) return [];
return db.tasks.filter((t) => t.projectId === projectId && !t.archived);
}, [db.tasks, projectId, existing]);
const displayedTasks = useMemo(() => [...draftTasks, ...taskList], [draftTasks, taskList]);
const tabs = [
{ id: "info", label: "Информация" },
{ id: "tasks", label: `Задачи (${displayedTasks.length})` },
{ id: "chat", label: "Чат проекта", count: chatTotal, badge: chatUnread },
{ id: "files", label: `Вложения (${filesCount})` },
...(existing ? [{ id: "hist", label: "История" }] : []),
];
const employeeOptions = useMemo(
() =>
db.employees
.filter((e) => !e.fired)
.map((e) => ({ value: e.id, label: `${e.last} ${e.first}` })),
[db.employees]
);
const unitOptions = useMemo(() => unitOptionsForPtype(values.ptype, db), [values.ptype, db]);
const priorityOptions = useMemo(() => priorityOptionsForProjectType(values.ptype), [values.ptype]);
const statusOptions = useMemo(() => Object.entries(PROJECT_STATUSES).map(([k, v]) => ({ value: k, label: v })), []);
const candidates = useMemo(() => {
const ids = new Set(db.tasks.filter((t) => t.projectId === values.id).map((t) => t.assigneeId).filter(Boolean));
if (values.managerId) ids.add(values.managerId);
return [...ids].map((id) => db.employees.find((e) => e.id === id)).filter(Boolean);
}, [db, values.id, values.managerId]);
const handleFileUpload = useCallback(
(files, folderId = null) =>
runExclusive(async () => {
const current = valuesRef.current;
const result = await prepareAttachments(files, current.files, folderId, ur.id);
if (result.accepted > 0) {
setFieldValue("files", result.nextFiles);
if (existing) store.patchProject(existing.id, { files: result.nextFiles });
toast(
result.accepted === 1
? TOASTS.fileUploaded(result.acceptedFiles[0].name)
: TOASTS.filesUploaded(result.accepted),
"success"
);
}
if (result.rejected > 0) toast(TOASTS.filesRejected(result.errors.join("; ")), "warning");
}),
[runExclusive, existing, store, setFieldValue, toast, ur.id]
);
const handleFileDelete = useCallback(
(fileId) =>
runExclusive(async () => {
if (!(await confirm(DIALOGS.deleteFile))) return;
const updatedFiles = (valuesRef.current.files || []).filter((f) => f.id !== fileId);
setFieldValue("files", updatedFiles);
if (existing) store.patchProject(existing.id, { files: updatedFiles });
toast(TOASTS.fileDeleted, "info");
}),
[runExclusive, existing, store, setFieldValue, toast, confirm]
);
const handleCreateFolder = useCallback(
(name, parentId) => {
const current = valuesRef.current;
const newFolder = createFolder(name, parentId, ur.id);
const updatedFolders = [...(current.folders || []), newFolder];
setFieldValue("folders", updatedFolders);
if (existing) store.patchProject(existing.id, { folders: updatedFolders });
},
[existing, store, setFieldValue, ur.id]
);
const handleDeleteFolder = useCallback(
(folderId) => {
const updatedFolders = (valuesRef.current.folders || []).filter((f) => f.id !== folderId);
setFieldValue("folders", updatedFolders);
if (existing) store.patchProject(existing.id, { folders: updatedFolders });
},
[existing, store, setFieldValue]
);
const approvalHint = useMemo(
() =>
isAdminProject
? null
: buildApprovalHint({
user: ur,
db,
existing,
isNew,
targetType: "project",
entity: values,
}),
[ur, db, existing, isNew, isAdminProject, values]
);
// NEW: учитываем canEditEconomicsFields — иначе для бухгалтера кнопка
// «Сохранить» осталась бы заблокированной, хотя поля contractNumber/
// hourlyRate доступны и форма isDirty.
const saveDisabled =
isSaving ||
!(canEditFields || canEditEconomicsFields || (existing && canChangeStatus) || canChangeManager) ||
(isNew ? !isValid : !isValid || !isDirty);
const showBackButton = !!returnToProjectId;
const modalTitle = readOnly
? "Проект (Архив)"
: existing
? "Карточка проекта"
: isCopy
? `Копирование проекта: ${copySource.name}`
: "Новый проект";
return (
<>
<ModalShell
title={modalTitle}
onClose={onClose}
width={900}
className="modal-project"
actions={
<ProjectFooterActions
readOnly={readOnly}
existing={existing}
ur={ur}
canCreateFromProject={canCreateFromProject}
canManageAccess={canManageAccess}
values={values}
db={db}
toast={toast}
onDelete={deleteHandler}
onCopy={onCopy}
onOpenAccess={() => setAccessOpen(true)}
disabled={isSaving}
onSaveAsDraft={isNew ? saveAsDraftHandler : null}
saveAsDraftDisabled={!values.name?.trim()}
/>
}
saveLabel={isNew ? "Создать проект" : "Сохранить"}
saveDisabled={saveDisabled}
onSave={handleSubmit(saveHandler)}
bodyRef={bodyRef}
showBack={showBackButton}
>
{readOnly && (
<div className="info-box">
{existing.archivedAt
? `Проект в архиве с ${fmtDMY(existing.archivedAt)}. Редактирование запрещено.`
: `Проект ${existing.status === "cancelled" ? "отменён" : "закрыт"}. Редактирование запрещено.`}
</div>
)}
<Tabs tabs={tabs} active={activeTab} onChange={handleTabChange} />
{activeTab === "info" && (
<div className="project-info-layout">
<div className="project-info-photos">
<ProjectGallery
photos={values.photos || []}
onUpload={handlePhotoUpload}
onDelete={handlePhotoDelete}
onSetMain={handleSetMain}
onOpenLightbox={handleOpenLightbox}
canUpload={!readOnly && canEditFields}
canDelete={!readOnly && canEditFields}
employeeName={empName}
/>
</div>
<div className="project-info-fields-wrap">
<div className="project-info-fields">
{isNew && !isCopy && <TemplateSelect kind="project" onApply={applyTemplate} />}
{isNew && appliedTemplateName && (
<div className="info-box">
Применён шаблон: <b>{appliedTemplateName}</b>
</div>
)}
<FormField
label="Название"
required
value={values.name}
onChange={(v) => handleChange("name", v)}
error={touched.name && errors.name}
disabled={!canEditFields}
inline
/>
<FormField
label="Описание"
type="textarea"
rows={2}
value={values.desc}
onChange={(v) => handleChange("desc", v)}
disabled={!canEditFields}
inline
/>
<FormField
label="Заказчик"
required
value={values.customer}
onChange={(v) => handleChange("customer", v)}
error={touched.customer && errors.customer}
disabled={!canEditFields}
inline
/>
<div className="fields-row">
<FormField
label="Тип проекта"
required
type="select"
options={Object.entries(PROJECT_TYPES).map(([k, v]) => ({ value: k, label: v }))}
value={values.ptype}
onChange={(v) => handleChange("ptype", v)}
disabled={!canEditFields}
inline
/>
<FormField
label="Код"
required
value={values.code}
onChange={(v) => handleChange("code", v)}
error={touched.code && errors.code}
disabled={!canEditFields}
inline
/>
</div>
{!isAdminProject && (
<div className="fields-row">
<FormField
label="Тип ВС"
required
type="select"
options={AIRCRAFT_TYPES.map((t) => ({ value: t, label: t }))}
value={values.aircraftType}
onChange={(v) => handleChange("aircraftType", v)}
error={touched.aircraftType && errors.aircraftType}
disabled={!canEditFields}
inline
/>
<FormField
label="Категория"
required
type="select"
options={PROJECT_TYPE_OPTIONS.map((t) => ({ value: t, label: t }))}
value={values.projectType}
onChange={(v) => handleChange("projectType", v)}
error={touched.projectType && errors.projectType}
disabled={!canEditFields}
inline
/>
</div>
)}
<div className="fields-row">
<FormField
label="Приоритет"
required
type="select"
options={priorityOptions}
value={values.priority}
onChange={(v) => handleChange("priority", v)}
error={touched.priority && errors.priority}
disabled={!canEditFields}
inline
/>
<FormField
label="Подразделения"
required={!isAdminProject}
type="select"
multiple
options={unitOptions}
value={values.unitIds}
onChange={(v) => handleChange("unitIds", v)}
error={touched.unitIds && errors.unitIds}
disabled={!canEditFields}
inline
/>
</div>
<div className="fields-row">
<FormField
label="Дата начала"
required
type="date"
value={values.start}
onChange={(v) => handleChange("start", v)}
error={touched.start && errors.start}
disabled={!canEditFields}
inline
/>
<FormField
label="Дата окончания"
required={!isAdminProject}
type="date"
value={values.end}
onChange={(v) => handleChange("end", v)}
error={touched.end && errors.end}
disabled={!canEditFields}
inline
/>
</div>
<div className="fields-row">
<FormField
label="Плановые часы, ч"
required={!isAdminProject}
type="number"
min="0"
step="0.5"
value={values.plannedHours}
onChange={(v) => handleChange("plannedHours", v)}
error={touched.plannedHours && errors.plannedHours}
disabled={!canEditFields}
inline
/>
<FormField
label="Статус"
required
type="select"
options={statusOptions}
value={values.status}
onChange={(v) => handleChange("status", v)}
error={touched.status && errors.status}
disabled={!canChangeStatus}
inline
/>
</div>
{!isAdminProject && isFinancier && (
<div className="fields-row">
{/* NEW: disabled учитывает canEditEconomicsFields.
Для admin/director canEditFields = true → disabled = false (как было).
Для economist — disabled = true (как было).
Для accountant — disabled = false (цель задачи).
Для остальных — disabled = true (как было). */}
<FormField
label="Номер договора"
value={values.contractNumber ?? ""}
onChange={(v) => handleChange("contractNumber", v)}
error={touched.contractNumber && errors.contractNumber}
disabled={!canEditFields && !canEditEconomicsFields}
inline
/>
<FormField
label="Ставка ₽/ч"
type="number"
min="0"
step="0.01"
value={values.hourlyRate ?? ""}
onChange={(v) => handleChange("hourlyRate", v)}
error={touched.hourlyRate && errors.hourlyRate}
disabled={!canEditFields && !canEditEconomicsFields}
inline
/>
</div>
)}
{!isAdminProject && (
<FormField
label="Ответственный"
required
type="select"
options={employeeOptions}
value={values.managerId}
onChange={(v) => handleChange("managerId", v)}
error={touched.managerId && errors.managerId}
disabled={!canChangeManager}
inline
/>
)}
{approvalHint && <div className="info-box">{approvalHint}</div>}
</div>
</div>
</div>
)}
{activeTab === "tasks" && (existing || draftTasks.length > 0) && (
<div className="tm-block">
<div className="subtask-header">
<div className="rep-panel-title">Задачи проекта</div>
{existing && (
<button
className="btn primary sm"
onClick={() => openTask(null, "form", null, existing.id, existing.id)}
disabled={readOnly}
>
Создать задачу
</button>
)}
</div>
<TaskTable
tasks={displayedTasks}
onRowClick={(id) => openTask(id, "form", null, null, existing.id)}
columns={["title", "assignee", "status", "planned", "fact", "deadline"]}
db={db}
getTaskSpent={getTaskSpent}
empName={empName}
/>
</div>
)}
{activeTab === "chat" && (
<ProjectChat
projectId={values.id}
store={store}
currentUser={ur}
toast={toast}
employees={db.employees}
candidates={candidates}
openTask={openTask}
tasks={db.tasks}
readOnly={readOnly}
/>
)}
{activeTab === "files" && (
<FileManager
files={values.files || []}
folders={values.folders || []}
highlightFileId={highlightFileId}
highlightFolderId={highlightFolderId}
onUpload={handleFileUpload}
onDelete={handleFileDelete}
onCreateFolder={handleCreateFolder}
onDeleteFolder={handleDeleteFolder}
canUpload={
!readOnly &&
(canEditFields ||
values.managerId === ur.id ||
hasRole(ur, "admin", "director", "project_manager"))
}
canDelete={
!readOnly &&
(canEditFields ||
values.managerId === ur.id ||
hasRole(ur, "admin", "director", "project_manager"))
}
employeeName={empName}
/>
)}
{activeTab === "hist" && existing && <HistoryTab history={values.history || []} empName={empName} />}
{lightboxIndex !== null && (values.photos || []).length > 0 && (
<Lightbox
photos={values.photos || []}
currentIndex={lightboxIndex}
onClose={handleCloseLightbox}
onPrev={handlePrevPhoto}
onNext={handleNextPhoto}
/>
)}
</ModalShell>
{accessOpen && existing && (
<ProjectAccessModal
db={db}
projectId={existing.id}
store={store}
toast={toast}
onClose={() => setAccessOpen(false)}
/>
)}
</>
);
};
@@ -0,0 +1,90 @@
// src/components/Modals/ReassignApprovalModal.jsx
import { useCallback, useMemo, useState } from 'react';
import { ModalShell } from '../ModalShell';
import { useNestedModalEscape } from '../Templates/useNestedModalEscape';
import { ASSIGNMENT_TARGET_KINDS } from '../../utils/assignmentApproval';
import { getApproverCandidates } from '../../utils/permissions';
/**
* Модалка переназначения при согласовании.
*
* Согласующий выбирает одного сотрудника из своего подразделения —
* он заменит исходного исполнителя.
*
* Список кандидатов считается через getApproverCandidates в
* permissions.js: то же правило, что использует
* AssignmentApprovalService при валидации переназначения. Раньше
* логика дублировалась в двух местах — теперь единая точка.
*/
export function ReassignApprovalModal({ request, ur, db, onConfirm, onClose }) {
useNestedModalEscape(onClose);
const candidates = useMemo(
() => getApproverCandidates(ur, db),
[ur, db],
);
const [selectedId, setSelectedId] = useState(() => candidates[0]?.id || '');
const kindLabel = ASSIGNMENT_TARGET_KINDS[request?.targetType]?.label || 'Объект';
const handleSubmit = useCallback(() => {
if (!selectedId) return;
onConfirm(selectedId);
}, [selectedId, onConfirm]);
const footer = (
<div className="modal-foot">
<div className="spacer" />
<button type="button" className="btn ghost" onClick={onClose}>Отмена</button>
<button
type="button"
className="btn primary"
onClick={handleSubmit}
disabled={!selectedId}
>
Переназначить
</button>
</div>
);
return (
<ModalShell
title={`Переназначить ${kindLabel.toLowerCase()}`}
onClose={onClose}
width={520}
footer={footer}
showSave={false}
>
<p className="mut sm mb-2">
Выберите сотрудника из вашего подразделения — он заменит
исходного исполнителя. Автор запроса получит уведомление о
решении.
</p>
{candidates.length === 0 ? (
<div className="info-box">
В вашем подразделении нет других сотрудников — переназначение
невозможно. Согласуйте или отклоните запрос.
</div>
) : (
<div className="roles-list">
{candidates.map((e) => (
<label key={e.id} className="roles-item">
<input
type="radio"
name="reassign-employee"
checked={selectedId === e.id}
onChange={() => setSelectedId(e.id)}
/>
<span className="flex-1">
<b>{e.last} {e.first}</b>
</span>
<span className="mut sm">{e.position || ''}</span>
</label>
))}
</div>
)}
</ModalShell>
);
}
@@ -0,0 +1,80 @@
// src/components/Modals/RejectionReasonModal.jsx
//
// Модалка «Причина отклонения».
//
// Единственное место в проекте, где собирается текст причины для
// отклонения любой сущности. Все 4 сценария (отпуск, запрос часов,
// делегирование ролей, регистрация) используют её через один и тот же
// onSubmit(reason).
//
// Контракт:
// - onSubmit(reason) получает trimmed-строку (никогда не пустую:
// кнопка заблокирована при пустом поле, сервисы дополнительно
// валидируют причину);
// - onClose - закрытие без решения.
//
// Почему не ConfirmDialog.prompt: он даёт однострочный input, а
// причина отклонения - многострочный комментарий; плюс свой заголовок
// и подпись кнопки.
import { useCallback, useState } from 'react';
import { ModalShell } from '../ModalShell';
import { FormField } from '../FormField';
import { useNestedModalEscape } from '../Templates/useNestedModalEscape';
export function RejectionReasonModal({
title = 'Причина отклонения',
confirmLabel = 'Отклонить',
onSubmit,
onClose,
}) {
useNestedModalEscape(onClose);
const [reason, setReason] = useState('');
const [touched, setTouched] = useState(false);
const trimmed = reason.trim();
const canSubmit = trimmed.length > 0;
const handleSubmit = useCallback(() => {
setTouched(true);
if (!trimmed) return;
onSubmit(trimmed);
}, [trimmed, onSubmit]);
const footer = (
<div className="modal-foot">
<div className="spacer" />
<button type="button" className="btn ghost" onClick={onClose}>
Отмена
</button>
<button
type="button"
className="btn danger"
onClick={handleSubmit}
disabled={!canSubmit}
>
{confirmLabel}
</button>
</div>
);
return (
<ModalShell
title={title}
onClose={onClose}
width={480}
footer={footer}
showSave={false}
>
<FormField
label="Причина"
required
type="textarea"
rows={4}
value={reason}
onChange={setReason}
error={touched && !canSubmit ? 'Укажите причину отклонения' : null}
placeholder="Кратко опишите, почему запрос отклонён…"
/>
</ModalShell>
);
}
+152
View File
@@ -0,0 +1,152 @@
// src/components/Modals/RolesModal.jsx
import { useCallback, useState } from 'react';
import { ModalShell } from '../ModalShell';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { ROLES } from '../../utils/constants';
/**
* Редактор ролей сотрудника.
*
* Состав ролей:
* - обычные роли — чекбокс, ничего больше;
* - kb_chief — чекбокс + список КБ (у ГК нет отдела, он работает
* на уровне бюро целиком);
* - head — чекбокс БЕЗ выбора отделов. Руководителем сотрудник
* становится в тех подразделениях, где он фактически числится;
* сами подразделения задаются в его карточке (DeptsModal) —
* а основная должность пишется в личном кабинете.
*
* `executor` показывается, но чекбокс выключен: роль производна от
* факта «есть задачи» и управляется автоматически (см. utils/roleSync).
*
* Почему head больше не выбирает отделы:
* Раньше было два независимых списка — «где сотрудник числится»
* (departments) и «где он руководитель» (headDeptIds). Они расходились:
* admin мог записать сотрудника руководителем отдела, в котором тот
* не числится, и наоборот. Теперь второй список выводится из первого
* при сохранении: если роль head установлена — человек руководит
* своими подразделениями; если снята — headDeptIds очищается.
* Единственный источник правды для «где я» — карточка подразделений.
*/
export const RolesModal = ({ store, empId, onClose, toast }) => {
const db = store.data;
const emp = db.employees.find((e) => e.id === empId);
if (!emp) return null;
const [roles, setRoles] = useState(emp.roles || []);
const [kbIds, setKbIds] = useState(emp.kbIds || []);
const toggleRole = (role) => {
setRoles((prev) =>
prev.includes(role) ? prev.filter((r) => r !== role) : [...prev, role],
);
};
const toggleKb = (kbId) => {
setKbIds((prev) =>
prev.includes(kbId) ? prev.filter((id) => id !== kbId) : [...prev, kbId],
);
};
const { submit: save, isSubmitting } = useAsyncSubmit(
useCallback(async () => {
if (roles.includes('kb_chief') && kbIds.length === 0) {
throw new Error('Для роли "Главный конструктор" необходимо выбрать хотя бы одно КБ');
}
// ГК работает на уровне бюро — отделов у него нет.
let updatedDepartments = emp.departments;
if (roles.includes('kb_chief')) updatedDepartments = [];
const updatedEmp = {
...emp,
roles,
kbIds: roles.includes('kb_chief') ? kbIds : [],
// headDeptIds производно от departments: руководителем человек
// становится в тех подразделениях, где он фактически числится.
// Если роль снята — список очищается.
headDeptIds: roles.includes('head')
? (updatedDepartments || []).map((d) => d.deptId)
: [],
departments: updatedDepartments,
};
await store.upsertEmployee(updatedEmp);
onClose();
}, [
emp,
roles,
kbIds,
store,
onClose,
]),
(error) => {
toast(error.message || 'Ошибка сохранения ролей', 'error');
},
);
return (
<ModalShell
title={`Роли - ${emp.last} ${emp.first}`}
onClose={onClose}
onSave={save}
width={520}
className="modal-roles"
saveDisabled={isSubmitting}
>
<p className="mut sm">
Сотрудник может иметь несколько ролей. Для «Главного конструктора»
укажите КБ, в которых он работает. Основная должность и
подразделения указываются в карточке сотрудника и в его личном
кабинете. Роль «Исполнитель» управляется автоматически: появляется
при назначении задач, снимается — когда их не остаётся (если есть
другие роли).
</p>
<div className="roles-list">
{Object.entries(ROLES).map(([k, v]) => {
const isDerived = k === 'executor';
return (
<div key={k}>
<label className="roles-item">
<input
type="checkbox"
checked={roles.includes(k)}
onChange={() => toggleRole(k)}
disabled={isDerived}
/>
<span
className="role-chip"
style={{ background: v.color + '1e', color: v.color }}
>
{v.short}
</span>
{v.label}
{isDerived && (
<span className="mut sm ml-auto">
назначается автоматически
</span>
)}
</label>
{k === 'kb_chief' && roles.includes('kb_chief') && (
<div className="sub-picks">
{db.kbs.map((kb) => (
<label className="dept-pick" key={kb.id}>
<input
type="checkbox"
checked={kbIds.includes(kb.id)}
onChange={() => toggleKb(kb.id)}
/>
{kb.name}
</label>
))}
</div>
)}
</div>
);
})}
</div>
</ModalShell>
);
};
@@ -0,0 +1,48 @@
// src/components/Modals/SubstitutionPreview.jsx
import { ROLES } from '../../utils/constants';
/**
* Превью прав, которые получит замещающий.
*
* Единая точка правила «что показывать в модалке отпуска при включённом
* делегировании». Собирает подписи по тем же справочникам, что и
* SubstitutionService: один источник правды - три источника отображения.
*/
export function SubstitutionPreview({ delegator, db }) {
if (!delegator || !db) return null;
const roleLabels = (delegator.roles || [])
.map((r) => ROLES[r]?.label)
.filter(Boolean);
const deptNames = (delegator.headDeptIds || [])
.map((id) => db.departments?.find((d) => d.id === id)?.name)
.filter(Boolean);
const kbNames = (delegator.kbIds || [])
.map((id) => db.kbs?.find((k) => k.id === id)?.name)
.filter(Boolean);
if (!roleLabels.length && !deptNames.length && !kbNames.length) return null;
return (
<div className="sub-preview">
<div className="sub-preview-title">Замещающий получит:</div>
{roleLabels.length > 0 && (
<div className="sub-preview-row">
<span className="mut sm">роли:</span> {roleLabels.join(', ')}
</div>
)}
{deptNames.length > 0 && (
<div className="sub-preview-row">
<span className="mut sm">отделы:</span> {deptNames.join(', ')}
</div>
)}
{kbNames.length > 0 && (
<div className="sub-preview-row">
<span className="mut sm">КБ:</span> {kbNames.join(', ')}
</div>
)}
</div>
);
}
@@ -0,0 +1,81 @@
// src/components/Modals/TaskModal/TaskFooterActions.jsx
import { Ic, ICONS } from '../../Icons';
import { TemplateActions } from '../../Templates/TemplateActions';
import { collectTaskPayloads } from '../../../utils/templateNesting';
/**
* Левый блок футера карточки задачи.
*
* Кнопки:
* - Удалить — только для существующей задачи с правами;
* - Копировать — только для существующей с правом создать задачу;
* - В шаблон — при праве создать задачу;
* - В черновики — ТОЛЬКО при создании (isNew). Сохраняет задачу
* как личный черновик, без согласования.
*
* Правая часть («Отмена» + «Создать/Сохранить») собирается в ModalShell.
* Здесь её нет, чтобы панель не дублировала разметку.
*/
export function TaskFooterActions({
readOnly,
existing,
canEditFields,
isAuthor,
canCreateFromTask,
values,
db,
toast,
onDelete,
onCopy,
disabled = false,
onSaveAsDraft = null,
saveAsDraftDisabled = false,
}) {
return (
<>
{!readOnly && existing && (canEditFields || isAuthor) && (
<button
className="btn danger"
onClick={onDelete}
disabled={disabled}
>
<Ic d={ICONS.trash} size={14} /> Удалить
</button>
)}
{existing && onCopy && canCreateFromTask && (
<button
type="button"
className="btn ghost sm"
onClick={() => onCopy(existing.id)}
title="Создать новую задачу на основе этой"
disabled={disabled}
>
<Ic d={ICONS.copy} size={13} /> Копировать
</button>
)}
{canCreateFromTask && (
<TemplateActions
kind="task"
source={values}
nested={existing ? collectTaskPayloads(db.tasks, existing.id) : []}
toast={toast}
disabled={disabled || !values.title?.trim()}
/>
)}
{onSaveAsDraft && (
<button
type="button"
className="btn ghost sm"
onClick={onSaveAsDraft}
disabled={disabled || saveAsDraftDisabled}
title="Сохранить как личный черновик"
>
<Ic d={ICONS.edit} size={13} /> В черновики
</button>
)}
</>
);
}
@@ -0,0 +1,148 @@
// src/components/Modals/TaskModal/TaskFormTab.jsx
import { FormField } from '../../FormField';
import { TemplateSelect } from '../../Templates';
import { Ic, ICONS } from '../../Icons';
/**
* Вкладка «Данные» — поля карточки задачи.
*
* Чистый presentational-компонент: вся логика приходит снаружи.
* approvalHint — информационный текст о согласовании назначения,
* передаётся из TaskModal/index.jsx. Если null — не рендерим.
*/
export function TaskFormTab({
form, access, options, template, summary,
onRequestHours, onRequestDeadline, lockedField, approvalHint,
}) {
const { values, handleChange, updateValues, touched, errors } = form;
const { canEditFields, isProjectLocked, isAdminProject } = access;
const {
projectOptions, assigneeOptionsList, priorityOptions, statusOptions,
dependencyOptions, dependencyTypeOptions,
} = options;
const { isNew, isCopy, appliedTemplateName, onApply } = template;
const startLocked = lockedField === 'start';
const deadlineLocked = lockedField === 'deadline';
return (
<div className="project-info-fields">
{isNew && !isCopy && <TemplateSelect kind="task" onApply={onApply} />}
{isNew && appliedTemplateName && (
<div className="info-box">Применён шаблон: <b>{appliedTemplateName}</b></div>
)}
<FormField label="Название" required value={values.title}
onChange={(v) => handleChange('title', v)}
error={touched.title && errors.title}
disabled={!canEditFields} inline />
<div className="field-row">
<label className="field-label" />
<div className="flex-1 flex gap-4">
<label className="checkbox-inline">
<input type="checkbox" checked={summary.checked}
onChange={(e) => handleChange('isSummary', e.target.checked)}
disabled={summary.disabled} /> Суммарная задача
</label>
<label className="checkbox-inline">
<input type="checkbox" checked={values.isHourly}
onChange={(e) => updateValues({ isHourly: e.target.checked })}
disabled={!canEditFields} /> Часовая задача
</label>
</div>
</div>
<FormField label="Описание" type="textarea" rows={2} value={values.desc}
onChange={(v) => handleChange('desc', v)} disabled={!canEditFields} inline />
<FormField label="Проект" required type="select" options={projectOptions}
value={values.projectId ?? ''}
onChange={(v) => handleChange('projectId', v)}
error={touched.projectId && errors.projectId}
disabled={!canEditFields || isProjectLocked} inline />
<div className="fields-with-action">
<div className="fields-row">
<FormField label="Исполнитель" required type="select" options={assigneeOptionsList}
value={values.assigneeId ?? ''}
onChange={(v) => handleChange('assigneeId', v)}
error={touched.assigneeId && errors.assigneeId}
disabled={!canEditFields} inline />
<FormField label="Плановые часы" required={!isAdminProject} type="number"
min="0.5" step="0.5" value={values.plannedHours ?? ''}
onChange={(v) => handleChange('plannedHours', v)}
error={touched.plannedHours && errors.plannedHours}
disabled={!canEditFields || values.isHourly} inline />
</div>
{onRequestHours && (
<div className="field-action-row">
<button type="button" className="btn request-hours field-action" onClick={onRequestHours}>
<Ic d={ICONS.clock} size={14} /> Запросить изменение часов
</button>
</div>
)}
</div>
{approvalHint && (
<div className="info-box">{approvalHint}</div>
)}
<div className="fields-row">
<FormField label="Приоритет" required type="select" options={priorityOptions}
value={values.priority ?? ''}
onChange={(v) => handleChange('priority', v)}
error={touched.priority && errors.priority}
disabled={!canEditFields} inline />
<FormField label="Статус" required type="select" options={statusOptions}
value={values.status ?? ''}
onChange={(v) => handleChange('status', v)}
error={touched.status && errors.status}
disabled={!canEditFields} inline />
</div>
<div className="fields-with-action">
<div className="fields-row">
<FormField label="Начало" required type="date" value={values.start}
onChange={(v) => updateValues({ start: v })}
error={touched.start && errors.start}
disabled={!canEditFields || startLocked} inline />
<FormField label="Срок исполнения" required={!isAdminProject} type="date"
value={values.deadline}
onChange={(v) => handleChange('deadline', v)}
error={touched.deadline && errors.deadline}
disabled={!canEditFields || values.isHourly || deadlineLocked} inline />
</div>
{onRequestDeadline && (
<div className="field-action-row">
<button type="button" className="btn request-hours field-action" onClick={onRequestDeadline}>
<Ic d={ICONS.cal} size={14} /> Запросить изменение срока
</button>
</div>
)}
</div>
{values.isHourly && (
<div className="fields-row">
<FormField label="Время начала" required type="time" value={values.startTime}
onChange={(v) => updateValues({ startTime: v })}
error={touched.startTime && errors.startTime}
disabled={!canEditFields} inline />
<FormField label="Время окончания" required type="time" value={values.endTime}
onChange={(v) => updateValues({ endTime: v })}
error={touched.endTime && errors.endTime}
disabled={!canEditFields} inline />
</div>
)}
<FormField label="Зависит от задачи" type="select" options={dependencyOptions}
value={values.dependencyId ?? ''}
onChange={(v) => handleChange('dependencyId', v)}
disabled={!canEditFields} inline />
<FormField label="Тип зависимости" type="select" options={dependencyTypeOptions}
value={values.dependencyType ?? ''}
onChange={(v) => handleChange('dependencyType', v)}
disabled={!canEditFields || !values.dependencyId} inline />
</div>
);
}
@@ -0,0 +1,42 @@
// src/components/Modals/TaskModal/TaskNotesTab.jsx
import { Ic, ICONS } from '../../Icons';
import { fmtDT } from '../../../utils/date';
/** Вкладка «Личные заметки» - видна и доступна только исполнителю задачи. */
export function TaskNotesTab({ notesList, onNewNote, onOpenNote }) {
return (
<div className="tm-block">
<div className="subtask-header">
<div className="rep-panel-title">Личные заметки</div>
<button
type="button"
className="btn primary sm"
onClick={onNewNote}
>
<Ic d={ICONS.plus} size={13} /> Новая заметка
</button>
</div>
<p className="mut sm mb-2">
Видны только вам. Сохраняются отдельно от формы задачи.
</p>
{notesList.length === 0 ? (
<div className="mut sm">Заметок пока нет</div>
) : (
<div className="note-grid">
{notesList.map(n => (
<button
key={n.id}
type="button"
className="note-tile"
onClick={() => onOpenNote(n)}
>
<div className="note-tile-title">{n.title || 'Без названия'}</div>
<div className="note-tile-preview">{n.text || 'Пустая заметка'}</div>
<div className="note-tile-date">{fmtDT(n.updatedAt)}</div>
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,21 @@
// src/components/Modals/TaskModal/TaskProgress.jsx
import { useLayoutEffect, useRef } from 'react';
/**
* Прогресс-бар учёта времени. Ширина заливки - через CSS-переменную,
* выставленную императивно. В JSX ни одного style={{}}.
*/
export function TaskProgress({ value, max }) {
const ref = useRef(null);
const pct = Math.min(100, max > 0 ? (value / max) * 100 : 0);
useLayoutEffect(() => {
ref.current?.style.setProperty('--tm-progress-fill', `${pct}%`);
}, [pct]);
return (
<div className="tm-progress">
<div ref={ref} className="tm-progress-fill" />
</div>
);
}
@@ -0,0 +1,57 @@
// src/components/Modals/TaskModal/TaskSubtasksTab.jsx
import { Ic, ICONS } from '../../Icons';
import { TaskTable } from '../../TaskTable';
/**
* Вкладка «Подзадачи»: таблица + кнопка создания (только для уже
* существующей задачи).
*
* Смена подзадачи с возвратом к родителю.
*
* В onCreateSubtask / onRowClick раньше стоял setTimeout(..., 50):
* сначала закрывалась текущая модалка, через 50 мс открывалась новая.
* За эти 50 мс MainLayout успевал отреагировать на промежуточное
* состояние «modal === null, route ещё указывает на родителя» и
* вызвать openTask повторно — это добавляло шум и участвовало в цикле
* «Maximum update depth exceeded» при переходах между задачами.
*
* queueMicrotask кладёт onClose и openTask в одну очередь микрозадач.
* React 18 батчит оба обновления в один коммит: route остаётся
* актуальным, промежуточное состояние не выходит наружу, эффект в
* MainLayout срабатывает один раз — на финальный набор (route, modal).
*/
export function TaskSubtasksTab({
tasks,
showCreateButton,
readOnly,
onCreateSubtask,
onRowClick,
db,
getTaskSpent,
empName,
}) {
return (
<div className="tm-block">
<div className="subtask-header">
<div className="rep-panel-title">Подзадачи</div>
{showCreateButton && (
<button
className="btn primary sm"
onClick={onCreateSubtask}
disabled={readOnly}
>
<Ic d={ICONS.plus} size={14} /> Создать подзадачу
</button>
)}
</div>
<TaskTable
tasks={tasks}
onRowClick={onRowClick}
columns={['title', 'assignee', 'status', 'planned', 'fact', 'deadline']}
db={db}
getTaskSpent={getTaskSpent}
empName={empName}
/>
</div>
);
}
@@ -0,0 +1,36 @@
// src/components/Modals/TaskModal/TaskTimeTab.jsx
import { TaskProgress } from './TaskProgress';
import { Ic, ICONS } from '../../Icons';
import { TODAY, fmtD } from '../../../utils/date';
/**
* Вкладка «Учёт времени»: список внесённых логов + форма добавления
* часов (видна только тем, кому разрешено логировать - canLog решается
* в TaskModal/index.jsx через canLog из прав задачи).
*/
export function TaskTimeTab({
spent, planned, logs, empName, canLog,
logDate, setLogDate, logHours, setLogHours, logNote, setLogNote, onAddLog,
}) {
return (
<div className="tm-block">
<TaskProgress value={spent} max={planned || 0} />
{logs.map(l => (
<div key={l.id} className="tm-log">
<span className="tm-log-name">{empName(l.userId)}</span>
<span className="mut">{fmtD(l.date)}</span>
<span className="tm-log-note">{l.note}</span>
<b className="tm-log-h">{l.hours} ч</b>
</div>
))}
{canLog && (
<div className="tm-add">
<input className="inp tm-add-date" type="date" value={logDate} onChange={(e) => setLogDate(e.target.value)} max={TODAY} />
<input className="inp tm-add-hours" type="number" min="0.5" step="0.5" placeholder="часы" value={logHours} onChange={(e) => setLogHours(e.target.value)} />
<input className="inp tm-add-note" placeholder="комментарий" value={logNote} onChange={(e) => setLogNote(e.target.value)} />
<button className="btn ghost" onClick={onAddLog}><Ic d={ICONS.clock} size={14} /> Внести часы</button>
</div>
)}
</div>
);
}
+346
View File
@@ -0,0 +1,346 @@
// src/components/Modals/TaskModal/index.jsx
import { useMemo, useCallback } from 'react';
import { ModalShell } from '../../ModalShell';
import { Tabs } from '../../Tabs';
import { FileManager } from '../../FileManager';
import Discussion from '../../Discussion';
import { HistoryTab } from '../../HistoryTab';
import NoteEditorModal from '../NoteEditorModal';
import { TaskFormTab } from './TaskFormTab';
import { TaskTimeTab } from './TaskTimeTab';
import { TaskSubtasksTab } from './TaskSubtasksTab';
import { TaskNotesTab } from './TaskNotesTab';
import { TaskFooterActions } from './TaskFooterActions';
import { useConfirm } from '../../../context/ConfirmContext';
import { useDataHelpers } from '../../../hooks/useDataHelpers';
import { useChatUnreadCount, useChatTotalCount } from '../../../hooks/useChatStats';
import { useTaskFormState } from '../../../hooks/useTaskFormState';
import { useTaskTemplate } from '../../../hooks/useTaskTemplate';
import { useTaskSubtasks } from '../../../hooks/useTaskSubtasks';
import { useTaskSelectOptions } from '../../../hooks/useTaskSelectOptions';
import { useTaskFileActions } from '../../../hooks/useTaskFileActions';
import { useTaskTimeLog } from '../../../hooks/useTaskTimeLog';
import { useTaskNotes } from '../../../hooks/useTaskNotes';
import { useTaskSave } from '../../../hooks/useTaskSave';
import { useTaskTabSync } from '../../../hooks/useTaskTabSync';
import {
canCreateTask,
canEditTaskFields,
canChangeTaskStatus,
} from '../../../utils/permissions';
import { buildApprovalHint } from '../../../utils/assignmentApproval';
import { isArchived } from '../../../utils/entityState';
import { DOMAIN } from '../../../utils/constants';
export const TaskModal = ({
db, ur, taskId, initialTab = 'form', parentTaskId, initialProjectId,
returnToProjectId, returnToTaskId, returnToEmployeeTasksId, copyFromId,
highlightFileId = null, highlightFolderId = null,
onClose, onSave, onDelete, onChangeReq, store, openTask, toast, onCopy, onTabChange,
}) => {
const { empName, getTaskSpent, vacOverlap } = useDataHelpers(db);
const { confirm } = useConfirm();
const existing = taskId ? db.tasks.find(t => t.id === taskId) : null;
const copySource = copyFromId ? db.tasks.find(t => t.id === copyFromId) : null;
const isCopy = !existing && !!copySource;
const isNew = !existing;
const readOnly = !!(existing && isArchived(existing));
const canEditFields = !readOnly && (existing
? canEditTaskFields(ur, existing, db)
: canCreateTask(ur));
const canChangeStatus = !readOnly && existing
&& canChangeTaskStatus(ur, existing, null, db);
const isAssignee = existing && existing.assigneeId === ur.id;
const isAuthor = existing && existing.creatorId === ur.id;
const canLog = !readOnly
&& (existing ? isAssignee : true)
&& !existing?.isSummary
&& !existing?.isHourly;
const canCreateFromTask = canCreateTask(ur);
const isProjectLocked = !!(initialProjectId || parentTaskId);
const effectiveProjectId = useMemo(() => {
if (initialProjectId) return initialProjectId;
if (parentTaskId) {
const parent = db.tasks.find(t => t.id === parentTaskId);
if (parent) return parent.projectId;
}
return '';
}, [initialProjectId, parentTaskId, db.tasks]);
const {
values, handleChange, updateValues, handleSubmit, errors, touched,
setFieldValue, isValid, isDirty, lockedField,
} = useTaskFormState({
existing, isCopy, copySource, isNew, isProjectLocked,
effectiveProjectId, parentTaskId, db, ur,
});
const { appliedTemplateName, pendingTemplateSubtasks, applyTemplate } = useTaskTemplate({
isCopy, copySource, db, setFieldValue, isProjectLocked, effectiveProjectId, toast,
});
const { subtasks, draftSubtasks, displayedSubtasks } = useTaskSubtasks({
db, taskId: values.id, projectId: values.projectId, isNew, pendingTemplateSubtasks,
});
const project = db.projects.find(p => p.id === values.projectId);
const isAdminProject = project && project.ptype === 'admin';
const isSummaryChecked = values.isSummary || subtasks.length > 0 || draftSubtasks.length > 0;
const isSummaryDisabled = !canEditFields || subtasks.length > 0;
const hasSubtasks = subtasks.length > 0 || values.isSummary || draftSubtasks.length > 0;
const {
projectOptions, assigneeOptionsList, priorityOptions, statusOptions,
dependencyOptions, dependencyTypeOptions, candidates,
} = useTaskSelectOptions({
db, ur, isProjectLocked, effectiveProjectId,
taskId: values.id, projectId: values.projectId, isAssignee, canChangeStatus,
});
const {
handleFileUpload, handleFileDelete, handleCreateFolder, handleDeleteFolder,
} = useTaskFileActions({ values, existing, store, setFieldValue, toast, confirm, ur });
const {
logHours, setLogHours, logNote, setLogNote, logDate, setLogDate, addLog,
} = useTaskTimeLog({ values, existing, store, ur, toast, setFieldValue, getTaskSpent });
const {
notesList, editingNote, openNewNote, openExistingNote,
closeNoteEditor, handleSaveNote, handleDeleteNote,
} = useTaskNotes({ existing, ur, store, toast });
const {
saveHandler, saveAsDraftHandler, deleteHandler, isSaving,
} = useTaskSave({
existing, isNew, db, vacOverlap, toast, onSave, onDelete, ur,
subtasks, draftSubtasks, pendingTemplateSubtasks, confirm, store, empName,
});
const chatUnread = useChatUnreadCount(ur.id, values.projectId, values.id);
const chatTotal = useChatTotalCount(values.projectId, values.id);
const approvalHint = useMemo(
() => buildApprovalHint({
user: ur, db, existing, isNew,
targetType: 'task',
entity: values,
}),
[ur, db, existing, isNew, values.assigneeId],
);
const tabs = [
{ id: 'form', label: 'Данные' },
...(!values.isHourly
? [{ id: 'time', label: `Учёт времени (${getTaskSpent(values)}/${values.plannedHours ?? '-'})` }]
: []),
...(hasSubtasks
? [{ id: 'subtasks', label: `Подзадачи (${displayedSubtasks.length})` }]
: []),
...(existing ? [
{ id: 'chat', label: 'Обсуждение', count: chatTotal, badge: chatUnread },
{ id: 'files', label: `Вложения (${values.files?.length || 0})` },
{ id: 'hist', label: 'История' },
] : []),
...(isAssignee && !readOnly
? [{ id: 'notes', label: `Заметки (${notesList.length})` }]
: []),
];
const { activeTab, handleTabChange, bodyRef } = useTaskTabSync(
initialTab, onTabChange, tabs, values.isHourly,
);
const showBackButton = returnToProjectId || returnToTaskId || returnToEmployeeTasksId;
const saveDisabled = isSaving
|| !(canEditFields || (existing && canChangeStatus))
|| (isNew ? !isValid : !isValid || !isDirty);
const modalTitle = readOnly
? 'Архивная задача - только чтение'
: existing
? 'Карточка задачи'
: isCopy
? `Копирование задачи: ${copySource.title}`
: 'Новая задача';
const modalSubtitle = useMemo(() => {
const author = existing?.creatorId
? db.employees.find(e => e.id === existing.creatorId)
: null;
if (!author) return null;
const email = author.email ? `${author.email}@${DOMAIN}` : null;
return (
<>
<span>Задачу составил: {author.last} {author.first}</span>
{author.extension && (<> | вн. тел.: {author.extension}</>)}
{email && (
<> {' | '} e-mail: {' '}
<a href={`mailto:${email}`} onClick={(e) => e.stopPropagation()}>{email}</a>
</>
)}
</>
);
}, [existing, db.employees]);
const canRequestChange = !readOnly && existing && isAssignee && !!onChangeReq;
const requestHours = canRequestChange && !values.isHourly
? () => onChangeReq('hours', 'task', values.id) : null;
const requestDeadline = canRequestChange && !values.isHourly
? () => onChangeReq('deadline', 'task', values.id) : null;
const goToSubtask = useCallback((subtaskId) => {
openTask(subtaskId, 'form', subtaskId ? null : values.id, null, null, null, values.id);
}, [openTask, values.id]);
// Обработчик кнопки «В черновики»: прокидывает текущие values в
// saveAsDraftHandler. Футер про values не знает — остаётся
// презентационным.
const handleSaveAsDraft = useCallback(() => {
saveAsDraftHandler(values);
}, [saveAsDraftHandler, values]);
const footerActions = (
<TaskFooterActions
readOnly={readOnly}
existing={existing}
canEditFields={canEditFields}
isAuthor={isAuthor}
canCreateFromTask={canCreateFromTask}
values={values}
db={db}
toast={toast}
onDelete={deleteHandler}
onCopy={onCopy}
disabled={isSaving}
onSaveAsDraft={isNew ? handleSaveAsDraft : null}
saveAsDraftDisabled={!values.title?.trim()}
/>
);
return (
<>
<ModalShell
title={modalTitle}
subtitle={modalSubtitle}
onClose={onClose}
width={800}
className="modal-task"
actions={footerActions}
saveLabel={existing ? 'Сохранить' : 'Создать задачу'}
saveDisabled={saveDisabled}
onSave={handleSubmit(saveHandler)}
bodyRef={bodyRef}
showBack={!!showBackButton}
>
{readOnly && (
<div className="info-box">
{existing.archivedAt
? `Задача в архиве с ${existing.archivedAt}. Редактирование запрещено.`
: `Задача ${existing.status === 'cancelled' ? 'отменена' : 'закрыта'}. Редактирование запрещено.`}
</div>
)}
<Tabs tabs={tabs} active={activeTab} onChange={handleTabChange} className="tabs-nowrap" />
{activeTab === 'form' && (
<TaskFormTab
form={{ values, handleChange, updateValues, touched, errors }}
access={{ canEditFields, isProjectLocked, isAdminProject }}
options={{
projectOptions, assigneeOptionsList, priorityOptions, statusOptions,
dependencyOptions, dependencyTypeOptions,
}}
template={{ isNew, isCopy, appliedTemplateName, onApply: applyTemplate }}
summary={{ checked: isSummaryChecked, disabled: isSummaryDisabled }}
onRequestHours={requestHours}
onRequestDeadline={requestDeadline}
lockedField={lockedField}
approvalHint={approvalHint}
/>
)}
{activeTab === 'time' && !values.isHourly && (
<TaskTimeTab
spent={getTaskSpent(values)}
planned={values.plannedHours}
logs={values.logs}
empName={empName}
canLog={canLog}
logDate={logDate} setLogDate={setLogDate}
logHours={logHours} setLogHours={setLogHours}
logNote={logNote} setLogNote={setLogNote}
onAddLog={addLog}
/>
)}
{activeTab === 'subtasks' && hasSubtasks && (
<TaskSubtasksTab
tasks={displayedSubtasks}
showCreateButton={!!existing}
readOnly={readOnly}
onCreateSubtask={() => goToSubtask(null)}
onRowClick={(id) => goToSubtask(id)}
db={db}
getTaskSpent={getTaskSpent}
empName={empName}
/>
)}
{activeTab === 'chat' && (
<Discussion
store={store}
projectId={values.projectId}
taskId={values.id}
currentUser={ur}
candidates={candidates}
readOnly={readOnly}
toast={toast}
employees={db.employees}
/>
)}
{activeTab === 'files' && existing && (
<FileManager
files={values.files}
folders={values.folders}
highlightFileId={highlightFileId}
highlightFolderId={highlightFolderId}
onUpload={handleFileUpload}
onDelete={handleFileDelete}
onCreateFolder={handleCreateFolder}
onDeleteFolder={handleDeleteFolder}
canUpload={!readOnly && (canEditFields || isAssignee || isAuthor)}
canDelete={!readOnly && (canEditFields || isAssignee || isAuthor)}
employeeName={empName}
/>
)}
{activeTab === 'hist' && existing && (
<HistoryTab history={values.history} empName={empName} />
)}
{activeTab === 'notes' && isAssignee && !readOnly && existing && (
<TaskNotesTab
notesList={notesList}
onNewNote={openNewNote}
onOpenNote={openExistingNote}
/>
)}
</ModalShell>
{editingNote && (
<NoteEditorModal
note={editingNote}
onSave={handleSaveNote}
onDelete={handleDeleteNote}
onClose={closeNoteEditor}
/>
)}
</>
);
};
+140
View File
@@ -0,0 +1,140 @@
// src/components/Modals/VacNowModal.jsx
import { useMemo, useState } from "react";
import { ModalShell } from "../ModalShell";
import { Select } from "../Select";
import { useDataHelpers } from "../../hooks/useDataHelpers";
import { optionsFromList } from "../../utils/selectOptions";
import { VACATION_TYPES } from "../../utils/constants";
import { TODAY, fmtDMY } from "../../utils/date";
import { findVacationSubstitutes } from "../../utils/substitution";
/**
* Список сотрудников, находящихся в отпусках сегодня.
*
* Отпуск и замещение — независимые сущности. Связь между ними
* не материализуется в данных, а вычисляется доменным правилом
* findVacationSubstitutes (src/utils/substitution.js). Модалка
* read-only: связь читается для колонки «Замещает», ни одна сущность
* не мутируется.
*/
export function VacNowModal({ db, onClose, toast }) {
const [filterDept, setFilterDept] = useState("all");
const [sort, setSort] = useState("start");
const { primaryDept, empName } = useDataHelpers(db);
const deptSelectOptions = useMemo(
() =>
optionsFromList(db.departments, "Все подразделения", (d) => ({
value: d.id,
label: d.name,
})),
[db.departments]
);
const rows = db.vacations
.filter((v) => v.status === "approved" && v.start <= TODAY && TODAY <= v.end)
.map((v) => {
const e = db.employees.find((x) => x.id === v.empId);
return {
v,
e,
dept: primaryDept(e),
substitutes: findVacationSubstitutes(v, db.roleDelegations),
};
})
.filter((r) => filterDept === "all" || (r.dept && r.dept.id === filterDept))
.sort((a, b) =>
sort === "start"
? a.v.start < b.v.start
? -1
: 1
: a.v.end < b.v.end
? -1
: 1
);
return (
<ModalShell
title="Сотрудники в отпусках"
onClose={onClose}
width={880}
showSave={false}
footer={
<div className="modal-foot">
<div className="spacer" />
<button className="btn ghost" onClick={onClose}>
Закрыть
</button>
</div>
}
>
<div className="toolbar">
<Select
className="sm"
value={filterDept}
onChange={setFilterDept}
options={deptSelectOptions}
/>
<div className="seg sm">
<button
className={"seg-btn" + (sort === "start" ? " on" : "")}
onClick={() => setSort("start")}
>
по началу
</button>
<button
className={"seg-btn" + (sort === "end" ? " on" : "")}
onClick={() => setSort("end")}
>
по окончанию
</button>
</div>
<div className="spacer" />
<button className="btn ghost sm" onClick={() => toast("Список обновлён")}>
⟳ Обновить
</button>
</div>
<table className="tbl">
<thead>
<tr>
<th>ФИО</th>
<th>Подразделение (основное)</th>
<th>Начало</th>
<th>Окончание</th>
<th>Тип</th>
<th>Комментарий</th>
<th>Замещает</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.v.id}>
<td>
<b>{r.e ? `${r.e.last} ${r.e.first}` : "-"}</b>
</td>
<td>{r.dept?.name || "-"}</td>
<td>{fmtDMY(r.v.start)}</td>
<td>{fmtDMY(r.v.end)}</td>
<td>{VACATION_TYPES[r.v.type]}</td>
<td className="mut">{r.v.comment || "-"}</td>
<td>
{r.substitutes.length
? r.substitutes.map((rd) => empName(rd.toId)).join(", ")
: "—"}
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan="7" className="mut">
Сейчас никто не находится в отпуске
</td>
</tr>
)}
</tbody>
</table>
</ModalShell>
);
}
export default VacNowModal;
+287
View File
@@ -0,0 +1,287 @@
// src/components/Modals/VacationModal.jsx
import { useCallback, useMemo } from "react";
import { ModalShell } from "../ModalShell";
import { FormField } from "../FormField";
import { useConfirm } from "../../context/ConfirmContext";
import { useDataHelpers } from "../../hooks/useDataHelpers";
import { useForm } from "../../hooks/useForm";
import { useAsyncSubmit } from "../../hooks/useAsyncSubmit";
import {
canManageAllVacations,
canManageVacation,
canCreateVacationFor,
canApproveVacation
} from "../../utils/permissions";
import { VACATION_TYPES } from "../../utils/constants";
import { getPrimaryDeptName } from "../../utils/helpers";
import { TODAY, addDays, iso, uid, fmtDMY } from "../../utils/date";
import { Ic, ICONS } from "../Icons";
const FIELDS = Object.freeze([
"empId",
"start",
"end",
"type",
"comment",
"status"
]);
/**
* Модалка отпуска.
*
* Отпуск — самостоятельная сущность: фиксирует только факт отсутствия
* сотрудника (период, тип, статус, комментарий). Делегирование задач на
* период отсутствия оформляется отдельно через «Передача полномочий →
* Замещение», где поводом можно выбрать «Замещение на отпуск». Тогда
* ручная запись roleDelegation живёт независимой жизнью: её период и
* статус не связаны с отпуском.
*/
export function VacationModal({
db,
ur,
vacationId,
forEmpId,
onClose,
onSave,
onDelete,
toast
}) {
const { empName } = useDataHelpers(db);
const { confirm } = useConfirm();
const existing = vacationId ? db.vacations.find((v) => v.id === vacationId) : null;
const isNew = !existing;
const canPick = canManageAllVacations(ur);
const isRejected = existing?.status === "rejected";
const isApproved = existing?.status === "approved";
const initialValues = useMemo(
() =>
existing
? { ...existing }
: {
id: "v_" + uid(),
empId: forEmpId || ur.id,
start: TODAY,
end: iso(addDays(new Date(), 7)),
type: "annual",
comment: "",
status: canPick && forEmpId ? "approved" : "pending"
},
[existing, forEmpId, ur.id, canPick]
);
const validate = useCallback(
(values) => {
const errors = {};
if (!values.start) errors.start = "Дата начала обязательна";
if (!values.end) errors.end = "Дата окончания обязательна";
if (values.end && values.start && values.end < values.start) {
errors.end = "Дата окончания должна быть позже начала";
}
if (values.empId) {
if (
db.vacations.some(
(v) =>
v.empId === values.empId &&
v.id !== values.id &&
v.status === "approved" &&
v.start <= values.end &&
v.end >= values.start
)
) {
errors.start = "У сотрудника уже есть утверждённый отпуск в этот период";
}
}
return errors;
},
[db, existing]
);
const { values, handleChange, handleSubmit, errors, touched } = useForm(
initialValues,
validate,
{ fields: FIELDS }
);
const targetEmpId = existing ? existing.empId : values.empId;
const canManage = existing
? canManageVacation(ur, existing)
: canCreateVacationFor(ur, targetEmpId);
const readOnly = !!existing && !canManage;
const { submit: save, isSubmitting } = useAsyncSubmit(
useCallback(
async (vals) => {
await onSave(vals, isNew);
},
[onSave, isNew]
),
(error) => {
toast(error.message || "Ошибка сохранения отпуска", "error");
}
);
const handleDelete = useCallback(async () => {
if (!existing || !onDelete) return;
if (
!await confirm({
title: "Удалить отпуск",
message: `Удалить отпуск ${fmtDMY(existing.start)}–${fmtDMY(existing.end)}?`,
confirmLabel: "Удалить",
danger: true
})
)
return;
try {
await onDelete(existing.id);
onClose();
} catch (error) {
toast(error.message || "Ошибка удаления отпуска", "error");
}
}, [existing, onDelete, confirm, onClose, toast]);
const employeeOptions = db.employees.map((e) => ({
value: e.id,
label: `${empName(e.id)} - ${getPrimaryDeptName(e, db)}`
}));
const statusOptions = (() => {
if (isRejected) return [];
if (isNew) {
const opts = [{ value: "pending", label: "На утверждении" }];
if (canPick && forEmpId) opts.push({ value: "approved", label: "Утверждён" });
return opts;
}
const opts = [{ value: "pending", label: "На утверждении" }];
if (canApproveVacation(ur, existing, db))
opts.push({ value: "approved", label: "Утверждён" });
return opts;
})();
const typeOptions = Object.entries(VACATION_TYPES).map(([k, v]) => ({
value: k,
label: v
}));
const canDelete = !!existing && !!onDelete && canManage;
const showStatusField = !readOnly && statusOptions.length > 1;
const title = readOnly
? "Просмотр отпуска"
: existing
? "Редактирование отпуска"
: "Новый отпуск";
const readOnlyFooter = (
<div className="modal-foot">
<div className="spacer" />
<button type="button" className="btn ghost" onClick={onClose}>
Закрыть
</button>
</div>
);
return (
<ModalShell
title={title}
onClose={onClose}
onSave={readOnly ? undefined : handleSubmit(save)}
saveLabel="Сохранить"
width={560}
className="modal-vacation"
saveDisabled={isSubmitting}
footer={readOnly ? readOnlyFooter : null}
showSave={!readOnly}
actions={
canDelete ? (
<button
type="button"
className="btn danger"
onClick={handleDelete}
disabled={isSubmitting}
>
<Ic d={ICONS.trash} size={14} /> Удалить
</button>
) : null
}
>
{readOnly && (
<div className="info-box">
{isApproved
? "Утверждённый отпуск нельзя редактировать или удалить. Изменения вносит администратор или генеральный директор."
: "Изменение этого отпуска недоступно для вашей роли."}
</div>
)}
<div className="project-info-fields">
{canPick && (
<FormField
label="Сотрудник"
required
type="select"
options={employeeOptions}
value={values.empId}
onChange={(v) => handleChange("empId", v)}
disabled={!!existing}
/>
)}
<FormField
label="Дата начала"
required
type="date"
value={values.start}
onChange={(v) => handleChange("start", v)}
error={touched.start && errors.start}
disabled={readOnly}
/>
<FormField
label="Дата окончания"
required
type="date"
value={values.end}
onChange={(v) => handleChange("end", v)}
error={touched.end && errors.end}
disabled={readOnly}
/>
<FormField
label="Тип отпуска"
required
type="select"
options={typeOptions}
value={values.type}
onChange={(v) => handleChange("type", v)}
disabled={readOnly}
/>
<FormField
label="Комментарий"
value={values.comment}
onChange={(v) => handleChange("comment", v)}
disabled={readOnly}
/>
{showStatusField && (
<FormField
label="Статус"
type="select"
options={statusOptions}
value={values.status}
onChange={(v) => handleChange("status", v)}
/>
)}
{isRejected && (
<div className="info-box">
<div>
Отпуск отклонён.
{existing.rejectionReason && ` Причина: ${existing.rejectionReason}`}
</div>
<div className="mut sm mt-1">
Отклонение принимается в разделе «Запросы и заявки». Переоткрыть
отпуск через форму нельзя.
</div>
</div>
)}
</div>
</ModalShell>
);
}
export default VacationModal;
+137
View File
@@ -0,0 +1,137 @@
// src/components/Modals/YearCalendarModal.jsx
import { useState, useMemo } from 'react';
import { ModalShell } from '../ModalShell';
import { Ic, ICONS } from '../Icons';
import { DAY_STATUS } from '../../utils/workCalendar';
import { iso, fmtDMY } from '../../utils/date';
const MONTH_NAMES = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь',
];
const WEEKDAY_LABELS = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс'];
// Date.getDay(): вс=0, пн=1, ..., сб=6. Для сетки «пн первый»:
// пн=0, вт=1, ..., вс=6 - сколько пустых ячеек до первого числа.
const mondayIndex = (jsDay) => (jsDay + 6) % 7;
const STATUS_CLASS = {
[DAY_STATUS.WORKDAY]: 'yc-day--workday',
[DAY_STATUS.SHORTDAY]: 'yc-day--shortday',
[DAY_STATUS.WEEKEND]: 'yc-day--weekend',
[DAY_STATUS.HOLIDAY]: 'yc-day--holiday',
};
/**
* Просмотр производственного календаря на год.
*
* Класс modal-year-calendar - модификатор к .modal: задаёт минимальную
* высоту, чтобы 12 месяцев не сжимались в плоскую полосу. Значение
* живёт в CSS, не inline-стилем.
*
* Модалка просмотровая: ни сохранения, ни футера. Закрытие - крестик
* в шапке, клик вне окна или Escape (стандарт для остальных смотровых
* модалок проекта).
*/
export default function YearCalendarModal({ store, onClose }) {
const [year, setYear] = useState(() => new Date().getFullYear());
// Не useMemo: пересчёт тривиален, а корректность сохраняется даже если
// модалка остаётся открытой через полночь - при следующем рендере
// метка «сегодня» переедет на новый день.
const todayIso = iso(new Date());
const months = useMemo(() => store.getYearCalendar(year), [store, year]);
return (
<ModalShell
title="Производственный календарь"
onClose={onClose}
width={1400}
className="modal-year-calendar"
showSave={false}
>
<div className="yc-nav">
<button
type="button"
className="icon-btn"
onClick={() => setYear(y => y - 1)}
title="Предыдущий год"
>
<Ic d={ICONS.left} size={16} />
</button>
<div className="yc-year">{year}</div>
<button
type="button"
className="icon-btn"
onClick={() => setYear(y => y + 1)}
title="Следующий год"
>
<Ic d={ICONS.right} size={16} />
</button>
<button
type="button"
className="btn ghost sm"
onClick={() => setYear(new Date().getFullYear())}
>
Сегодня
</button>
<span className="yc-today-hint">Сегодня: {fmtDMY(todayIso)}</span>
</div>
<div className="yc-grid">
{months.map((m, idx) => (
<Month
key={m.month}
month={m}
name={MONTH_NAMES[idx]}
year={year}
todayIso={todayIso}
/>
))}
</div>
<div className="yc-legend">
<span className="yc-legend-item">
<span className="yc-legend-dot yc-legend-dot--workday" /> Рабочий
</span>
<span className="yc-legend-item">
<span className="yc-legend-dot yc-legend-dot--shortday" /> Сокращённый
</span>
<span className="yc-legend-item">
<span className="yc-legend-dot yc-legend-dot--weekend" /> Выходной/Праздничный
</span>
<span className="yc-legend-item">
<span className="yc-legend-dot yc-legend-dot--today" /> Сегодня
</span>
</div>
</ModalShell>
);
}
function Month({ month, name, year, todayIso }) {
const firstWeekday = mondayIndex(new Date(year, month.month, 1).getDay());
const blanks = Array.from({ length: firstWeekday }, (_, i) => i);
return (
<div className="yc-month">
<div className="yc-month-title">{name}</div>
<div className="yc-weekdays">
{WEEKDAY_LABELS.map(w => <span key={w}>{w}</span>)}
</div>
<div className="yc-days">
{blanks.map(i => (
<span key={`b${i}`} className="yc-day yc-day--empty" />
))}
{month.days.map(d => {
const classes = ['yc-day', STATUS_CLASS[d.status]];
if (d.iso === todayIso) classes.push('yc-day--today');
return (
<span key={d.iso} className={classes.join(' ')}>{d.day}</span>
);
})}
</div>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
export { TaskModal } from './TaskModal';
export { ProjectModal } from './ProjectModal';
export { ProjectAccessModal } from './ProjectAccessModal';
export { ChangeRequestModal } from './ChangeRequestModal';
export { RolesModal } from './RolesModal';
export { DeptsModal } from './DeptsModal';
export { VacationModal } from './VacationModal';
export { DelegationModal } from './DelegationModal';
export { VacNowModal } from './VacNowModal';
export { CreateEmployeeModal } from './CreateEmployeeModal';
export { EditEmployeeModal } from './EditEmployeeModal';
export { default as YearCalendarModal } from './YearCalendarModal';
export { default as EmployeeTasksModal } from './EmployeeTasksModal';
export { default as NoteEditorModal } from './NoteEditorModal';
+76
View File
@@ -0,0 +1,76 @@
// src/components/NotifBell.jsx
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { useSelector } from '../context/StoreContext';
import NotifPanel from './NotifPanel';
import { Ic, ICONS } from './Icons';
/**
* Колокольчик уведомлений.
*
* Сам подписан на срез notifications и сам держит состояние попапа.
* MainLayout его не касается: смена счётчика непрочитанных или открытие
* панели не дёргают корневое дерево.
*
* Навигация по клику передаётся снаружи (onNavigate) - она завязана на
* openTask / openProject / openVacation / setView, которые живут в
* MainLayout. Тащить их сюда значило бы пробрасывать четыре колбэка
* ради того же результата.
*/
export default function NotifBell({ user, store, onNavigate }) {
const notifications = useSelector(s => s.notifications);
const [open, setOpen] = useState(false);
const wrapRef = useRef(null);
const myNotifs = useMemo(
() => notifications.filter(n => n.userId === user.id),
[notifications, user.id],
);
const unread = useMemo(
() => myNotifs.filter(n => !n.read).length,
[myNotifs],
);
useEffect(() => {
if (!open) return;
const onDown = (e) => {
if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
};
const onKey = (e) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const handleNavigate = useCallback((notification) => {
if (onNavigate) onNavigate(notification);
// NotifPanel после onNavigate сам вызывает onClose - здесь
// дублировать setOpen(false) не нужно.
}, [onNavigate]);
return (
<div className="bell-wrap" ref={wrapRef}>
<button
className={`icon-btn bell${unread ? ' has' : ''}`}
onClick={() => setOpen(v => !v)}
>
<Ic d={ICONS.bell} size={17} />
{unread > 0 && <span className="bell-count">{unread}</span>}
</button>
{open && (
<NotifPanel
list={myNotifs}
currentUserId={user.id}
markAllRead={store.markAllNotificationsRead}
onNavigate={handleNavigate}
onClose={() => setOpen(false)}
/>
)}
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
import { fmtDT, fmtDMY, iso } from '../utils/date';
import { Ic, ICONS } from './Icons';
/**
* Иконка уведомления по его типу.
*
* Для старых типов — прямая карта. Для changeRequest — функция от
* targetTab: уведомления о сроке и о часах принадлежат одному типу
* (changeRequest), но визуально должны различаться — иначе пользователь
* не отличит «просят сдвинуть срок» от «просят добавить часов».
*
* Ключ hours оставлен: если в сторе остались уведомления от старой
* схемы (до переезда на changeRequest), они не потеряют иконку.
*/
const iconForNotification = (n) => {
if (n.targetType === 'changeRequest') {
return n.targetTab === 'deadline' ? ICONS.cal : ICONS.clock;
}
return TYPE_ICONS[n.targetType] || TYPE_ICONS.default;
};
const TYPE_ICONS = {
task: ICONS.tasks,
project: ICONS.folder,
hours: ICONS.clock,
vacation: ICONS.beach,
delegation: ICONS.swap,
registration: ICONS.user,
employee: ICONS.users,
default: ICONS.bell,
};
export default function NotifPanel({ list, currentUserId, markAllRead, onNavigate, onClose }) {
const handleClick = (item) => {
if (onNavigate) onNavigate(item);
if (onClose) onClose();
};
const handleMarkAll = () => {
if (markAllRead) markAllRead(currentUserId);
};
// Группировка по дням
const groups = [];
const today = iso(new Date());
const sorted = [...list].sort((a, b) => b.ts - a.ts);
let currentGroup = null;
sorted.forEach(n => {
const day = iso(new Date(n.ts));
const label = day === today ? 'Сегодня' : fmtDMY(day);
if (!currentGroup || currentGroup.label !== label) {
currentGroup = { label, items: [] };
groups.push(currentGroup);
}
currentGroup.items.push(n);
});
const hasUnread = list.some(n => !n.read);
return (
<div className="notif-pop">
<div className="notif-head">
<span>Уведомления</span>
{hasUnread && (
<button className="link" onClick={handleMarkAll}>прочитать все</button>
)}
</div>
<div className="notif-list">
{groups.map(g => (
<div key={g.label}>
<div className="notif-group-label">{g.label}</div>
{g.items.map(n => (
<div
key={n.id}
className={`notif-item${n.read ? '' : ' new'}`}
onClick={() => handleClick(n)}
>
<div className="notif-icon">
<Ic d={iconForNotification(n)} size={14} />
</div>
<div className="notif-content">
<div className="notif-text">{n.text}</div>
<div className="mut sm">{fmtDT(n.ts)}</div>
</div>
</div>
))}
</div>
))}
{list.length === 0 && <div className="mut sm notif-empty">Нет уведомлений</div>}
</div>
<p className="mut sm notif-note">E-mail-дубли отправляются по настройкам профиля.</p>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
// src/components/ProjectChat.jsx
import { useCallback } from 'react';
import Discussion from './Discussion';
// Модульная пустая ссылка: стабильный [] для случаев, когда родитель
// не передал массив. Инлайн `tasks || []` создавал бы новый массив на
// каждом рендере.
const EMPTY_ARRAY = Object.freeze([]);
/**
* Чат проекта. Тонкая обёртка над Discussion: передаёт projectId и
* знает, как открыть задачу по клику из комментария.
*/
export const ProjectChat = ({
projectId,
store,
currentUser,
toast,
employees,
candidates,
openTask,
tasks,
readOnly = false,
}) => {
const handleTaskClick = useCallback((taskId) => {
openTask(taskId, 'form', null, null, projectId, 'chat');
}, [openTask, projectId]);
return (
<Discussion
store={store}
projectId={projectId}
currentUser={currentUser}
candidates={candidates || EMPTY_ARRAY}
readOnly={readOnly}
toast={toast}
employees={employees}
onTaskClick={handleTaskClick}
showTaskLink={true}
tasks={tasks || EMPTY_ARRAY}
/>
);
};
+155
View File
@@ -0,0 +1,155 @@
// src/components/ProjectGallery.jsx
import { useEffect, useId, useState } from 'react';
import { Ic, ICONS } from './Icons';
import { fmtDMY } from '../utils/date';
const EMPTY_PHOTOS = [];
export const ProjectGallery = ({
photos = EMPTY_PHOTOS,
onUpload, // (files: File[], onDone: () => void) => void
onDelete,
onSetMain,
onOpenLightbox,
canUpload = true,
canDelete = true,
employeeName = (id) => id,
}) => {
const [uploading, setUploading] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const uploadInputId = useId();
const photoList = photos;
const preferredIndex = photoList.findIndex(p => p.isMain);
const [currentIndex, setCurrentIndex] = useState(() =>
preferredIndex >= 0 ? preferredIndex : 0
);
// Если фото удалили и индекс оказался за границей - вернуться к главному.
// В effect, а не во время рендера: setState во время рендера того же
// компонента - антипаттерн React.
useEffect(() => {
if (currentIndex >= photoList.length) {
setCurrentIndex(preferredIndex >= 0 ? preferredIndex : 0);
}
}, [currentIndex, photoList.length, preferredIndex]);
const currentPhoto = photoList[currentIndex] || null;
const handleFileChange = (e) => {
const files = Array.from(e.target.files || []);
e.target.value = '';
if (files.length === 0) return;
setUploading(true);
onUpload(files, () => setUploading(false));
};
const handlePrev = (e) => {
e.stopPropagation();
if (photoList.length <= 1) return;
setCurrentIndex(prev => (prev === 0 ? photoList.length - 1 : prev - 1));
};
const handleNext = (e) => {
e.stopPropagation();
if (photoList.length <= 1) return;
setCurrentIndex(prev => (prev === photoList.length - 1 ? 0 : prev + 1));
};
const uploadButton = canUpload && (
<div className="gallery-upload-wrapper">
<input
type="file"
accept="image/*"
multiple
id={uploadInputId}
className="file-input-hidden"
onChange={handleFileChange}
disabled={uploading}
/>
<label htmlFor={uploadInputId} className="btn primary sm">
{uploading ? 'Загрузка…' : 'Загрузить фото'}
</label>
</div>
);
if (photoList.length === 0) {
return (
<div className="gallery-empty-state">
<div className="gallery-empty-text">Нет фотографий</div>
{uploadButton}
</div>
);
}
return (
<div className="gallery-with-photos">
<div
className="gallery-main"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<img
src={currentPhoto.url}
alt={currentPhoto.name || 'Фото'}
className="gallery-main-image"
onClick={() => onOpenLightbox(currentIndex)}
/>
{isHovering && (
<div className="gallery-main-overlay">
{canDelete && (
<button
type="button"
className="icon-btn danger"
onClick={(e) => { e.stopPropagation(); onDelete(currentPhoto.id); }}
title="Удалить фото"
>
<Ic d={ICONS.trash} size={14} />
</button>
)}
{!currentPhoto.isMain && canUpload && (
<button
type="button"
className="icon-btn"
onClick={(e) => { e.stopPropagation(); onSetMain(currentPhoto.id); }}
title="Сделать главным"
>
<Ic d={ICONS.star} size={14} />
</button>
)}
{currentPhoto.isMain && (
<span className="gallery-main-badge" title="Главное фото">★</span>
)}
</div>
)}
<div className="gallery-counter">
{currentIndex + 1} / {photoList.length}
</div>
{photoList.length > 1 && (
<div className={`gallery-nav ${isHovering ? 'visible' : ''}`}>
<button type="button" className="gallery-nav-btn prev" onClick={handlePrev}>
<Ic d={ICONS.left} size={20} />
</button>
<button type="button" className="gallery-nav-btn next" onClick={handleNext}>
<Ic d={ICONS.right} size={20} />
</button>
</div>
)}
</div>
<div className="gallery-meta">
<span className="gallery-name">{currentPhoto.name}</span>
<span className="gallery-user">{employeeName(currentPhoto.uploadedBy)}</span>
{currentPhoto.uploadedAt && (
<span className="gallery-date">{fmtDMY(currentPhoto.uploadedAt)}</span>
)}
</div>
{uploadButton}
</div>
);
};
+54
View File
@@ -0,0 +1,54 @@
// components/ProjectProgress.jsx
import { useLayoutEffect, useRef } from 'react';
import { getProjectColor } from '../utils/projectHelpers';
/**
* План и факт часов проекта + прогресс-бар.
*
* Ширина и цвет заливки — через CSS-переменные, выставленные императивно
* (паттерн useStableModalHeight / TaskProgress / WorkloadBar). В JSX
* ни одного style={{}} — правило проекта.
*
* Показываем бар только если у проекта задан план (plannedHours > 0).
* Для административных проектов план обычно null — им бар не нужен.
*/
export default function ProjectProgress({ project, fact }) {
const plan = project?.plannedHours;
const barRef = useRef(null);
const hasBar = plan != null && plan > 0;
const over = hasBar && fact > plan;
const pct = hasBar ? Math.min(100, (fact / plan) * 100) : 0;
const color = getProjectColor(project);
useLayoutEffect(() => {
if (!barRef.current || !hasBar) return;
barRef.current.style.setProperty('--pj-progress-fill', `${pct}%`);
barRef.current.style.setProperty('--pj-progress-color', color);
}, [pct, color, hasBar]);
if (!hasBar) {
return (
<div className="pj-plan">
<div className="pj-plan-row">
<span>Факт: <b>{fact} ч</b></span>
</div>
</div>
);
}
return (
<div className="pj-plan">
<div className="pj-plan-row">
<span>План: <b>{plan} ч</b></span>
<span>Факт: <b className={over ? 'red' : ''}>{fact} ч</b></span>
</div>
<div className="pj-progress">
<div
ref={barRef}
className={`pj-progress-fill${over ? ' over' : ''}`}
/>
</div>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
// src/components/Projects.jsx
import { useMemo } from 'react';
import { PROJECT_STATUSES, PROJECT_TYPES, PROJECT_PRIORITIES } from '../utils/constants';
import { fmtDMY, initials } from '../utils/date';
import { isTaskActive } from '../utils/entityState';
import { Ic, ICONS } from './Icons';
import { getProjectColor } from '../utils/projectHelpers';
import ProjectProgress from './ProjectProgress';
import FloatingMenu from './FloatingMenu';
import { buildProjectMenu } from './menus';
import { chatKey } from '../utils/chatKey';
import { UnreadBadge } from './UnreadBadge';
/**
* Список карточек проектов.
*
* onMoveProject - единый колбэк смены статуса. Подтверждение для
* закрывающих статусов - забота самого колбэка
* (ProjectsView.handleMoveProject).
*
* Статистика по проектам (только fact) и справочник сотрудников
* предвычислены один раз до .map() по проектам: внутри цикла идут
* только обращения к Map.
*/
export default function Projects({
db,
ur,
openProject,
projects,
unreadIndex,
onMoveProject,
openCopyProject,
openTemplateFromProject,
openProjectAccess,
}) {
const employeesById = useMemo(
() => new Map(db.employees.map(e => [e.id, e])),
[db.employees],
);
/**
* Статистика по проектам одним проходом по задачам.
*
* Возвращает Map<projectId, { fact, activeTasks }>.
* activeTasks - список активных задач проекта: он нужен для отрисовки
* аватаров исполнителей на карточке. Храним его здесь же, а не
* фильтруем повторно в .map() по проектам.
*
* isTaskActive фильтрует архив и завершённые: тот же предикат, что в
* ProjectsView — цифры на карточке и в статистике совпадают.
*
* Суммируется только fact: план проекта живёт в project.plannedHours,
* отдельное поле «план задач» больше не показываем — вместо него
* сам план проекта в ProjectProgress.
*/
const projectStatsById = useMemo(() => {
const map = new Map();
const byProject = new Map();
for (const t of db.tasks) {
if (!isTaskActive(t)) continue;
let list = byProject.get(t.projectId);
if (!list) { list = []; byProject.set(t.projectId, list); }
list.push(t);
}
byProject.forEach((list, projectId) => {
let fact = 0;
for (const t of list) {
if (Array.isArray(t.logs)) {
for (const l of t.logs) fact += l.hours || 0;
}
}
map.set(projectId, { fact, activeTasks: list });
});
return map;
}, [db.tasks]);
return (
<div>
<div className="pj-grid">
{projects.map(p => {
const stats = projectStatsById.get(p.id) || { fact: 0, activeTasks: [] };
const projectColor = getProjectColor(p);
const unread = unreadIndex.get(chatKey({ projectId: p.id })) || 0;
const menuItems = buildProjectMenu({
project: p,
user: ur,
openProject,
onMove: onMoveProject,
onCopy: openCopyProject,
onMakeTemplate: openTemplateFromProject,
onOpenAccess: openProjectAccess,
});
return (
<FloatingMenu key={p.id} items={menuItems}>
{({ anchorProps, buttonProps }) => (
<div
{...anchorProps}
className="pj-card cursor-pointer relative"
onClick={() => openProject(p.id)}
>
<button
{...buttonProps}
className="icon-btn kcard-menu-btn"
title="Действия"
aria-label={`Действия с проектом ${p.name}`}
>
<Ic d={ICONS.more} size={15} />
</button>
<div className="pj-top">
<span className="pj-code" style={{ background: projectColor + '22', color: projectColor }}>
{p.code}
</span>
<span className={`pj-st ${p.status}`}>{PROJECT_STATUSES[p.status]}</span>
<span className="text-xs text-mut">{PROJECT_TYPES[p.ptype || 'prod']}</span>
<span className="pj-priority font-semibold" style={{ color: PROJECT_PRIORITIES[p.priority]?.color || '#64748b' }}>
{p.priority || 'NORM'}
</span>
</div>
<div className="pj-name">{p.name}</div>
<div className="pj-row">
<span className="mut">Сроки:</span> {fmtDMY(p.start)} - {p.end ? fmtDMY(p.end) : 'не задан'}
</div>
<ProjectProgress project={p} fact={stats.fact} />
<div className="pj-foot">
<div className="pj-avatars">
{stats.activeTasks.map(t => {
const a = t.assigneeId ? employeesById.get(t.assigneeId) : null;
return a && (
<span key={t.id} className="avatar xs" title={`${a.last} ${a.first}`}>
{initials(a.first, a.last)}
</span>
);
})}
</div>
</div>
<UnreadBadge
count={unread}
onClick={() => openProject(p.id, 'chat')}
/>
</div>
)}
</FloatingMenu>
);
})}
</div>
</div>
);
}
+942
View File
@@ -0,0 +1,942 @@
// src/components/Reports.jsx
import { useEffect, useMemo, useState } from 'react';
import { useToast } from '../context/ToastContext';
import { useDataHelpers } from '../hooks/useDataHelpers';
import { useFilters } from '../hooks/useFilters';
import { Select } from './Select';
import { Ic, ICONS } from './Icons';
import {
canSeeWipReport,
computeScope,
taskVisible,
} from '../utils/permissions';
import { TASK_STATUSES, PRIORITIES, PROJECT_STATUSES } from '../utils/constants';
import { optionsFromList, optionsFromMap } from '../utils/selectOptions';
import { fmtDMY } from '../utils/date';
import { getPrimaryDeptName } from '../utils/helpers';
import { downloadCsv } from '../utils/csvExport';
import { formatRub, formatRate, formatRubCsv, formatRateCsv } from '../utils/format';
import { calcWipHours } from '../utils/wip';
const REPORT_TYPES = [
{ value: 'tasks', label: 'Задачи' },
{ value: 'projects', label: 'Проекты' },
{ value: 'employees', label: 'Сотрудники' },
{ value: 'worklog', label: 'Трудозатраты' },
{ value: 'wip', label: 'Выручка по НЗП' },
];
const INITIAL_FILTERS = Object.freeze({
type: 'tasks',
dateFrom: '',
dateTo: '',
deadlineFrom: '',
deadlineTo: '',
closedFrom: '',
closedTo: '',
projectId: 'all',
assigneeId: 'all',
status: 'all',
priority: 'all',
customer: '',
});
const SAVED_FILTERS_KEY = 'savedReportFilters';
const EMPTY_STATS = { plan: 0, fact: 0, count: 0 };
const STATUS_SELECT_OPTIONS = optionsFromMap(TASK_STATUSES, 'Все');
const PRIORITY_SELECT_OPTIONS = optionsFromMap(PRIORITIES, 'Все');
function Reports({ db, ur }) {
const { showToast } = useToast();
const { empName, getTaskSpent } = useDataHelpers(db);
const scope = useMemo(() => computeScope(ur, db), [ur, db]);
const safeDb = useMemo(() => {
if (!db) {
return {
projects: [], tasks: [], employees: [],
departments: [], kbs: [], vacations: [],
};
}
return db;
}, [db]);
const { filters, setFilter, resetFilters } = useFilters(INITIAL_FILTERS);
const {
type: filterType,
dateFrom, dateTo,
deadlineFrom, deadlineTo,
closedFrom, closedTo,
projectId, assigneeId, status, priority, customer,
} = filters;
const [savedFilters, setSavedFilters] = useState(() => {
try {
const data = localStorage.getItem(SAVED_FILTERS_KEY);
return data ? JSON.parse(data) : [];
} catch { return []; }
});
const [filterName, setFilterName] = useState('');
const allProjects = useMemo(
() => safeDb.projects || [],
[safeDb.projects],
);
const allEmployees = useMemo(
() => safeDb.employees || [],
[safeDb.employees],
);
const projectsById = useMemo(
() => new Map(allProjects.map((p) => [p.id, p])),
[allProjects],
);
const employeesById = useMemo(
() => new Map(allEmployees.map((e) => [e.id, e])),
[allEmployees],
);
/**
* Статистика по проектам: только fact и count. plan остаётся для
* колонки «План (ч)» — она берётся не отсюда, а напрямую из
* project.plannedHours. stats.plan оставляем для совместимости
* (используется в CSV как агрегат по листьям — если понадобится).
*/
const projectStatsById = useMemo(() => {
const map = new Map();
(safeDb.tasks || []).forEach((t) => {
if (t.archived) return;
let s = map.get(t.projectId);
if (!s) { s = { plan: 0, fact: 0, count: 0 }; map.set(t.projectId, s); }
s.plan += t.plannedHours || 0;
if (Array.isArray(t.logs)) for (const l of t.logs) s.fact += l.hours || 0;
s.count += 1;
});
return map;
}, [safeDb.tasks]);
const employeeStatsById = useMemo(() => {
const map = new Map();
(safeDb.tasks || []).forEach((t) => {
if (!t.assigneeId) return;
let s = map.get(t.assigneeId);
if (!s) { s = { plan: 0, fact: 0, count: 0 }; map.set(t.assigneeId, s); }
s.plan += t.plannedHours || 0;
if (Array.isArray(t.logs)) for (const l of t.logs) s.fact += l.hours || 0;
s.count += 1;
});
return map;
}, [safeDb.tasks]);
const visibleProjects = useMemo(() => {
if (scope.all) return allProjects;
return allProjects.filter((p) => scope.projIds.has(p.id));
}, [allProjects, scope]);
const visibleEmployees = useMemo(() => {
if (scope.all) return allEmployees;
return allEmployees.filter((e) => scope.empIds.has(e.id));
}, [allEmployees, scope]);
const projectSelectOptions = useMemo(() => optionsFromList(
visibleProjects, 'Все проекты',
(p) => ({ value: p.id, label: `${p.code} - ${p.name}` }),
), [visibleProjects]);
const assigneeSelectOptions = useMemo(() => optionsFromList(
visibleEmployees, 'Все',
(e) => ({ value: e.id, label: `${e.last} ${e.first}` }),
), [visibleEmployees]);
// Реестр доступных типов отчёта: WIP скрыт от ролей без прав.
const availableReportTypes = useMemo(
() => REPORT_TYPES.filter(
(t) => t.value !== 'wip' || canSeeWipReport(ur),
),
[ur],
);
// Сохранённый фильтр мог остаться с type='wip' — если у роли нет
// доступа, откатываем на 'tasks', чтобы не показать пустой отчёт.
useEffect(() => {
if (filterType === 'wip' && !canSeeWipReport(ur)) {
setFilter('type', 'tasks');
}
}, [filterType, ur, setFilter]);
const handleFilterChange = setFilter;
const results = useMemo(() => {
const tasksList = safeDb.tasks || [];
const projectsList = safeDb.projects || [];
const employeesList = safeDb.employees || [];
if (filterType === 'tasks') {
let tasks = tasksList;
if (dateFrom || dateTo) {
tasks = tasks.filter((t) => {
if (!t.createdAt) return false;
if (dateFrom && t.createdAt < dateFrom) return false;
if (dateTo && t.createdAt > dateTo) return false;
return true;
});
}
if (deadlineFrom || deadlineTo) {
tasks = tasks.filter((t) => {
if (!t.deadline) return false;
if (deadlineFrom && t.deadline < deadlineFrom) return false;
if (deadlineTo && t.deadline > deadlineTo) return false;
return true;
});
}
if (closedFrom || closedTo) {
tasks = tasks.filter((t) => {
if (!t.closedAt) return false;
if (closedFrom && t.closedAt < closedFrom) return false;
if (closedTo && t.closedAt > closedTo) return false;
return true;
});
}
if (projectId !== 'all') tasks = tasks.filter((t) => t.projectId === projectId);
if (assigneeId !== 'all') tasks = tasks.filter((t) => t.assigneeId === assigneeId);
if (status !== 'all') tasks = tasks.filter((t) => t.status === status);
if (priority !== 'all') tasks = tasks.filter((t) => t.priority === priority);
if (customer) {
const q = customer.toLowerCase();
tasks = tasks.filter((t) => {
const project = projectsById.get(t.projectId);
return project && project.customer?.toLowerCase().includes(q);
});
}
tasks = tasks.filter((t) => taskVisible(ur, scope, t, safeDb));
return tasks;
}
if (filterType === 'projects') {
let projects = projectsList;
if (dateFrom) projects = projects.filter((p) => p.start >= dateFrom);
if (dateTo) projects = projects.filter((p) => p.start <= dateTo);
if (deadlineFrom) projects = projects.filter((p) => p.end && p.end >= deadlineFrom);
if (deadlineTo) projects = projects.filter((p) => p.end && p.end <= deadlineTo);
if (closedFrom) projects = projects.filter((p) => p.closedAt && p.closedAt >= closedFrom);
if (closedTo) projects = projects.filter((p) => p.closedAt && p.closedAt <= closedTo);
if (projectId !== 'all') projects = projects.filter((p) => p.id === projectId);
if (customer) {
const q = customer.toLowerCase();
projects = projects.filter((p) => p.customer?.toLowerCase().includes(q));
}
if (!scope.all) projects = projects.filter((p) => scope.projIds.has(p.id));
return projects;
}
if (filterType === 'employees') {
let employees = employeesList;
if (projectId !== 'all') {
const taskIds = tasksList
.filter((t) => t.projectId === projectId)
.map((t) => t.id);
employees = employees.filter((e) =>
tasksList.some((t) => t.assigneeId === e.id && taskIds.includes(t.id)),
);
}
if (assigneeId !== 'all') employees = employees.filter((e) => e.id === assigneeId);
if (customer) {
const q = customer.toLowerCase();
employees = employees.filter((e) => {
const userTasks = tasksList.filter((t) => t.assigneeId === e.id);
if (userTasks.length === 0) return false;
return userTasks.some((t) => {
const project = projectsById.get(t.projectId);
return project && project.customer?.toLowerCase().includes(q);
});
});
}
return employees;
}
if (filterType === 'worklog') {
let logs = [];
tasksList.forEach((t) => {
(t.logs || []).forEach((l) => {
logs.push({ ...l, taskTitle: t.title, projectId: t.projectId, assigneeId: t.assigneeId });
});
});
if (dateFrom) logs = logs.filter((l) => l.date >= dateFrom);
if (dateTo) logs = logs.filter((l) => l.date <= dateTo);
if (projectId !== 'all') logs = logs.filter((l) => l.projectId === projectId);
if (assigneeId !== 'all') logs = logs.filter((l) => l.userId === assigneeId);
if (customer) {
const q = customer.toLowerCase();
logs = logs.filter((l) => {
const project = projectsById.get(l.projectId);
return project && project.customer?.toLowerCase().includes(q);
});
}
if (!scope.all) {
const visibleTaskIds = tasksList
.filter((t) => taskVisible(ur, scope, t, safeDb))
.map((t) => t.id);
const visibleSet = new Set(visibleTaskIds);
logs = logs.filter((l) => visibleSet.has(l.taskId));
}
return logs;
}
if (filterType === 'wip') {
const activeProjects = projectsList.filter((p) => p.status === 'active');
const filtered = projectId === 'all'
? activeProjects
: activeProjects.filter((p) => p.id === projectId);
return filtered.map((p) => {
const hours = calcWipHours(p, tasksList, dateFrom, dateTo);
const rate = Number(p.hourlyRate) || 0;
const hasRate = rate > 0;
return {
project: p,
hours,
rate: hasRate ? rate : null,
revenue: hasRate ? hours * rate : null,
};
});
}
return [];
}, [
filterType,
dateFrom, dateTo,
deadlineFrom, deadlineTo,
closedFrom, closedTo,
projectId, assigneeId, status, priority, customer,
safeDb, ur, scope, projectsById,
]);
const saveFilter = () => {
if (!filterName.trim()) {
showToast('Введите название фильтра', 'warning');
return;
}
const newFilter = {
id: Date.now(),
name: filterName.trim(),
filters: { ...filters },
};
const updated = [...savedFilters, newFilter];
setSavedFilters(updated);
localStorage.setItem(SAVED_FILTERS_KEY, JSON.stringify(updated));
setFilterName('');
showToast('Фильтр сохранён', 'success');
};
const loadFilter = (filter) => {
Object.entries(filter.filters).forEach(([key, value]) => setFilter(key, value));
setFilterName(filter.name);
};
const deleteSavedFilter = (id) => {
const updated = savedFilters.filter((f) => f.id !== id);
setSavedFilters(updated);
localStorage.setItem(SAVED_FILTERS_KEY, JSON.stringify(updated));
};
/**
* Формирует строки CSV для текущего типа отчёта. Набор колонок
* повторяет набор колонок в UI-таблице — 1:1.
*
* Форматтеры выручки/ставки — единая точка utils/format.js.
*/
const buildReportRows = () => {
const header = {
tasks: [
'#', 'Задача', 'Проект', 'Исполнитель', 'Статус', 'Приоритет',
'План (ч)', 'Факт (ч)', 'Срок исполнения', 'Закрыто',
],
projects: [
'#', 'Код', 'Проект', 'Статус', 'Заказчик',
'План (ч)', 'Факт (ч)', 'Ответственный', 'Закрыто',
],
employees: [
'#', 'Сотрудник', 'Отдел (основной)',
'План (ч)', 'Факт (ч)', 'Кол-во задач',
],
worklog: [
'#', 'Дата', 'Сотрудник', 'Задача', 'Проект', 'Часы', 'Комментарий',
],
wip: [
'#', 'Проект (код)', 'Номер договора', 'Заказчик',
'Ставка ₽/ч', 'Часы НЗП за период', 'Выручка ₽',
],
}[filterType] || [];
const rows = [header];
if (filterType === 'tasks') {
results.forEach((t, idx) => {
const project = projectsById.get(t.projectId);
rows.push([
idx + 1,
t.title || '',
project?.code || '',
t.assigneeId ? empName(t.assigneeId) : '',
TASK_STATUSES[t.status]?.label || t.status || '',
PRIORITIES[t.priority]?.label || t.priority || '',
t.plannedHours ?? '',
getTaskSpent(t),
t.deadline ? fmtDMY(t.deadline) : '',
t.closedAt ? fmtDMY(t.closedAt) : '',
]);
});
} else if (filterType === 'projects') {
results.forEach((p, idx) => {
const stats = projectStatsById.get(p.id) || EMPTY_STATS;
rows.push([
idx + 1,
p.code || '',
p.name || '',
PROJECT_STATUSES[p.status] || p.status || '',
p.customer || '',
p.plannedHours ?? '',
stats.fact,
p.managerId ? empName(p.managerId) : '',
p.closedAt ? fmtDMY(p.closedAt) : '',
]);
});
} else if (filterType === 'employees') {
results.forEach((e, idx) => {
const stats = employeeStatsById.get(e.id) || EMPTY_STATS;
rows.push([
idx + 1,
`${e.last || ''} ${e.first || ''}`.trim(),
getPrimaryDeptName(e, safeDb),
stats.plan,
stats.fact,
stats.count,
]);
});
} else if (filterType === 'worklog') {
results.forEach((l, idx) => {
const project = projectsById.get(l.projectId);
const user = employeesById.get(l.userId);
rows.push([
idx + 1,
fmtDMY(l.date),
user ? `${user.last} ${user.first}` : '',
l.taskTitle || '',
project?.code || '',
l.hours ?? '',
l.note || '',
]);
});
} else if (filterType === 'wip') {
results.forEach((r, idx) => {
rows.push([
idx + 1,
r.project.code || '',
r.project.contractNumber || '',
r.project.customer || '',
formatRateCsv(r.rate),
r.hours,
formatRubCsv(r.revenue),
]);
});
}
return rows;
};
const buildFileName = () => {
const from = dateFrom || closedFrom || 'start';
const to = dateTo || closedTo || 'end';
if (filterType === 'wip') return `report_wip_${from}_${to}`;
return `report_${filterType}_${from}_${to}`;
};
const handleExport = () => {
if (results.length === 0) {
showToast('Нет данных для выгрузки', 'warning');
return;
}
downloadCsv(buildFileName(), buildReportRows());
showToast(`Выгружено строк: ${results.length}`, 'success');
};
const renderResults = () => {
if (results.length === 0) {
return <div className="empty-note p-4">Нет данных, соответствующих фильтрам</div>;
}
if (filterType === 'tasks') {
return (
<div className="w-full overflow-x-auto">
<table className="tbl tbl-report" style={{ minWidth: '860px' }}>
<thead>
<tr>
<th>#</th><th>Задача</th><th>Проект</th><th>Исполнитель</th>
<th>Статус</th><th>Приоритет</th>
<th>План (ч)</th><th>Факт (ч)</th>
<th>Срок исполнения</th><th>Закрыто</th>
</tr>
</thead>
<tbody>
{results.map((t, idx) => {
const project = projectsById.get(t.projectId);
const statusDef = TASK_STATUSES[t.status] || { label: t.status || 'Неизвестно', color: '#64748b' };
const priorityDef = PRIORITIES[t.priority] || { label: t.priority || 'Неизвестно', color: '#64748b' };
return (
<tr key={t.id}>
<td>{idx + 1}</td>
<td><b>{t.title}</b></td>
<td>{project?.code || '-'}</td>
<td>{t.assigneeId ? empName(t.assigneeId) : '-'}</td>
<td>
<span className="st-chip" style={{ background: statusDef.color + '22', color: statusDef.color }}>
{statusDef.label}
</span>
</td>
<td><span style={{ color: priorityDef.color }}>{priorityDef.label}</span></td>
<td>{t.plannedHours ?? '-'}</td>
<td>{getTaskSpent(t)}</td>
<td>{t.deadline ? fmtDMY(t.deadline) : '-'}</td>
<td>{fmtDMY(t.closedAt)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
if (filterType === 'projects') {
return (
<div className="w-full overflow-x-auto">
<table className="tbl tbl-report" style={{ minWidth: '760px' }}>
<thead>
<tr>
<th>#</th><th>Код</th><th>Проект</th><th>Статус</th>
<th>Заказчик</th>
<th>План (ч)</th><th>Факт (ч)</th>
<th>Ответственный</th><th>Закрыто</th>
</tr>
</thead>
<tbody>
{results.map((p, idx) => {
const stats = projectStatsById.get(p.id) || EMPTY_STATS;
return (
<tr key={p.id}>
<td>{idx + 1}</td>
<td><b>{p.code}</b></td>
<td>{p.name}</td>
<td>
<span className={`st-chip ${p.status === 'active' ? 'active' : ''}`}>
{PROJECT_STATUSES[p.status] || p.status}
</span>
</td>
<td>{p.customer || '-'}</td>
<td>{p.plannedHours ?? '-'}</td>
<td>{stats.fact}</td>
<td>{empName(p.managerId)}</td>
<td>{fmtDMY(p.closedAt)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
if (filterType === 'employees') {
return (
<div className="w-full overflow-x-auto">
<table className="tbl tbl-report" style={{ minWidth: '600px' }}>
<thead>
<tr>
<th>#</th><th>Сотрудник</th><th>Отдел (основной)</th>
<th>План (ч)</th><th>Факт (ч)</th><th>Кол-во задач</th>
</tr>
</thead>
<tbody>
{results.map((e, idx) => {
const deptName = getPrimaryDeptName(e, safeDb);
const stats = employeeStatsById.get(e.id) || EMPTY_STATS;
return (
<tr key={e.id}>
<td>{idx + 1}</td>
<td><b>{e.last} {e.first}</b></td>
<td>{deptName}</td>
<td>{stats.plan}</td>
<td>{stats.fact}</td>
<td>{stats.count}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
if (filterType === 'worklog') {
return (
<div className="w-full overflow-x-auto">
<table className="tbl tbl-report" style={{ minWidth: '700px' }}>
<thead>
<tr>
<th>#</th><th>Дата</th><th>Сотрудник</th><th>Задача</th>
<th>Проект</th><th>Часы</th><th>Комментарий</th>
</tr>
</thead>
<tbody>
{results.map((l, idx) => {
const project = projectsById.get(l.projectId);
const user = employeesById.get(l.userId);
return (
<tr key={idx}>
<td>{idx + 1}</td>
<td>{fmtDMY(l.date)}</td>
<td>{user ? `${user.last} ${user.first}` : '-'}</td>
<td>{l.taskTitle}</td>
<td>{project?.code || '-'}</td>
<td><b>{l.hours}</b></td>
<td>{l.note || '-'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
if (filterType === 'wip') {
const withRate = results.filter((r) => r.revenue != null);
const withoutRate = results.filter((r) => r.revenue == null);
const totalHours = withRate.reduce((s, r) => s + r.hours, 0);
const totalRevenue = withRate.reduce((s, r) => s + r.revenue, 0);
const zeroHours = withoutRate.reduce((s, r) => s + r.hours, 0);
return (
<div className="w-full overflow-x-auto">
<table className="tbl tbl-report tbl-report--wip">
<thead>
<tr>
<th>#</th>
<th>Проект (код)</th>
<th>Номер договора</th>
<th>Заказчик</th>
<th>Ставка ₽/ч</th>
<th>Часы НЗП за период</th>
<th>Затраты ₽</th>
</tr>
</thead>
<tbody>
{withRate.map((r, idx) => (
<tr key={r.project.id}>
<td>{idx + 1}</td>
<td><b>{r.project.code}</b></td>
<td>{r.project.contractNumber || '—'}</td>
<td>{r.project.customer || '—'}</td>
<td>{formatRate(r.rate)}</td>
<td>{r.hours}</td>
<td><b>{formatRub(r.revenue)}</b></td>
</tr>
))}
<tr className="tbl-total-row">
<td colSpan={5}><b>Итого</b></td>
<td><b>{totalHours}</b></td>
<td><b>{formatRub(totalRevenue)}</b></td>
</tr>
</tbody>
</table>
{withoutRate.length > 0 && (
<div className="mt-3">
<b>
Без ставки: {withoutRate.length} проектов, {zeroHours} часов
</b>
<table className="tbl tbl-report tbl-report--wip tbl-report--wip-without mt-2">
<thead>
<tr>
<th>#</th>
<th>Проект (код)</th>
<th>Номер договора</th>
<th>Заказчик</th>
<th>Часы НЗП за период</th>
<th>Затраты ₽</th>
</tr>
</thead>
<tbody>
{withoutRate.map((r, idx) => (
<tr key={r.project.id}>
<td>{idx + 1}</td>
<td><b>{r.project.code}</b></td>
<td>{r.project.contractNumber || '—'}</td>
<td>{r.project.customer || '—'}</td>
<td>{r.hours}</td>
<td>—</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
};
const showDateRange = filterType !== 'employees';
const showDeadlineRange = filterType === 'tasks' || filterType === 'projects';
const showClosedRange = filterType === 'tasks' || filterType === 'projects';
const showStatusPriority = filterType === 'tasks';
// У WIP уже есть отдельная колонка «Заказчик» — фильтр по заказчику
// там не нужен.
const showCustomer = filterType !== 'wip';
return (
<div className="rep">
<div className="rep-panel p-4">
<div className="rep-panel-title">Фильтры отчёта</div>
<div className="toolbar mb-2">
<label className="lbl m-0">Тип отчёта:</label>
<div className="seg">
{availableReportTypes.map((typeOption) => (
<button
key={typeOption.value}
className={`seg-btn${filterType === typeOption.value ? ' on' : ''}`}
onClick={() => handleFilterChange('type', typeOption.value)}
>
{typeOption.label}
</button>
))}
</div>
</div>
<div className="toolbar filters-row">
{showDateRange && (
<div className="filter-group">
<label className="lbl m-0">
{filterType === 'worklog' ? 'Дата записи:' :
filterType === 'projects' ? 'Начало:' :
filterType === 'wip' ? 'Период НЗП:' :
'ДАТА СОЗДАНИЯ:'}
</label>
<input
className="inp w-150"
type="date"
value={dateFrom}
onChange={(e) => handleFilterChange('dateFrom', e.target.value)}
/>
<span className="filter-group-sep">-</span>
<input
className="inp w-150"
type="date"
value={dateTo}
onChange={(e) => handleFilterChange('dateTo', e.target.value)}
/>
</div>
)}
{showDeadlineRange && (
<div className="filter-group">
<label className="lbl m-0">
{filterType === 'projects' ? 'Окончание:' : 'Срок исполнения:'}
</label>
<input
className="inp w-150"
type="date"
value={deadlineFrom}
onChange={(e) => handleFilterChange('deadlineFrom', e.target.value)}
/>
<span className="filter-group-sep">-</span>
<input
className="inp w-150"
type="date"
value={deadlineTo}
onChange={(e) => handleFilterChange('deadlineTo', e.target.value)}
/>
</div>
)}
{showClosedRange && (
<div className="filter-group">
<label className="lbl m-0">Дата закрытия:</label>
<input
className="inp w-150"
type="date"
value={closedFrom}
onChange={(e) => handleFilterChange('closedFrom', e.target.value)}
/>
<span className="filter-group-sep">-</span>
<input
className="inp w-150"
type="date"
value={closedTo}
onChange={(e) => handleFilterChange('closedTo', e.target.value)}
/>
</div>
)}
<div className="filter-group">
<label className="lbl m-0">Проект:</label>
<Select
className="w-180"
value={projectId}
onChange={(v) => handleFilterChange('projectId', v)}
options={projectSelectOptions}
/>
</div>
{filterType !== 'wip' && (
<div className="filter-group">
<label className="lbl m-0">Исполнитель:</label>
<Select
className="w-180"
value={assigneeId}
onChange={(v) => handleFilterChange('assigneeId', v)}
options={assigneeSelectOptions}
/>
</div>
)}
{showCustomer && (
<div className="filter-group">
<label className="lbl m-0">Заказчик:</label>
<input
className="inp w-200"
type="text"
value={customer}
onChange={(e) => handleFilterChange('customer', e.target.value)}
placeholder="поиск по названию"
/>
</div>
)}
{showStatusPriority && (
<>
<div className="filter-group">
<label className="lbl m-0">Статус задачи:</label>
<Select
className="w-140"
value={status}
onChange={(v) => handleFilterChange('status', v)}
options={STATUS_SELECT_OPTIONS}
/>
</div>
<div className="filter-group">
<label className="lbl m-0">Приоритет:</label>
<Select
className="w-140"
value={priority}
onChange={(v) => handleFilterChange('priority', v)}
options={PRIORITY_SELECT_OPTIONS}
/>
</div>
</>
)}
</div>
<div className="mt-3 flex gap-3 flex-wrap">
<button className="btn ghost" onClick={resetFilters}>Сбросить фильтры</button>
<div className="flex gap-2 items-center">
<input
className="inp w-180"
type="text"
value={filterName}
onChange={(e) => setFilterName(e.target.value)}
placeholder="Название шаблона"
/>
<button className="btn ghost" onClick={saveFilter}>
<Ic d={ICONS.plus} size={13} /> Сохранить фильтр
</button>
</div>
</div>
</div>
{savedFilters.length > 0 && (
<div className="rep-panel p-4">
<div className="rep-panel-title">Сохранённые шаблоны</div>
<div className="flex flex-wrap gap-3">
{savedFilters.map((f) => {
const criteria = [];
const typeLabel = REPORT_TYPES.find((t) => t.value === f.filters.type)?.label || 'Задачи';
criteria.push(`Тип: ${typeLabel}`);
if (f.filters.dateFrom || f.filters.dateTo) {
const from = f.filters.dateFrom ? fmtDMY(f.filters.dateFrom) : '';
const to = f.filters.dateTo ? fmtDMY(f.filters.dateTo) : '';
criteria.push(`Период: ${from} - ${to}`);
}
if (f.filters.deadlineFrom || f.filters.deadlineTo) {
const from = f.filters.deadlineFrom ? fmtDMY(f.filters.deadlineFrom) : '';
const to = f.filters.deadlineTo ? fmtDMY(f.filters.deadlineTo) : '';
criteria.push(`Срок исполнения: ${from} - ${to}`);
}
if (f.filters.closedFrom || f.filters.closedTo) {
const from = f.filters.closedFrom ? fmtDMY(f.filters.closedFrom) : '';
const to = f.filters.closedTo ? fmtDMY(f.filters.closedTo) : '';
criteria.push(`Закрыто: ${from} - ${to}`);
}
if (f.filters.projectId !== 'all') {
const proj = projectsById.get(f.filters.projectId);
criteria.push(`Проект: ${proj?.code || '-'}`);
}
if (f.filters.assigneeId !== 'all') {
const emp = employeesById.get(f.filters.assigneeId);
criteria.push(`Исполнитель: ${emp ? emp.last : '-'}`);
}
if (f.filters.status !== 'all') {
criteria.push(`Статус: ${TASK_STATUSES[f.filters.status]?.label || f.filters.status}`);
}
if (f.filters.priority !== 'all') {
criteria.push(`Приоритет: ${PRIORITIES[f.filters.priority]?.label || f.filters.priority}`);
}
if (f.filters.customer) criteria.push(`Заказчик: ${f.filters.customer}`);
const displayText = criteria.length ? criteria.join(' · ') : 'Все';
return (
<div
key={f.id}
className="pj-card cursor-pointer p-3 relative saved-filter-card"
>
<div className="font-bold text-sm mb-1">{f.name}</div>
<div className="text-xs text-mut leading-tight">{displayText}</div>
<button
className="icon-btn absolute top-1 right-1"
onClick={(e) => { e.stopPropagation(); deleteSavedFilter(f.id); }}
title="Удалить шаблон"
>
<Ic d={ICONS.x} size={14} />
</button>
<div className="mt-2">
<button className="btn ghost sm" onClick={() => loadFilter(f)}>Загрузить</button>
</div>
</div>
);
})}
</div>
</div>
)}
<div className="rep-panel p-4">
<div className="flex justify-between items-center mb-3">
<div className="rep-panel-title m-0">Результаты ({results.length})</div>
<button
className="btn primary sm"
onClick={handleExport}
disabled={results.length === 0}
>
<Ic d={ICONS.download} size={13} /> Выгрузить CSV
</button>
</div>
{renderResults()}
</div>
</div>
);
}
export default Reports;
+506
View File
@@ -0,0 +1,506 @@
// src/components/Requests.jsx
import { useCallback, useMemo, useState } from "react";
import { useStore } from "../context/StoreContext";
import { useToast } from "../context/ToastContext";
import { useConfirm } from "../context/ConfirmContext";
import { useDataHelpers } from "../hooks/useDataHelpers";
import { CHANGE_KINDS, CHANGE_KIND_LIST } from "../utils/changeKinds";
import {
hasRole,
canViewRoleDelegation,
canManageSubstitution,
canDecideDelegation
} from "../utils/permissions";
import {
ASSIGNMENT_APPROVAL_STATUSES,
ASSIGNMENT_TARGET_KINDS
} from "../utils/assignmentApproval";
import { substitutionKindLabel, ROLES } from "../utils/constants";
import { fmtDMY, TODAY } from "../utils/date";
import { RejectionReasonModal } from "./Modals/RejectionReasonModal";
import { ReassignApprovalModal } from "./Modals/ReassignApprovalModal";
export function Requests({ db, ur, initialTab = "hours" }) {
const { store } = useStore();
const { showToast } = useToast();
const { confirm } = useConfirm();
const { empName } = useDataHelpers(db);
const [tab, setTab] = useState(initialTab);
const [rejecting, setRejecting] = useState(null);
const [reassignFor, setReassignFor] = useState(null);
const canDecideChange = hasRole(ur, "director", "admin");
const isAdmin = hasRole(ur, "admin");
const tabs = useMemo(() => {
const list = [];
if (canDecideChange) CHANGE_KIND_LIST.forEach((k) => list.push([k.id, k.label]));
list.push(["assignments", "Согласование назначений"]);
list.push(["rd", "Передача ролей и замещения"]);
if (isAdmin) list.push(["reg", "Заявки на регистрацию"]);
return list;
}, [canDecideChange, isAdmin]);
const tasksById = useMemo(
() => new Map(db.tasks.map((t) => [t.id, t])),
[db.tasks]
);
const projectsById = useMemo(
() => new Map(db.projects.map((p) => [p.id, p])),
[db.projects]
);
const targetTitleOf = useCallback(
(r) => {
const fallback = r.targetTitle || "(удалено)";
if (r.targetType === "task") return tasksById.get(r.targetId)?.title || fallback;
return projectsById.get(r.targetId)?.name || fallback;
},
[tasksById, projectsById]
);
const decide = useCallback(
(fn, fallbackMessage) => {
try {
fn();
} catch (err) {
showToast(err.message || fallbackMessage, "error");
}
},
[showToast]
);
const decideChange = useCallback(
(r, ok, reason = null) =>
decide(() => store.decideChangeRequest(r.id, ok, reason), "Не удалось применить запрос"),
[store, decide]
);
const decideRD = useCallback(
(r, ok, reason = null) =>
decide(() => store.decideRoleDelegation(r.id, ok, reason), "Не удалось применить решение по замещению"),
[store, decide]
);
const decideReg = useCallback(
(r, ok, reason = null) =>
decide(() => store.decideRegistration(r.id, ok, reason), "Не удалось применить решение по регистрации"),
[store, decide]
);
const revokeRD = useCallback(
(r) => decide(() => store.revokeRoleDelegation(r.id), "Не удалось отозвать замещение"),
[store, decide]
);
const forceActivateRD = useCallback(
async (r) => {
const ok = await confirm({
message:
"Применить замещение до согласия получателя? Получатель не сможет отклонить его.",
confirmLabel: "Применить",
});
if (!ok) return;
decide(
() => store.forceActivateRoleDelegation(r.id),
"Не удалось применить замещение"
);
},
[store, decide, confirm]
);
const openReject = useCallback((kind, item) => setRejecting({ kind, item }), []);
const confirmReject = useCallback(
(reason) => {
if (!rejecting) return;
const { kind, item } = rejecting;
if (CHANGE_KINDS[kind]) decideChange(item, false, reason);
else if (kind === "rd") decideRD(item, false, reason);
else if (kind === "reg") decideReg(item, false, reason);
else if (kind === "assignment") {
decide(
() => store.decideAssignmentApproval(item.id, "reject", { reason }),
"Не удалось отклонить заявку"
);
}
setRejecting(null);
},
[rejecting, store, decide, decideChange, decideRD, decideReg]
);
const closeReject = useCallback(() => setRejecting(null), []);
const confirmReassign = useCallback(
(newAssigneeId) => {
if (!reassignFor) return;
decide(
() => store.decideAssignmentApproval(reassignFor.id, "reassign", { newAssigneeId }),
"Не удалось переназначить"
);
setReassignFor(null);
},
[reassignFor, store, decide]
);
const closeReassign = useCallback(() => setReassignFor(null), []);
const rejectionTitle = useMemo(() => {
if (!rejecting) return "Причина отклонения";
if (CHANGE_KINDS[rejecting.kind]) {
return `Причина отклонения: ${CHANGE_KINDS[rejecting.kind].label.toLowerCase()}`;
}
switch (rejecting.kind) {
case "rd":
return "Причина отклонения замещения";
case "reg":
return "Причина отклонения регистрации";
case "assignment":
return "Причина отклонения назначения";
default:
return "Причина отклонения";
}
}, [rejecting]);
const renderChangeKindTab = (kindId) => {
const kind = CHANGE_KINDS[kindId];
const items = (db.changeRequests || []).filter((r) => r.changeKind === kindId);
return (
<div className="rep-panel">
<div className="rep-panel-title">Запросы: {kind.label.toLowerCase()}</div>
<table className="tbl">
<thead>
<tr>
<th>Объект</th>
<th>Текущее</th>
<th>Предлагаемое</th>
<th>Обоснование</th>
<th>Запросил</th>
<th>Решение</th>
</tr>
</thead>
<tbody>
{items.length === 0 && (
<tr>
<td colSpan="6" className="mut text-center">
Запросов нет
</td>
</tr>
)}
{items.map((r) => (
<tr key={r.id}>
<td>
<b>{targetTitleOf(r)}</b>
</td>
<td>{kind.formatValue(r.oldValue)}</td>
<td>
<b>{kind.formatValue(r.newValue)}</b>
</td>
<td className="mut sm">{r.reason}</td>
<td>{empName(r.reqId)}</td>
<td>
{r.status === "pending" ? (
<>
<button className="btn primary sm" onClick={() => decideChange(r, true)}>
Подтвердить
</button>
<button
className="btn danger sm"
onClick={() => openReject(kindId, r)}
>
Отклонить
</button>
</>
) : (
<span className={`st-chip ${r.status === "approved" ? "approved" : "rejected"}`}>
{r.status}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
const renderAssignmentsTab = () => {
const mine = (db.assignmentApprovals || []).filter((a) => a.approverId === ur.id);
return (
<div className="rep-panel">
<div className="rep-panel-title">Заявки на согласование назначения</div>
<table className="tbl">
<thead>
<tr>
<th>Объект</th>
<th>Тип</th>
<th>Исполнитель</th>
<th>Запросил</th>
<th>Дата</th>
<th>Решение</th>
</tr>
</thead>
<tbody>
{mine.length === 0 && (
<tr>
<td colSpan="6" className="mut text-center">
Заявок нет
</td>
</tr>
)}
{mine.map((r) => {
const kind = ASSIGNMENT_TARGET_KINDS[r.targetType];
const status = ASSIGNMENT_APPROVAL_STATUSES[r.status];
return (
<tr key={r.id}>
<td>
<b>{targetTitleOf(r)}</b>
</td>
<td>{kind?.label || r.targetType}</td>
<td>{empName(r.assigneeId)}</td>
<td>{empName(r.requestedBy)}</td>
<td className="mut sm">{fmtDMY(r.createdAt)}</td>
<td>
{r.status === "pending" ? (
<>
<button
className="btn primary sm"
onClick={() =>
decide(
() => store.decideAssignmentApproval(r.id, "approve"),
"Не удалось согласовать"
)
}
>
Согласовать
</button>{" "}
<button
className="btn ghost sm"
onClick={() => setReassignFor(r)}
>
Переназначить
</button>{" "}
<button
className="btn danger sm"
onClick={() => openReject("assignment", r)}
>
Отклонить
</button>
</>
) : (
<span className={`st-chip ${r.status}`}>{status?.label}</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
};
const renderSubstitutionsTab = () => {
// Видимость записи — через доменный предикат: admin/director,
// автор передачи или получатель. Остальные записи не показываются.
const all = (db.roleDelegations || []).filter((rd) =>
canViewRoleDelegation(ur, rd)
);
return (
<div className="rep-panel">
<div className="rep-panel-title">Передача ролей и замещения</div>
<table className="tbl">
<thead>
<tr>
<th>От</th>
<th>Кому</th>
<th>Повод</th>
<th>Период</th>
<th>Роли</th>
<th>Статус / действие</th>
</tr>
</thead>
<tbody>
{all.length === 0 && (
<tr>
<td colSpan="6" className="mut text-center">
Записей нет
</td>
</tr>
)}
{all.map((r) => {
const isPending = r.status === "pending";
const isActive = r.status === "active";
const canDecide = isPending && canDecideDelegation(ur, r);
const canForceActivate = isPending && hasRole(ur, "admin", "director");
const canRevoke = isActive && canManageSubstitution(ur, r);
const isPastEnd = isActive && r.end && r.end < TODAY;
const showApplyNow = canForceActivate && r.start <= TODAY;
return (
<tr key={r.id}>
<td>{empName(r.fromId)}</td>
<td>{empName(r.toId)}</td>
<td className="mut sm">{substitutionKindLabel(r.kind)}</td>
<td className="mut sm">
{fmtDMY(r.start)} - {r.end ? fmtDMY(r.end) : "до отмены"}
</td>
<td>
{(r.roles || []).map((x) => ROLES[x]?.label || x).join(", ")}
</td>
<td>
{isPending && (
<>
{canDecide && (
<>
<button className="btn primary sm" onClick={() => decideRD(r, true)}>
Принять
</button>{" "}
<button
className="btn danger sm"
onClick={() => openReject("rd", r)}
>
Отклонить
</button>
</>
)}
{showApplyNow && (
<>
{canDecide && " "}
<button
className="btn primary sm"
onClick={() => forceActivateRD(r)}
>
Применить сейчас
</button>
</>
)}
{!canDecide && !canForceActivate && (
<span className="st-chip">ожидает решения получателя</span>
)}
</>
)}
{isActive && (
<>
<span className="st-chip approved">активно</span>
{isPastEnd && (
<>
{" "}
<span className="st-chip">истекло</span>
</>
)}
{canRevoke && (
<>
{" "}
<button
className="btn ghost sm"
onClick={() => revokeRD(r)}
>
{isPastEnd ? "Завершить" : "Отозвать"}
</button>
</>
)}
</>
)}
{r.status === "rejected" && (
<span className="st-chip rejected">отклонено</span>
)}
{r.status === "revoked" && (
<span className="st-chip revoked">отозвано</span>
)}
{r.status === "expired" && (
<span className="st-chip">истекло</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
};
return (
<div>
<div className="tabs">
{tabs.map(([id, l]) => (
<button
key={id}
className={"tab" + (tab === id ? " on" : "")}
onClick={() => setTab(id)}
>
{l}
</button>
))}
</div>
{canDecideChange && CHANGE_KINDS[tab] && renderChangeKindTab(tab)}
{tab === "assignments" && renderAssignmentsTab()}
{tab === "rd" && renderSubstitutionsTab()}
{tab === "reg" && hasRole(ur, "admin") && (
<div className="rep-panel">
<div className="rep-panel-title">Заявки на регистрацию</div>
<table className="tbl">
<thead>
<tr>
<th>ФИО</th>
<th>E-mail</th>
<th>Решение</th>
</tr>
</thead>
<tbody>
{db.regRequests.map((r) => (
<tr key={r.id}>
<td>
<b>
{r.last} {r.first}
</b>
</td>
<td>{r.email}@hor.ru</td>
<td>
{r.status === "pending" ? (
<>
<button className="btn primary sm" onClick={() => decideReg(r, true)}>
Одобрить
</button>{" "}
<button
className="btn danger sm"
onClick={() => openReject("reg", r)}
>
Отклонить
</button>
</>
) : (
<span
className={`st-chip ${r.status === "approved" ? "approved" : "rejected"}`}
>
{r.status}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{rejecting && (
<RejectionReasonModal
title={rejectionTitle}
onSubmit={confirmReject}
onClose={closeReject}
/>
)}
{reassignFor && (
<ReassignApprovalModal
request={reassignFor}
ur={ur}
db={db}
onConfirm={confirmReassign}
onClose={closeReassign}
/>
)}
</div>
);
}
export default Requests;
+45
View File
@@ -0,0 +1,45 @@
// src/components/SearchBox.jsx
import { Ic, ICONS } from './Icons';
/**
* Строка поиска: иконка + input + кнопка очистки.
*
* Единая точка правды для визуала поиска по спискам. Значение и логика
* фильтрации живут у вызывающего компонента - SearchBox не знает ни о
* сотрудниках, ни о проектах, ни о правах.
*
* Ширина и растяжение задаются контекстом через className (см. правила
* .search-box и модификаторы в styles.css). Компонент не диктует layout.
*/
export const SearchBox = ({
value,
onChange,
placeholder = 'Поиск…',
className = '',
autoFocus = false,
}) => {
const classes = `search-box${className ? ' ' + className : ''}`;
return (
<div className={classes}>
<Ic d={ICONS.search} size={15} />
<input
type="text"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
autoFocus={autoFocus}
/>
{value && (
<button
type="button"
className="icon-btn xs"
onClick={() => onChange('')}
title="Очистить"
>
<Ic d={ICONS.x} size={14} />
</button>
)}
</div>
);
};
+370
View File
@@ -0,0 +1,370 @@
// src/components/Select.jsx
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { sortOptions } from '../utils/selectOptions';
/**
* Кастомный выпадающий список с поиском и множественным выбором.
*
* Порядок опций нормализуется здесь: sortOptions поднимает служебные
* пункты ('', 'all', 'any', 'none') наверх, остальные раскладывает по
* алфавиту. Правило одно на весь проект - вызывающий код про сортировку
* не думает и не дублирует её в optionsFromMap / optionsFromList.
*
* autoSort={false} отключает нормализацию: список рендерится в том
* порядке, в каком его передал вызывающий. Нужно там, где порядок опций
* семантичен и не совпадает с алфавитным - например, в SortControl.
*
* placement задаёт сторону открытия попапа:
* 'auto' — по умолчанию: вниз, если снизу достаточно места,
* иначе вверх (с учётом границ .modal-body, если он есть);
* 'top' — всегда вверх;
* 'bottom' — всегда вниз.
*
* Позиционирование попапа - через CSS-переменные --select-popup-*
* (паттерн useStableModalHeight / TaskProgress / WorkloadBar).
* В JSX попапа никаких style={{}} - разметка чистая.
*
* MOD: расчёт позиции учитывает границы ближайшего .modal-body.
* Раньше попап «смотрел» только на viewport: при открытии в модалке
* он вылезал за пределы прокручиваемой области и перекрывал поля
* ниже (типичный случай — многозначный выбор ролей в «Передаче
* полномочий», перекрывающий даты). Также добавлен resize-listener:
* при ресайзе окна попап пересчитывает позицию на месте.
*
* Навигация с клавиатуры - через onKeyDown на самом попапе, не через
* document-listener. Escape остаётся на документе (capture).
*/
export const Select = ({
id,
value,
onChange,
options,
disabled,
placeholder = '-',
className = '',
multiple = false,
searchable,
emptyMessage = 'Ничего не найдено',
searchPlaceholder = 'Поиск...',
autoSort = true,
placement = 'auto',
}) => {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [highlightIndex, setHighlightIndex] = useState(-1);
const triggerRef = useRef(null);
const popupRef = useRef(null);
const searchInputRef = useRef(null);
const values = multiple && Array.isArray(value) ? value : [];
const sortedOptions = useMemo(
() => (autoSort ? sortOptions(options) : options),
[options, autoSort],
);
const selectedSingle = multiple
? null
: sortedOptions.find(o => String(o.value) === String(value));
const shouldSearch = searchable === undefined
? sortedOptions.length > 10
: searchable;
const filtered = useMemo(() => {
if (!search.trim()) return sortedOptions;
const q = search.toLowerCase();
return sortedOptions.filter(o => o.label.toLowerCase().includes(q));
}, [sortedOptions, search]);
const selectedSet = useMemo(
() => new Set(values.map(String)),
[values],
);
const openPopup = () => {
if (disabled) return;
setSearch('');
setOpen(true);
};
const close = () => {
setOpen(false);
setSearch('');
setHighlightIndex(-1);
};
const toggle = () => (open ? close() : openPopup());
useEffect(() => {
if (!open) return;
if (filtered.length === 0) {
setHighlightIndex(-1);
return;
}
if (search.trim()) {
setHighlightIndex(0);
return;
}
if (!multiple && value !== null && value !== undefined && value !== '') {
const idx = filtered.findIndex(o => String(o.value) === String(value));
setHighlightIndex(idx >= 0 ? idx : 0);
return;
}
setHighlightIndex(0);
}, [open, search]);
useEffect(() => {
if (open && popupRef.current && !shouldSearch) popupRef.current.focus();
}, [open, shouldSearch]);
useEffect(() => {
if (open && shouldSearch && searchInputRef.current) searchInputRef.current.focus();
}, [open, shouldSearch]);
/**
* Позиционирование попапа. useLayoutEffect - до первого пейнта,
* чтобы не было «прыжка» из дефолтной позиции в вычисленную.
*
* Границы. Если триггер лежит в .modal-body, вертикальные границы
* считаем по нему: попап не должен вылезать за прокручиваемую
* область модалки. Иначе - по viewport. Горизонталь всегда по
* viewport: попап позиционируется fixed и должен влезать в окно.
*
* Открытие вверх.
* placement='top' — всегда вверх.
* placement='bottom' — всегда вниз.
* placement='auto' — вверх только если попап не влезает снизу
* и сверху места больше, чем снизу.
*
* Resize-listener: попап позиционируется fixed-координатами, и
* при ресайзе окна они остаются «прибитыми» к старым значениям.
* Пересчитываем на месте, чтобы попап не уезжал от триггера.
*/
useLayoutEffect(() => {
if (!open || !popupRef.current || !triggerRef.current) return;
const position = () => {
const popup = popupRef.current;
const trigger = triggerRef.current;
if (!popup || !trigger) return;
const triggerRect = trigger.getBoundingClientRect();
const popupWidth = popup.offsetWidth;
const popupHeight = popup.offsetHeight;
const modalBody = trigger.closest('.modal-body');
const containerRect = modalBody
? modalBody.getBoundingClientRect()
: { top: 0, bottom: window.innerHeight };
const vw = window.innerWidth;
const EDGE = 8;
const spaceBelow = Math.max(0, containerRect.bottom - triggerRect.bottom);
const spaceAbove = Math.max(0, triggerRect.top - containerRect.top);
let openUp;
if (placement === 'top') openUp = true;
else if (placement === 'bottom') openUp = false;
else openUp = popupHeight > spaceBelow && spaceAbove > spaceBelow;
let left = triggerRect.left;
if (left + popupWidth > vw - EDGE) {
left = Math.max(EDGE, vw - popupWidth - EDGE);
}
popup.style.setProperty('--select-popup-left', `${left}px`);
popup.style.setProperty('--select-popup-min-width', `${triggerRect.width}px`);
if (openUp) {
popup.style.setProperty('--select-popup-top', 'auto');
popup.style.setProperty(
'--select-popup-bottom',
`${window.innerHeight - triggerRect.top + 4}px`,
);
} else {
popup.style.setProperty('--select-popup-top', `${triggerRect.bottom + 4}px`);
popup.style.setProperty('--select-popup-bottom', 'auto');
}
};
position();
window.addEventListener('resize', position);
return () => window.removeEventListener('resize', position);
}, [open, filtered.length, placement]);
useEffect(() => {
if (!open) return;
const onDown = (e) => {
if (popupRef.current?.contains(e.target)) return;
if (triggerRef.current?.contains(e.target)) return;
close();
};
const onKey = (e) => {
if (e.key === 'Escape') {
e.stopPropagation();
close();
}
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey, true);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey, true);
};
}, [open]);
const handleSelect = (opt) => {
if (multiple) {
const next = new Set(selectedSet);
const key = String(opt.value);
if (next.has(key)) next.delete(key);
else next.add(key);
onChange(
sortedOptions
.map(o => o.value)
.filter(v => next.has(String(v))),
);
} else {
onChange(opt.value);
close();
}
};
const handlePopupKeyDown = (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightIndex(i => Math.min(filtered.length - 1, i + 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightIndex(i => Math.max(0, i - 1));
} else if (e.key === 'Home') {
e.preventDefault();
setHighlightIndex(0);
} else if (e.key === 'End') {
e.preventDefault();
setHighlightIndex(filtered.length - 1);
} else if (e.key === 'Enter') {
e.preventDefault();
if (highlightIndex >= 0 && filtered[highlightIndex]) {
handleSelect(filtered[highlightIndex]);
}
}
};
const handleTriggerKeyDown = (e) => {
if (disabled) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
openPopup();
}
};
const renderTriggerContent = () => {
if (multiple) {
if (values.length === 0) {
return <span className="custom-select-single-label text-mut">{placeholder}</span>;
}
const selected = sortedOptions.filter(o => selectedSet.has(String(o.value)));
return (
<span className="custom-select-chips">
{selected.map(o => (
<span
key={String(o.value)}
className="sel-chip"
title={o.label}
>
{o.label}
</span>
))}
</span>
);
}
return (
<span className={`custom-select-single-label${selectedSingle ? '' : ' text-mut'}`}>
{selectedSingle ? selectedSingle.label : placeholder}
</span>
);
};
return (
<>
<button
type="button"
id={id}
ref={triggerRef}
className={`inp sel custom-select-trigger ${className}`}
onClick={toggle}
onKeyDown={handleTriggerKeyDown}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
>
<span className="custom-select-trigger-content">
{renderTriggerContent()}
</span>
<span className="custom-select-arrow">▾</span>
</button>
{open && createPortal(
<div
ref={popupRef}
className="custom-select-popup"
role="listbox"
aria-multiselectable={multiple || undefined}
tabIndex={-1}
onKeyDown={handlePopupKeyDown}
>
{shouldSearch && (
<div className="custom-select-search">
<input
ref={searchInputRef}
className="inp inp-sm"
placeholder={searchPlaceholder}
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
)}
<div className="custom-select-list">
{filtered.map((o, idx) => {
const isSelected = multiple
? selectedSet.has(String(o.value))
: String(o.value) === String(value);
return (
<div
key={String(o.value)}
className={
'custom-select-option'
+ (isSelected ? ' on' : '')
+ (idx === highlightIndex ? ' hl' : '')
}
role="option"
aria-selected={isSelected}
onClick={() => handleSelect(o)}
onMouseEnter={() => setHighlightIndex(idx)}
>
{multiple && (
<span className={`custom-select-check${isSelected ? ' on' : ''}`}>
{isSelected ? '✓' : ''}
</span>
)}
<span className="custom-select-option-label">{o.label}</span>
</div>
);
})}
{filtered.length === 0 && (
<div className="custom-select-empty">{emptyMessage}</div>
)}
</div>
</div>,
document.body,
)}
</>
);
};
export default Select;
+77
View File
@@ -0,0 +1,77 @@
// src/components/SortControl.jsx
import { Select } from './Select';
/**
* Иконка направления сортировки.
*
* Три горизонтальные линии разной длины - «лестница». asc: короткая
* сверху, длинная снизу. desc: длинная сверху, короткая снизу.
* Концы линий скруглены (strokeLinecap="round"), выровнены по левому
* краю - так и «рост», и «падение» читаются мгновенно, без подписи.
*
* Свой SVG прямо здесь, а не в Ic/ICONS: это узкоспециальный значок,
* нужен ровно в одном месте, и добавление его в общий набор иконок
* засорило бы словарь без пользы.
*/
function SortDirIcon({ kind }) {
const widths = kind === 'asc' ? [5, 9, 13] : [13, 9, 5];
return (
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true" focusable="false">
<g stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="1" y1="3" x2={1 + widths[0]} y2="3" />
<line x1="1" y1="7" x2={1 + widths[1]} y2="7" />
<line x1="1" y1="11" x2={1 + widths[2]} y2="11" />
</g>
</svg>
);
}
/**
* Контрол сортировки: селект поля и одна кнопка направления.
*
* Кнопка одна, но не toggle-стрелка - на ней значок «лестница»,
* который сам показывает текущее направление:
* - asc - короткая полоска сверху, длинная снизу (растёт вниз);
* - desc - длинная сверху, короткая снизу (убывает вниз).
*
* Направление не «угадать по стрелке», а сразу видно по форме значка:
* пользователь не должен вспоминать, что «сейчас было». Клик по
* кнопке меняет направление на противоположное; отдельной иконки
* «активная/неактивная» нет - она и есть индикатор.
*
* compact - тот же размер селекта, что у фильтров в этой вьюхе.
*
* autoSort={false} у Select: список опций уже в осмысленном порядке,
* и пересортировка по алфавиту его бы перемешала.
*/
export function SortControl({
options,
field,
dir,
onFieldChange,
onToggleDir,
compact = false,
className = '',
}) {
return (
<div className={`sort-control${className ? ' ' + className : ''}`}>
<Select
className={compact ? 'sort-select sm' : 'sort-select'}
value={field}
onChange={onFieldChange}
options={options}
autoSort={false}
/>
<button
type="button"
className="sort-dir-btn"
onClick={onToggleDir}
title={dir === 'asc' ? 'По возрастанию' : 'По убыванию'}
aria-label="Переключить направление сортировки"
>
<SortDirIcon kind={dir} />
</button>
</div>
);
}
+631
View File
@@ -0,0 +1,631 @@
// src/components/Staff.jsx
import React, { useState, useMemo, useCallback } from "react";
import { ROLES, VACATION_TYPES, DIALOGS, TOASTS } from "../utils/constants";
import { TODAY, fmtDMY } from "../utils/date";
import {
canEditDepartments,
canEditRoles,
canManageAllVacations,
canManageVacation,
canFireEmployee,
} from "../utils/permissions";
import { Ic, ICONS } from "./Icons";
import { useDataHelpers, useFilters } from "../hooks";
import { useToast } from "../context/ToastContext";
import { useConfirm } from "../context/ConfirmContext";
import { EditEmployeeModal } from "./Modals/EditEmployeeModal";
import { CreateEmployeeModal } from "./Modals/CreateEmployeeModal";
import { EmployeeCardModal } from "./Modals/EmployeeCardModal";
import Avatar from "./Avatar";
import FloatingMenu from "./FloatingMenu";
import { SearchBox } from "./SearchBox";
import { Select } from "./Select";
import { getPrimaryDeptName, getPositionInDept } from "../utils/helpers";
import { optionsFromMap } from "../utils/selectOptions";
import { isPartTime, effectiveEmployeeRate } from "../utils/employeeRate";
const INITIAL_FILTERS = Object.freeze({
query: '',
kbId: 'all',
deptId: 'all',
role: 'all',
});
const ROLE_SELECT_OPTIONS = optionsFromMap(ROLES, 'Все роли');
const matchesQuery = (emp, q, departmentsById) => {
if (!q) return true;
const needle = q.toLowerCase();
if (`${emp.last} ${emp.first}`.toLowerCase().includes(needle)) return true;
if ((emp.position || '').toLowerCase().includes(needle)) return true;
const deptText = (emp.departments || [])
.map(d => {
const name = departmentsById.get(d.deptId)?.name || '';
const pos = d.position || '';
return `${name} ${pos}`;
})
.join(' ')
.toLowerCase();
return deptText.includes(needle);
};
const matchesFilters = (emp, filters, departmentsById) => {
const { query, kbId, deptId, role } = filters;
const empDepts = emp.departments || [];
if (role !== 'all' && !(emp.roles || []).includes(role)) return false;
if (deptId === 'any') {
if (empDepts.length === 0) return false;
} else if (deptId === 'none') {
if (empDepts.length > 0) return false;
} else if (deptId !== 'all') {
if (!empDepts.some(d => d.deptId === deptId)) return false;
}
if (kbId !== 'all') {
const inAnyKb = empDepts.some(d => departmentsById.get(d.deptId)?.kbId);
const isAnyKbChief = (emp.kbIds || []).length > 0;
if (kbId === 'any') {
if (!inAnyKb && !isAnyKbChief) return false;
} else if (kbId === 'none') {
if (inAnyKb || isAnyKbChief) return false;
} else {
const inKbDept = empDepts.some(d => departmentsById.get(d.deptId)?.kbId === kbId);
const isKbChief = (emp.kbIds || []).includes(kbId);
if (!inKbDept && !isKbChief) return false;
}
}
return matchesQuery(emp, query, departmentsById);
};
/**
* Строка сотрудника в списке «Персонал».
*
* Флаг `fired` берём из самого `employee` — отдельный проп не нужен,
* он дублировал бы поле сущности.
*/
const EmployeeRow = React.memo(({
employee,
ur,
deptId,
vacationEnd,
openDepts,
openRoles,
store,
canFire,
confirm,
showToast,
openEditEmployee,
departments,
kbs,
employees,
}) => {
const [cardOpen, setCardOpen] = useState(false);
const isFired = employee.fired;
const modalDb = useMemo(
() => ({ departments, kbs, employees }),
[departments, kbs, employees],
);
const position = useMemo(
() => getPositionInDept(employee, deptId),
[employee, deptId],
);
// Бейдж «N ст.» у ФИО. Показываем только когда эффективная ставка ≠ 1;
// при полной ставке (или отсутствии departments) молчим — не создаём
// визуальный шум у 99% строк.
const showPartTime = isPartTime(employee);
const effRate = showPartTime ? effectiveEmployeeRate(employee) : null;
const handleRowClick = useCallback(() => setCardOpen(true), []);
const handleCardClose = useCallback(() => setCardOpen(false), []);
const handleFireToggle = useCallback(async () => {
const name = `${employee.last} ${employee.first}`;
const dlg = employee.fired
? DIALOGS.restoreEmployee(name)
: DIALOGS.fireEmployee(name);
if (!(await confirm(dlg))) return;
try {
store.setEmployeeFired(employee.id, !employee.fired);
} catch (err) {
showToast(err.message || 'Не удалось изменить статус сотрудника', 'error');
}
}, [employee, store, confirm, showToast]);
const buildEmployeeMenu = () => {
const items = [];
if (canEditDepartments(ur)) {
items.push({
id: 'edit',
label: 'Редактировать',
icon: ICONS.edit,
onClick: () => openEditEmployee(employee.id),
});
if (!isFired) {
items.push({
id: 'depts',
label: 'Подразделения',
icon: ICONS.users,
onClick: () => openDepts(employee.id),
});
}
}
if (canEditRoles(ur) && !isFired) {
items.push({
id: 'roles',
label: 'Роли',
icon: ICONS.shield,
onClick: () => openRoles(employee.id),
});
}
if (canFire) {
if (items.length) items.push({ type: 'divider' });
items.push({
id: 'fire',
label: isFired ? 'Восстановить' : 'Уволить',
icon: isFired ? ICONS.restore : ICONS.x,
danger: !isFired,
onClick: handleFireToggle,
});
}
return items;
};
const menuItems = buildEmployeeMenu();
return (
<>
<div className="st-row cursor-pointer" onClick={handleRowClick}>
<Avatar employee={employee} size="sm" />
<div className="st-name">
<div className="st-fio">
{employee.last} {employee.first}
{showPartTime && (
<span className="pos-tag" title="Эффективная ставка">
{effRate} ст.
</span>
)}
{isFired && <span className="vac-badge fired">Уволен</span>}
{vacationEnd && <span className="vac-badge">в отпуске до {fmtDMY(vacationEnd)}</span>}
</div>
<div className="st-pos">
<span>{position.position}</span>
{position.isExtra && <span className="pos-tag">совм</span>}
</div>
</div>
<div className="st-roles">
{employee.roles.map(r => (
<span
key={r}
className="role-chip"
title={ROLES[r].label}
style={{ background: ROLES[r].color + '1e', color: ROLES[r].color }}
>
{ROLES[r].short}
</span>
))}
</div>
{menuItems.length > 0 && (
<FloatingMenu items={menuItems}>
{({ buttonProps }) => (
<button
{...buttonProps}
className="icon-btn"
title="Действия"
aria-label={`Действия с ${employee.last} ${employee.first}`}
>
<Ic d={ICONS.more} size={16} />
</button>
)}
</FloatingMenu>
)}
</div>
{cardOpen && (
<EmployeeCardModal
employee={employee}
db={modalDb}
onClose={handleCardClose}
/>
)}
</>
);
});
export default function Staff({ store, db, setDb, ur, openRoles, openDepts, openVacation }) {
const { empName } = useDataHelpers(db);
const { showToast } = useToast();
const { prompt, confirm } = useConfirm();
const [showFired, setShowFired] = useState(false);
const [editEmployeeId, setEditEmployeeId] = useState(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const { filters, setFilter } = useFilters(INITIAL_FILTERS);
const { query, kbId, deptId, role } = filters;
const departmentsById = useMemo(
() => new Map(db.departments.map(d => [d.id, d])),
[db.departments]
);
const kbSelectOptions = useMemo(() => [
{ value: 'all', label: 'Все сотрудники' },
{ value: 'any', label: 'Все КБ' },
...db.kbs.map(k => ({ value: k.id, label: k.name })),
{ value: 'none', label: 'Вне КБ' },
], [db.kbs]);
const deptSelectOptions = useMemo(() => [
{ value: 'all', label: 'Все сотрудники' },
{ value: 'any', label: 'Все отделы' },
...db.departments.map(d => ({ value: d.id, label: d.name })),
{ value: 'none', label: 'Вне отделов' },
], [db.departments]);
const vacationEndByEmp = useMemo(() => {
const map = new Map();
for (const v of db.vacations) {
if (v.status !== 'approved') continue;
if (!(v.start <= TODAY && TODAY <= v.end)) continue;
// При нескольких активных отпусках показываем самую позднюю дату
// окончания — до неё сотрудник точно отсутствует.
const prev = map.get(v.empId);
if (!prev || v.end > prev) map.set(v.empId, v.end);
}
return map;
}, [db.vacations]);
const filteredActiveEmployees = useMemo(
() => db.employees.filter(e => !e.fired && matchesFilters(e, filters, departmentsById)),
[db.employees, filters, departmentsById]
);
const filteredFiredEmployees = useMemo(
() => db.employees.filter(e => e.fired && matchesFilters(e, filters, departmentsById)),
[db.employees, filters, departmentsById]
);
const noDeptEmployees = useMemo(() => {
return filteredActiveEmployees
.filter(e => e.departments.length === 0 && !e.roles.includes('kb_chief'))
.sort((a, b) => {
if (a.roles.includes('director') && !b.roles.includes('director')) return -1;
if (!a.roles.includes('director') && b.roles.includes('director')) return 1;
return a.last.localeCompare(b.last);
});
}, [filteredActiveEmployees]);
const deptMap = useMemo(() => {
const map = new Map();
db.departments.forEach(d => {
const members = filteredActiveEmployees.filter(e =>
e.departments.some(x => x.deptId === d.id)
);
if (members.length) map.set(d.id, members);
});
return map;
}, [db.departments, filteredActiveEmployees]);
const kbSections = useMemo(() => {
return db.kbs.map(k => {
const allDeptsInKb = db.departments.filter(d => d.kbId === k.id);
const deptsInKb = deptId === 'all'
? allDeptsInKb
: deptId === 'none'
? []
: allDeptsInKb.filter(d => d.id === deptId);
const members = filteredActiveEmployees.filter(e =>
e.departments.some(d => allDeptsInKb.some(x => x.id === d.deptId))
);
const chiefs = filteredActiveEmployees.filter(e =>
e.roles.includes('kb_chief') && (e.kbIds || []).includes(k.id)
);
if (members.length === 0 && chiefs.length === 0) return null;
return { kb: k, deptsInKb, members, chiefs };
}).filter(Boolean);
}, [db.kbs, db.departments, filteredActiveEmployees, deptId]);
const deptsWithoutKb = useMemo(() => {
return db.departments.filter(d => {
if (d.kbId !== null) return false;
if ((deptMap.get(d.id) || []).length === 0) return false;
if (deptId === 'all') return true;
return d.id === deptId;
});
}, [db.departments, deptMap, deptId]);
const allVacs = useMemo(() => {
return db.vacations
.filter(v => {
const emp = db.employees.find(e => e.id === v.empId);
return emp && !emp.fired && matchesFilters(emp, filters, departmentsById);
})
.sort((a, b) => (a.start < b.start ? 1 : -1));
}, [db.vacations, db.employees, filters, departmentsById]);
const canFire = canFireEmployee(ur);
const openEditEmployee = useCallback((id) => setEditEmployeeId(id), []);
const closeEditEmployee = useCallback(() => setEditEmployeeId(null), []);
const handleCreateKb = useCallback(async () => {
const name = await prompt(DIALOGS.createKb);
if (!name) return;
setDb((s) => ({
...s,
kbs: [...s.kbs, { id: 'kb_' + Math.random().toString(36).slice(2, 6), name, full: name }],
}));
showToast(TOASTS.kbCreated(name), 'success');
}, [prompt, setDb, showToast]);
const handleCreateDept = useCallback(async () => {
const name = await prompt(DIALOGS.createDept);
if (!name) return;
setDb((s) => ({
...s,
departments: [...s.departments, { id: 'd_' + Math.random().toString(36).slice(2, 6), name, kbId: null }],
}));
showToast(TOASTS.deptCreated(name), 'success');
}, [prompt, setDb, showToast]);
const renderEmployeeRow = (e, deptId) => (
<EmployeeRow
key={e.id}
employee={e}
ur={ur}
deptId={deptId}
vacationEnd={vacationEndByEmp.get(e.id) || null}
openDepts={openDepts}
openRoles={openRoles}
store={store}
canFire={canFire}
confirm={confirm}
showToast={showToast}
openEditEmployee={openEditEmployee}
departments={db.departments}
kbs={db.kbs}
employees={db.employees}
/>
);
const renderDepartment = (deptId) => {
const members = deptMap.get(deptId) || [];
if (!members.length) return null;
const dept = departmentsById.get(deptId);
if (!dept) return null;
const headNames = db.employees
.filter(e => (e.headDeptIds || []).includes(deptId) && !e.fired)
.map(e => `${e.last} ${e.first[0]}.`)
.join(', ');
return (
<div className="st-dept" key={deptId}>
<div className="st-dept-head">
<span className="st-dept-name">{dept.name}</span>
<span className="mut">руководитель: {headNames || '-'}</span>
<span className="kcount">{members.length}</span>
</div>
{members.map(e => renderEmployeeRow(e, deptId))}
</div>
);
};
const handleVacationDelete = useCallback(async (vac) => {
if (!await confirm({
title: 'Удалить отпуск',
message: `Удалить отпуск ${fmtDMY(vac.start)}–${fmtDMY(vac.end)}?`,
confirmLabel: 'Удалить',
danger: true,
})) return;
try {
store.deleteVacation(vac.id);
} catch (err) {
showToast(err.message || 'Не удалось удалить отпуск', 'error');
}
}, [store, showToast, confirm]);
return (
<div className="staff">
<div className="toolbar">
<SearchBox
value={query}
onChange={(v) => setFilter('query', v)}
placeholder="Поиск по ФИО, должности, отделу…"
className="staff-search"
/>
<Select
className="filter-select staff-select"
value={kbId}
onChange={(v) => setFilter('kbId', v)}
options={kbSelectOptions}
/>
<Select
className="filter-select staff-select"
value={deptId}
onChange={(v) => setFilter('deptId', v)}
options={deptSelectOptions}
/>
<Select
className="filter-select"
value={role}
onChange={(v) => setFilter('role', v)}
options={ROLE_SELECT_OPTIONS}
/>
{canEditRoles(ur) && (
<div className="ml-auto flex gap-2">
<button className="btn ghost sm" onClick={handleCreateKb}>
<Ic d={ICONS.plus} size={13} /> КБ
</button>
<button className="btn ghost sm" onClick={handleCreateDept}>
<Ic d={ICONS.plus} size={13} /> Отдел
</button>
{canEditDepartments(ur) && (
<button className="btn primary sm" onClick={() => setShowCreateModal(true)}>
<Ic d={ICONS.plus} size={13} /> Добавить сотрудника
</button>
)}
</div>
)}
</div>
{showCreateModal && (
<CreateEmployeeModal
store={store}
ur={ur}
onClose={() => setShowCreateModal(false)}
toast={(msg, type) => showToast(msg, type || 'error')}
/>
)}
{editEmployeeId && (
<EditEmployeeModal
store={store}
ur={ur}
employeeId={editEmployeeId}
onClose={closeEditEmployee}
toast={(msg, type) => showToast(msg, type || 'error')}
/>
)}
{(query || kbId !== 'all' || deptId !== 'all' || role !== 'all')
&& filteredActiveEmployees.length === 0
&& filteredFiredEmployees.length === 0 && (
<div className="empty-note p-4">Сотрудников по заданным условиям не найдено</div>
)}
{noDeptEmployees.length > 0 && (
<div className="st-section">
<div className="st-sec-head">
<div className="st-sec-title">Руководство</div>
</div>
{noDeptEmployees.map(e => renderEmployeeRow(e, null))}
</div>
)}
{kbSections.map(({ kb, deptsInKb, chiefs }) => (
<div className="st-section" key={kb.id}>
<div className="st-sec-head">
<div className="st-sec-title">{kb.name}</div>
</div>
{chiefs.length > 0 && (
<div className="st-dept">
{chiefs.map(e => renderEmployeeRow(e, null))}
</div>
)}
{deptsInKb.map(d => renderDepartment(d.id))}
</div>
))}
{deptsWithoutKb.length > 0 && (
<div className="st-section">
<div className="st-sec-head">
<div className="st-sec-title">Отделы и сотрудники вне КБ</div>
<div className="st-sec-sub">Подразделения прямого подчинения</div>
</div>
{deptsWithoutKb.map(d => renderDepartment(d.id))}
</div>
)}
{canManageAllVacations(ur) && allVacs.length > 0 && (
<div className="st-section">
<div className="st-sec-head">
<div className="st-sec-title">Все отпуска ({allVacs.length})</div>
<button className="btn primary sm" onClick={() => openVacation(null, null)}>
<Ic d={ICONS.plus} size={13} /> Отпуск сотруднику
</button>
</div>
<div className="p-3">
<table className="tbl">
<thead>
<tr>
<th>Сотрудник</th>
<th>Отдел</th>
<th>Период</th>
<th>Тип</th>
<th>Статус</th>
<th></th>
</tr>
</thead>
<tbody>
{allVacs.map(v => {
const e = db.employees.find(x => x.id === v.empId);
const canManage = canManageVacation(ur, v);
return (
<tr key={v.id}>
<td><b>{empName(v.empId)}</b></td>
<td>{getPrimaryDeptName(e, db)}</td>
<td>{fmtDMY(v.start)} - {fmtDMY(v.end)}</td>
<td>{VACATION_TYPES[v.type]}</td>
<td><span className={`st-chip ${v.status}`}>
{{ pending: 'На утверждении', approved: 'Утверждён', rejected: 'Отклонён' }[v.status]}
</span></td>
<td>
{canManage ? (
<>
<button
className="icon-btn"
onClick={() => openVacation(v.id, null)}
title="Редактировать"
>
<Ic d={ICONS.edit} size={14} />
</button>
<button
className="icon-btn danger"
onClick={() => handleVacationDelete(v)}
title="Удалить"
>
<Ic d={ICONS.trash} size={14} />
</button>
</>
) : (
<button
className="icon-btn"
onClick={() => openVacation(v.id, null)}
title="Просмотр"
>
<Ic d={ICONS.eye} size={14} />
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{filteredFiredEmployees.length > 0 && canFire && (
<div className="st-section">
<div className="st-sec-head">
<div className="st-sec-title">Архив сотрудников (уволенные)</div>
<button className="btn ghost sm" onClick={() => setShowFired(!showFired)}>
{showFired ? 'Скрыть' : 'Показать'}
</button>
</div>
{showFired && (
<div className="st-empty-box">
{filteredFiredEmployees.map(e => renderEmployeeRow(e, null))}
</div>
)}
</div>
)}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
// src/components/SubstitutionBadge.jsx
import { getSubstitutionInfo } from '../utils/substitution';
import { fmtDMY } from '../utils/date';
import { substitutionKindLabel } from '../utils/constants';
/**
* Индикатор активных замещений в карточке пользователя.
*
* Показывает одну строку на каждое активное замещение. Именно «замещает
* X до даты» и повод в подсказке: пользователь должен видеть, что его
* текущие расширенные права — временные.
*
* Не показывает список прав (роли / отделы / КБ): это шум в сайдбаре.
* Права читаются через hasRole и реализуются сами собой, а детали
* всегда доступны в «Запросы и заявки».
*/
export function SubstitutionBadge({ user, empName }) {
const subs = getSubstitutionInfo(user);
if (subs.length === 0) return null;
return (
<div className="user-substituting">
{subs.map((s) => (
<div key={s.id} title={s.reasonText || substitutionKindLabel(s.kind)}>
замещает {empName(s.fromEmpId)} до {fmtDMY(s.end)}
</div>
))}
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
// src/components/Tabs.jsx
export const Tabs = ({ tabs, active, onChange, className = '' }) => (
<div className={`tabs sm ${className}`}>
{tabs.map(({ id, label, count, badge }) => (
<button
key={id}
className={`tab${active === id ? ' on' : ''}`}
onClick={() => onChange(id)}
>
{label}{count !== undefined && ` (${count})`}
{badge > 0 && <span className="tab-badge">{badge}</span>}
</button>
))}
</div>
);
+134
View File
@@ -0,0 +1,134 @@
import { useState, useMemo } from 'react';
import { TASK_STATUSES } from '../utils/constants';
import { fmtDMY } from '../utils/date';
/**
* Таблица задач с сортировкой по столбцам.
*
* Раньше `.find` по проектам стоял прямо в компараторе sort:
* `db.projects.find(p => p.id === a.projectId)` для каждой пары
* сравниваемых элементов. Это O(N log N · M). Сейчас ключи сортировки
* для всех задач вычисляются один раз перед сортировкой - O(N), а
* компаратор только сравнивает готовые значения.
*/
export const TaskTable = ({
tasks,
onRowClick,
columns = ['title', 'assignee', 'status', 'planned', 'fact', 'deadline'],
db,
getTaskSpent,
empName,
showProject = false,
}) => {
const [sortField, setSortField] = useState('title');
const [sortDir, setSortDir] = useState('asc');
// Стабильная по ссылке карта проектов; сравнение в deps useMemo по
// ссылке на массив db.projects.
const projectsById = useMemo(
() => new Map((db?.projects || []).map(p => [p.id, p])),
[db?.projects],
);
const sortedTasks = useMemo(() => {
const keyed = tasks.map(t => {
let key;
switch (sortField) {
case 'title': key = (t.title || '').toLowerCase(); break;
case 'assignee': key = (t.assigneeId ? empName(t.assigneeId) : '').toLowerCase(); break;
case 'status': key = TASK_STATUSES[t.status]?.label || t.status || ''; break;
case 'planned': key = t.plannedHours ?? -1; break;
case 'fact': key = getTaskSpent(t); break;
case 'deadline': key = t.deadline || ''; break;
case 'project': key = projectsById.get(t.projectId)?.code || ''; break;
default: key = '';
}
return { task: t, key };
});
keyed.sort((a, b) => {
if (a.task._draft !== b.task._draft) return a.task._draft ? -1 : 1;
if (a.key < b.key) return sortDir === 'asc' ? -1 : 1;
if (a.key > b.key) return sortDir === 'asc' ? 1 : -1;
return 0;
});
return keyed.map(k => k.task);
}, [tasks, sortField, sortDir, projectsById, empName, getTaskSpent]);
const handleSort = (field) => {
if (sortField === field) setSortDir(prev => prev === 'asc' ? 'desc' : 'asc');
else { setSortField(field); setSortDir('asc'); }
};
const sortableHeader = (field, label) => (
<th className="sortable" onClick={() => handleSort(field)}>
{label} {sortField === field && (sortDir === 'asc' ? '↑' : '↓')}
</th>
);
return (
<div className="tasks-table-wrap">
<table className="tbl tasks-table">
<thead>
<tr>
{columns.includes('title') && sortableHeader('title', 'Задача')}
{columns.includes('assignee') && sortableHeader('assignee', 'Исполнитель')}
{columns.includes('status') && sortableHeader('status', 'Статус')}
{columns.includes('planned') && sortableHeader('planned', 'План (ч)')}
{columns.includes('fact') && sortableHeader('fact', 'Факт (ч)')}
{columns.includes('deadline') && sortableHeader('deadline', 'Дедлайн')}
{showProject && columns.includes('project') && sortableHeader('project', 'Проект')}
</tr>
</thead>
<tbody>
{sortedTasks.length === 0 ? (
<tr><td colSpan={columns.length} className="mut text-center">Нет задач</td></tr>
) : (
sortedTasks.map(task => {
const isDraft = task._draft === true;
return (
<tr
key={task.id}
className={isDraft ? 'draft-row' : 'clickable-row'}
onClick={isDraft ? undefined : () => onRowClick(task.id)}
>
{columns.includes('title') && (
<td>
<b>{task.title}</b>
{isDraft && <span className="task-draft-badge">черновик</span>}
</td>
)}
{columns.includes('assignee') && (
<td>{task.assigneeId ? empName(task.assigneeId) : '-'}</td>
)}
{columns.includes('status') && (
<td>
<span
className="st-chip"
style={{
background: (TASK_STATUSES[task.status]?.color || '#64748b') + '22',
color: TASK_STATUSES[task.status]?.color || '#64748b',
}}
>
{TASK_STATUSES[task.status]?.label || task.status}
</span>
</td>
)}
{columns.includes('planned') && <td>{task.plannedHours ?? '-'}</td>}
{columns.includes('fact') && <td>{getTaskSpent(task)}</td>}
{columns.includes('deadline') && (
<td>{task.deadline ? fmtDMY(task.deadline) : '-'}</td>
)}
{showProject && columns.includes('project') && (
<td>{projectsById.get(task.projectId)?.code || '-'}</td>
)}
</tr>
);
})
)}
</tbody>
</table>
</div>
);
};
@@ -0,0 +1,26 @@
import { DRAFT_STATUS } from '../../utils/constants';
import { countNestedTasks } from '../../utils/templateNesting';
/**
* Строка списка черновиков. Одна нода = одна кнопка - значит, доступна
* с клавиатуры и озвучивается скринридером без дополнительных
* обработчиков. Вся информация о ноде уже в payload, компонент её только
* отображает.
*/
export default function DraftListItem({ node, onClick }) {
const childCount = Array.isArray(node.subtasks)
? countNestedTasks(node.subtasks)
: 0;
return (
<button type="button" className="template-draft-item" onClick={onClick}>
<span className="template-draft-title">{node.title || 'Без названия'}</span>
<span className="template-draft-meta">
{childCount > 0 && (
<span className="template-draft-children">+{childCount}</span>
)}
<span className="template-draft-status">{DRAFT_STATUS.label}</span>
</span>
</button>
);
}
+136
View File
@@ -0,0 +1,136 @@
import { useCallback, useMemo, useState } from 'react';
import { ModalShell } from '../ModalShell';
import { FormField } from '../FormField';
import { DRAFT_STATUS } from '../../utils/constants';
import { countNestedTasks } from '../../utils/templateNesting';
import { useNestedModalEscape } from './useNestedModalEscape';
import TaskNodeFields from './TaskNodeFields';
import DraftListItem from './DraftListItem';
const createEmptyNode = (singular) => ({
title: `Новая ${singular}`,
priority: 'mid',
plannedHours: 8,
});
/**
* Модалка-редактор одного черновика дерева.
*
* Показывает только поля текущего узла и список прямых подзадач. Клик по
* подзадаче открывает такую же модалку для неё - это устраняет визуальное
* дублирование полей и кнопок, которое возникало при inline-рендере всего
* поддерева. Один уровень = одна модалка.
*
* Всё редактирование полей живёт в TaskNodeFields. Сама модалка знает
* только структуру node.subtasks и делегирует изменение узлов наверх через
* onChange - как и раньше.
*/
export default function TaskDraftModal({
node,
readOnly = false,
childSingular = 'подзадача',
title,
onChange,
onDelete,
onClose,
}) {
useNestedModalEscape(onClose);
const [editingChildIndex, setEditingChildIndex] = useState(null);
const children = useMemo(
() => (Array.isArray(node.subtasks) ? node.subtasks : []),
[node.subtasks]
);
const setChildren = useCallback((next) => {
const copy = { ...node };
if (next.length) copy.subtasks = next;
else delete copy.subtasks;
onChange(copy);
}, [node, onChange]);
const updateChild = useCallback((idx, child) => {
setChildren(children.map((c, i) => (i === idx ? child : c)));
}, [children, setChildren]);
const deleteChild = useCallback((idx) => {
setChildren(children.filter((_, i) => i !== idx));
setEditingChildIndex(null);
}, [children, setChildren]);
const addChild = useCallback(() => {
setChildren([...children, createEmptyNode(childSingular)]);
}, [children, setChildren, childSingular]);
const childCount = countNestedTasks(children);
const footer = (
<div className="modal-foot">
{!readOnly && onDelete && (
<button type="button" className="btn danger" onClick={onDelete}>
Удалить черновик
</button>
)}
<div className="spacer" />
<button type="button" className="btn ghost" onClick={onClose}>
Назад
</button>
</div>
);
return (
<>
<ModalShell
title={title || `Черновик: ${node?.title || '-'}`}
onClose={onClose}
width={620}
showSave={false}
footer={footer}
>
<div className="project-info-fields">
<FormField
label="Статус"
value={DRAFT_STATUS.label}
disabled
inline
/>
<TaskNodeFields node={node} readOnly={readOnly} onChange={onChange} />
</div>
<div className="template-tree-header mt-3">
<div className="template-tree-title">{childSingular} ({childCount})</div>
{!readOnly && (
<button type="button" className="btn ghost sm" onClick={addChild}>
+ {childSingular}
</button>
)}
</div>
{children.length === 0 ? (
<div className="mut sm">Вложенных элементов нет</div>
) : (
<div className="template-draft-list">
{children.map((child, idx) => (
<DraftListItem
key={idx}
node={child}
onClick={() => setEditingChildIndex(idx)}
/>
))}
</div>
)}
</ModalShell>
{editingChildIndex !== null && children[editingChildIndex] && (
<TaskDraftModal
node={children[editingChildIndex]}
readOnly={readOnly}
childSingular={childSingular}
onChange={(next) => updateChild(editingChildIndex, next)}
onDelete={readOnly ? null : () => deleteChild(editingChildIndex)}
onClose={() => setEditingChildIndex(null)}
/>
)}
</>
);
}
@@ -0,0 +1,56 @@
import { FormField } from '../FormField';
import { TEMPLATE_FIELDS_META } from '../../utils/templateSchemas';
const TASK_META = TEMPLATE_FIELDS_META.task;
/**
* Поля одного узла дерева. Единственное место, где описаны поля задачи
* в шаблоне: используется и TaskNodeEditor, и TaskDraftModal.
* Никаких «+ подзадача» и «Удалить» - это ответственность контейнера.
*/
export default function TaskNodeFields({ node, readOnly = false, onChange }) {
const updateField = (field, value) => onChange({ ...node, [field]: value });
return (
<>
<FormField
label="Название"
required
value={node.title ?? ''}
onChange={(v) => updateField('title', v)}
disabled={readOnly}
inline
/>
<FormField
label="Описание"
type="textarea"
rows={2}
value={node.desc ?? ''}
onChange={(v) => updateField('desc', v)}
disabled={readOnly}
inline
/>
<div className="fields-row">
<FormField
label="Приоритет"
type="select"
options={TASK_META.priority.options}
value={node.priority ?? 'mid'}
onChange={(v) => updateField('priority', v)}
disabled={readOnly}
inline
/>
<FormField
label="Плановые часы"
type="number"
min="0"
step="0.5"
value={node.plannedHours ?? ''}
onChange={(v) => updateField('plannedHours', v)}
disabled={readOnly}
inline
/>
</div>
</>
);
}
@@ -0,0 +1,41 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { Ic, ICONS } from '../Icons';
import TemplateModal from './TemplateModal';
export function TemplateActions({
kind,
source,
nested = [],
toast,
disabled = false,
}) {
const [open, setOpen] = useState(false);
const close = () => setOpen(false);
const openModal = () => setOpen(true);
return (
<>
<button
type="button"
className="btn ghost sm"
onClick={openModal}
disabled={disabled}
>
<Ic d={ICONS.star} size={13} /> В шаблон
</button>
{open && createPortal(
<TemplateModal
mode="create"
kind={kind}
source={source}
nested={nested}
onClose={close}
toast={toast}
/>,
document.body,
)}
</>
);
}
+407
View File
@@ -0,0 +1,407 @@
// src/components/Modals/TemplateModal.jsx
import { useCallback, useMemo, useState } from 'react';
import { ModalShell } from '../ModalShell';
import { Tabs } from '../Tabs';
import { FormField } from '../FormField';
import { useForm } from '../../hooks/useForm';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useStore } from '../../hooks/useStore';
import {
TEMPLATE_KINDS,
TEMPLATE_FIELDS_META,
TEMPLATE_KIND_LABELS,
} from '../../utils/templateSchemas';
import {
priorityOptionsForProjectType,
withSyncedPriority,
} from '../../utils/constants';
import { countNestedTasks } from '../../utils/templateNesting';
import { useNestedModalEscape } from './useNestedModalEscape';
import TaskDraftModal from './TaskDraftModal';
import DraftListItem from './DraftListItem';
const MAX_NAME_LENGTH = 80;
/**
* Поля формы шаблона, участвующие в проверке isDirty. Обязательный
* параметр useForm - без него форма падает на первом рендере. Список
* явный: только два поля формы, поля payload редактируются отдельно
* (setPayloadField) и не должны открывать кнопку «Сохранить» сами по
* себе - иначе достаточно кликнуть в поле «Название», ничего не меняя,
* и Save станет активным.
*/
const FIELDS = Object.freeze(['name', 'isShared']);
const MODES = Object.freeze({
create: 'create',
edit: 'edit',
view: 'view',
});
const createEmptyNestedNode = (singular) => ({
title: `Новая ${singular}`,
priority: 'mid',
plannedHours: 8,
});
export default function TemplateModal({
mode = MODES.create,
kind: propKind,
source,
nested = [],
template = null,
onClose,
toast,
}) {
const { store } = useStore();
useNestedModalEscape(onClose);
const isCreate = mode === MODES.create;
const isEdit = mode === MODES.edit;
const isView = mode === MODES.view;
const readOnly = isView;
const effectiveKind = template?.kind || propKind;
const kindMeta = TEMPLATE_KINDS[effectiveKind];
const fieldsMeta = TEMPLATE_FIELDS_META[effectiveKind] || {};
const nestedKey = kindMeta?.nestedKey;
const nestedLabel = kindMeta?.nestedLabel || 'вложенные элементы';
const nestedSingular = kindMeta?.nestedSingular || 'элемент';
const kindLabel = TEMPLATE_KIND_LABELS[effectiveKind] || 'сущности';
const initialPayload = useMemo(() => {
if (isCreate) {
const base = { ...(source || {}) };
if (nested.length && nestedKey) base[nestedKey] = nested;
return base;
}
return { ...(template?.payload || {}) };
}, []);
const [payload, setPayload] = useState(initialPayload);
const [includeNested, setIncludeNested] = useState(
() => Array.isArray(initialPayload[nestedKey]) && initialPayload[nestedKey].length > 0
);
const [activeTab, setActiveTab] = useState('info');
const [editingDraftIndex, setEditingDraftIndex] = useState(null);
const nestedItems = useMemo(
() => (Array.isArray(payload[nestedKey]) ? payload[nestedKey] : []),
[payload, nestedKey]
);
const initialValues = useMemo(() => ({
name: template?.name || '',
isShared: template?.isShared || false,
}), [template]);
const validate = useCallback((vals) => {
const errors = {};
const name = String(vals.name || '').trim();
if (!name) errors.name = 'Укажите название';
else if (name.length > MAX_NAME_LENGTH) errors.name = `Не более ${MAX_NAME_LENGTH} символов`;
return errors;
}, []);
/**
* Единственная точка записи в payload.
*
* Развилка только одна: смена ptype у шаблона проекта. У производственного
* проекта набор приоритетов AOG / CRIT / NORM, у административного -
* high / mid / low. Если после смены ptype текущий приоритет в новый
* набор не входит, его надо переустановить на первый допустимый. Иначе
* форма молча сохранит несовместимую пару (тип: admin, приоритет: AOG),
* а getProjectColor и правила цвета дальше работают по priority в отрыве
* от ptype и показывают цвет из чужого набора.
*
* Сброс делаем только когда приоритет уже выставлен. Пустое значение
* не трогаем: пользователь мог ещё не дойти до поля, а валидатор сам
* подсветит его как обязательное.
*
* Все остальные поля - просто запись. Единый метод вместо пары
* setPayloadField / setField: два почти одинаковых сеттера рядом
* приглашали к ошибке «выбрал не тот».
*/
const setPayloadField = useCallback((field, value) => {
setPayload((prev) => {
if (field === 'ptype' && effectiveKind === 'project') {
return withSyncedPriority(prev, value);
}
return { ...prev, [field]: value };
});
}, [effectiveKind]);
const setNested = useCallback((next) => {
setPayload((prev) => {
const copy = { ...prev };
if (next.length) copy[nestedKey] = next;
else delete copy[nestedKey];
return copy;
});
}, [nestedKey]);
const updateDraftAt = useCallback((idx, next) => {
setNested(nestedItems.map((n, i) => (i === idx ? next : n)));
}, [nestedItems, setNested]);
const deleteDraftAt = useCallback((idx) => {
setNested(nestedItems.filter((_, i) => i !== idx));
setEditingDraftIndex(null);
}, [nestedItems, setNested]);
const saveAsync = useCallback(async (vals) => {
if (isEdit) {
store.updateTemplate(template.id, {
name: vals.name,
isShared: vals.isShared,
payload,
});
} else if (isCreate) {
const payloadSource = { ...payload };
if (nestedKey && !includeNested) delete payloadSource[nestedKey];
store.saveTemplate({
kind: effectiveKind,
name: vals.name,
isShared: vals.isShared,
source: payloadSource,
});
}
onClose();
}, [isEdit, isCreate, template, payload, includeNested, nestedKey, effectiveKind, store, onClose]);
const { values, handleChange, handleSubmit, errors, touched } = useForm(
initialValues,
validate,
{ fields: FIELDS },
);
const { submit, isSubmitting } = useAsyncSubmit(saveAsync, (error) => {
toast(error.message || 'Не удалось сохранить шаблон', 'error');
});
const title = isView
? `Шаблон: ${template?.name || ''}`
: isEdit
? `Редактирование шаблона ${kindLabel}`
: `Сохранить как шаблон ${kindLabel}`;
const saveLabel = isEdit ? 'Сохранить' : 'Создать';
const footer = isView
? (
<div className="modal-foot">
<div className="spacer" />
<button className="btn ghost" onClick={onClose}>Закрыть</button>
</div>
)
: null;
const nestedCount = countNestedTasks(nestedItems);
const addNestedNode = () => {
setNested([...nestedItems, createEmptyNestedNode(nestedSingular)]);
if (isCreate && !includeNested) setIncludeNested(true);
};
const tabs = [
{ id: 'info', label: 'Информация' },
...(nestedKey
? [{ id: 'nested', label: `${nestedLabel} (${nestedItems.length})` }]
: []),
];
const renderProjectField = () => {
if (effectiveKind !== 'task') return null;
const projectId = payload.projectId ?? '';
const project = projectId
? store.data.projects.find(p => p.id === projectId)
: null;
if (readOnly) {
const display = project
? `${project.code} - ${project.name}`
: (projectId ? 'Проект недоступен' : '- не указан -');
return (
<div className="field-row">
<label className="field-label">Проект</label>
<div className="flex-1">
<div className="inp" aria-disabled="true">{display}</div>
</div>
</div>
);
}
const projectOptions = [
{ value: '', label: '- Не выбран -' },
...store.data.projects
.filter(p => p.status === 'active' && !p.archived)
.sort((a, b) => a.code.localeCompare(b.code))
.map(p => ({ value: p.id, label: `${p.code} - ${p.name}` })),
];
return (
<FormField
label="Проект"
type="select"
options={projectOptions}
value={projectId}
onChange={(v) => setPayloadField('projectId', v)}
inline
/>
);
};
/**
* Вкладка «Информация».
*
* Все поля идут прямыми потомками .project-info-fields (flex-column
* с gap: 10px). Раньше блок «Параметры» был обёрнут в <div className="mt-3">,
* и gap родителя до его содержимого не доходил - поля слипались. Теперь
* обёртки нет, mt-3 повешен на заголовок секции, а field-row внутри
* получают тот же gap, что и поля до секции.
*/
const renderInfoTab = () => (
<>
{isView && (
<div className="info-box">
Это общий шаблон. Менять его содержимое может только владелец.
</div>
)}
<FormField
label="Название шаблона"
required
value={values.name}
onChange={(v) => handleChange('name', v)}
error={touched.name && errors.name}
disabled={readOnly}
inline
/>
<div className="field-row">
<label className="field-label">Общий шаблон</label>
<label className="dept-pick">
<input
type="checkbox"
checked={values.isShared}
disabled={readOnly}
onChange={(e) => handleChange('isShared', e.target.checked)}
/>
<span>
{values.isShared
? 'Доступен всем сотрудникам'
: 'Личный - видите только вы'}
</span>
</label>
</div>
{Object.keys(fieldsMeta).length > 0 && (
<>
<div className="rep-panel-title mt-3">Параметры</div>
{renderProjectField()}
{Object.entries(fieldsMeta).map(([field, meta]) => {
// Options для приоритета проекта зависят от выбранного типа:
// производственный - AOG / CRIT / NORM, административный -
// высокий / средний / низкий. В схеме (TEMPLATE_FIELDS_META)
// options для project.priority намеренно не заданы - там был
// бы полный список из шести значений, и форма, читающая его
// напрямую, показывала бы лишние пункты. Актуальный набор
// формирует priorityOptionsForProjectType по payload.ptype.
const options = effectiveKind === 'project' && field === 'priority'
? priorityOptionsForProjectType(payload.ptype)
: meta.options;
return (
<FormField
key={field}
label={meta.label}
required={meta.required}
type={meta.type || 'text'}
options={options}
rows={meta.type === 'textarea' ? 2 : undefined}
value={payload[field] ?? ''}
onChange={(v) => setPayloadField(field, v)}
disabled={readOnly}
inline
/>
);
})}
</>
)}
</>
);
const renderNestedTab = () => (
<>
<div className="template-tree-header">
<div className="template-tree-title">{nestedLabel} ({nestedCount})</div>
{!readOnly && (
<button type="button" className="btn ghost sm" onClick={addNestedNode}>
+ {nestedSingular}
</button>
)}
</div>
{isCreate && nestedItems.length > 0 && (
<div className="field-row">
<label className="field-label">Сохранение</label>
<label className="dept-pick">
<input
type="checkbox"
checked={includeNested}
onChange={(e) => setIncludeNested(e.target.checked)}
/>
<span>Включить в шаблон при сохранении</span>
</label>
</div>
)}
{nestedItems.length === 0 ? (
<div className="mut sm">Вложенных элементов нет</div>
) : (
<div className="template-draft-list">
{nestedItems.map((node, idx) => (
<DraftListItem
key={idx}
node={node}
onClick={() => setEditingDraftIndex(idx)}
/>
))}
</div>
)}
</>
);
return (
<>
<ModalShell
title={title}
onClose={onClose}
onSave={isView ? undefined : handleSubmit(submit)}
saveLabel={saveLabel}
showSave={!isView}
saveDisabled={isSubmitting}
width={620}
footer={footer}
>
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
<div className="project-info-fields">
{activeTab === 'info' && renderInfoTab()}
{activeTab === 'nested' && nestedKey && renderNestedTab()}
</div>
</ModalShell>
{editingDraftIndex !== null && nestedItems[editingDraftIndex] && (
<TaskDraftModal
node={nestedItems[editingDraftIndex]}
readOnly={readOnly}
childSingular={nestedSingular}
onChange={(next) => updateDraftAt(editingDraftIndex, next)}
onDelete={readOnly ? null : () => deleteDraftAt(editingDraftIndex)}
onClose={() => setEditingDraftIndex(null)}
/>
)}
</>
);
}
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { useStore } from '../../hooks/useStore';
import { useToast } from '../../context/ToastContext';
import { Select } from '../Select';
/**
* Контролируемый селектор шаблонов.
* При выборе передаёт родителю объект шаблона (или null, если выбран пустой пункт).
* Держит выбранное значение, чтобы пользователь видел, какой шаблон применён.
*/
export function TemplateSelect({ kind, onApply, disabled = false }) {
const { store } = useStore();
const { showToast } = useToast();
const [templates, setTemplates] = useState(() => store.getTemplates(kind));
const [selectedId, setSelectedId] = useState('');
useEffect(() => {
const unsubscribe = store.subscribe(() => setTemplates(store.getTemplates(kind)));
return unsubscribe;
}, [store, kind]);
if (templates.length === 0) return null;
const handleChange = (id) => {
setSelectedId(id);
const template = id ? templates.find(t => t.id === id) : null;
try {
onApply(template);
if (template) {
showToast(`Шаблон «${template.name}» применён`, 'success');
}
} catch (error) {
showToast(error.message || 'Не удалось применить шаблон', 'error');
}
};
return (
<div className="field-row">
<label className="field-label">Шаблон</label>
<div className="flex-1">
<Select
value={selectedId}
onChange={handleChange}
disabled={disabled}
options={[
{ value: '', label: '- Применить шаблон -' },
...templates.map(t => ({
value: t.id,
label: t.name + (t.isShared ? ' · общий' : ''),
})),
]}
/>
</div>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
export { TemplateSelect } from './TemplateSelect';
export { TemplateActions } from './TemplateActions';
export { default as TemplateModal } from './TemplateModal';
export { default as DraftListItem } from './DraftListItem';
export { default as TaskNodeFields } from './TaskNodeFields';
export { default as TaskDraftModal } from './TaskDraftModal';
export { useNestedModalEscape } from './useNestedModalEscape';
@@ -0,0 +1,40 @@
import { useEffect } from 'react';
/**
* Стек активных перехватчиков Escape.
*
* Раньше обработчик просто глушил клавишу через stopImmediatePropagation.
* Этого достаточно для одного уровня (модалка + ConfirmDialog, где
* ConfirmDialog ловит событие в фазе погружения, а модалка - во всплытии),
* но при двух и более «перехватчиках» зарегистрированные раньше гасили
* событие раньше остальных: Escape закрывал внешнюю модалку вместо
* внутренней.
*
* Теперь каждый экземпляр кладёт в стек уникальный токен и реагирует на
* клавишу только если он - верхний. Порядок стека = порядок монтирования,
* то есть «сверху вниз» по визуальному слою. Escape закрывает ровно один
* слой - верхний.
*/
const escapeStack = [];
export function useNestedModalEscape(onEscape) {
useEffect(() => {
const token = {};
escapeStack.push(token);
const handler = (event) => {
if (event.key !== 'Escape') return;
if (escapeStack[escapeStack.length - 1] !== token) return;
event.stopImmediatePropagation();
onEscape();
};
window.addEventListener('keydown', handler, true);
return () => {
window.removeEventListener('keydown', handler, true);
const idx = escapeStack.indexOf(token);
if (idx !== -1) escapeStack.splice(idx, 1);
};
}, [onEscape]);
}
+28
View File
@@ -0,0 +1,28 @@
// src/components/ThemeToggle.jsx
import { useTheme } from '../hooks/useTheme';
import { Ic, ICONS } from './Icons';
/**
* Переключатель светлой/тёмной темы. Живёт в тулбаре MainLayout.
*
* Использует .icon-btn — тот же визуальный слой, что у колокольчика,
* кнопок навигации и меню «⋯». Отдельного класса не заводим: тема —
* это «действие над интерфейсом», а не отдельная сущность.
*/
export function ThemeToggle() {
const { isDark, toggle } = useTheme();
return (
<button
type="button"
className="icon-btn"
onClick={toggle}
title={isDark ? 'Светлая тема' : 'Тёмная тема'}
aria-label={isDark ? 'Включить светлую тему' : 'Включить тёмную тему'}
aria-pressed={isDark}
>
<Ic d={isDark ? ICONS.sun : ICONS.moon} size={17} />
</button>
);
}
export default ThemeToggle;
+43
View File
@@ -0,0 +1,43 @@
// src/components/ToggleSwitch.jsx
/**
* Тумблер «вкл / выкл» с подписью.
*
* Рендерится как <button role="switch">, а не как <input type="checkbox">
* внутри <label>. Причины:
* - семантика switch читается скринридером как «переключатель», а не
* как «флажок» - для булевых фильтров это точнее;
* - один элемент = одна цель клика (метка и тумблер не разъезжаются,
* нажатие на любую часть срабатывает одинаково);
* - не нужен ни <input>, ни отдельная связка id/for.
*
* Компонент fully controlled: своё состояние не хранит, значение и
* onChange приходят из filters через useFilters. Это позволяет
* использовать его и в тулбаре (фильтр), и в любом другом месте, не
* плодя второй вариант с внутренним state.
*
* Доступность: role="switch" + aria-checked дают скринридеру корректное
* «включено / выключено»; :focus-visible в CSS рисует фокус-кольцо для
* клавиатуры; текстовая метка рядом с тумблером - не только цветовой
* индикатор, состояние читается и без зрения цвета.
*/
export function ToggleSwitch({
checked,
onChange,
label,
className = '',
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
className={`toggle-switch${checked ? ' on' : ''}${className ? ' ' + className : ''}`}
onClick={() => onChange(!checked)}
>
<span className="toggle-switch-track" aria-hidden="true">
<span className="toggle-switch-knob" />
</span>
<span className="toggle-switch-label">{label}</span>
</button>
);
}
+61
View File
@@ -0,0 +1,61 @@
// src/components/UnreadBadge.jsx
import { messageWord } from '../utils/pluralize';
/**
* Метка непрочитанных сообщений на карточке. Позиционируется абсолютно
* в правом нижнем углу — родитель должен быть position: relative
* (у .kcard и у .pj-card relative уже есть).
*
* Если передан onClick — бейдж становится кнопкой: клик открывает чат
* сущности напрямую, минуя родительскую карточку. Это ключевая деталь:
* без stopPropagation родительский onOpen перехватил бы клик и открыл
* карточку на вкладке по умолчанию («Данные» / «Информация»), а
* пользователь хотел именно чат.
*
* role="button" + tabIndex + onKeyDown — вместо вложенного <button>:
* бейдж часто лежит внутри кликабельной карточки, которая сама может
* быть <button> или div с onClick. Вложенные <button> — невалидный
* HTML. Span с role даёт ту же доступность без нарушения семантики.
*
* Экспортируется и как named, и как default — потому что в разных
* местах проекта встречаются оба стиля импорта (`import UnreadBadge`
* и `import { UnreadBadge }`). Наличие двух форм здесь дешевле, чем
* правка импортов во всех потребителях и риск забыть один.
*/
export function UnreadBadge({ count, onClick = null }) {
if (!count) return null;
const clickable = typeof onClick === 'function';
const handleClick = clickable
? (e) => {
e.stopPropagation();
onClick();
}
: undefined;
const handleKeyDown = clickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onClick();
}
}
: undefined;
return (
<span
className={`kcard-unread${clickable ? ' kcard-unread--clickable' : ''}`}
onClick={handleClick}
onKeyDown={handleKeyDown}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
title={clickable ? 'Открыть чат' : undefined}
>
{count} {messageWord(count)}
</span>
);
}
export default UnreadBadge;
+77
View File
@@ -0,0 +1,77 @@
// src/components/ViewFooter.jsx
import { useLayoutEffect, useRef } from 'react';
/**
* Подвал раздела - панель внизу контента, прижатая к нижнему краю
* видимой области.
*
* По образцу ModalShell.footer: единая разметка, в которую раздел
* кладёт только своё содержимое. Отличие от .modal-foot в
* позиционировании: у модалки футер - часть рамки окна, у раздела
* содержимое длиннее экрана, и футер должен оставаться на виду при
* прокрутке. Поэтому position: sticky.
*
* API из двух слотов:
* - children - левая часть (переключатели, счётчики); React кладёт
* их рядом как соседей, обёртка не нужна;
* - action - правая часть (обычно одна кнопка создания), прижата
* к правому краю через margin-left: auto.
*
* children вместо произвольного набора пропсов - идиома React: для
* левой части не нужен ни фрагмент, ни искусственная обёртка. action
* отдельным пропсом, потому что это семантически другая роль
* (действие vs. управление видом), и раскладка у неё своя.
*
* Если оба слота пусты - компонент возвращает null: пустая полоса
* с рамкой внизу экрана не нужна.
*
* Регистрация класса .has-view-foot на .content.
*
* Раньше в styles.css было правило .content:has(> .view-foot),
* которое снимало боковые padding'и у .content, когда внутри рендерился
* футер - полоса шла от края до края, без двойного отступа. Проблема
* :has() в том, что браузер перепроверяет совпадение при каждом
* изменении дочерних узлов .content. А в .content лежит всё дерево
* активной вьюхи (канбан, гантт, таблицы) - при скролле, перерисовке
* карточек, обновлении задач это заметная просадка.
*
* Теперь класс вешается императивно из самого ViewFooter: он знает,
* что он - футер, и знает, что его действие касается контейнера.
* Родитель (.content) об этом ничего не знает и знать не должен.
* closest('.content') находит контейнер по классу, не завязываясь на
* конкретную структуру DOM выше.
*
* useLayoutEffect - до пейнта, чтобы не было кадра без снятого
* padding'а. Cleanup снимает класс при размонтировании, чтобы при
* переходе на вьюху без футера .content не остался с флагом.
*
* depends от hasContent, а не от children/action: эффект должен
* срабатывать только на появление/исчезновение контента, а не на
* каждое изменение JSX внутри слотов.
*/
export function ViewFooter({ children = null, action = null, className = '' }) {
const rootRef = useRef(null);
const hasContent = !!(children || action);
useLayoutEffect(() => {
if (!hasContent) return undefined;
const el = rootRef.current;
if (!el) return undefined;
const content = el.closest('.content');
if (!content) return undefined;
content.classList.add('has-view-foot');
return () => content.classList.remove('has-view-foot');
}, [hasContent]);
if (!hasContent) return null;
return (
<div
ref={rootRef}
className={`view-foot${className ? ' ' + className : ''}`}
>
{children}
{action && <div className="view-foot-action">{action}</div>}
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
// src/components/ViewModeToggle.jsx
import { Ic, ICONS } from './Icons';
/**
* Переключатель «Список / Канбан».
*
* Один и тот же контрол в разделах задач и проектов: те же два режима,
* та же группа кнопок, тот же способ подсветки активного. Вынесен
* отдельным компонентом, потому что разметка совпадает символ в
* символ, а состояние (`viewMode`) остаётся у вьюхи - компонент
* полностью управляемый и о режимах ничего не знает, кроме их имён.
*
* Имена режимов заданы здесь: вызывающий не обязан помнить точное
* 'kanban' / 'list', он говорит `value` и получает `onChange` с тем же
* строковым ключом. Если когда-нибудь появится третий режим, расширять
* придётся один файл.
*/
const MODES = [
{ id: 'list', label: 'Список', icon: ICONS.list },
{ id: 'kanban', label: 'Канбан', icon: ICONS.kanban },
];
export function ViewModeToggle({ value, onChange }) {
return (
<div className="btn-group">
{MODES.map(({ id, label, icon }) => (
<button
key={id}
type="button"
className={`btn ghost sm ${value === id ? 'active' : ''}`}
onClick={() => onChange(id)}
>
<Ic d={icon} size={15} /> {label}
</button>
))}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
// src/components/WorkloadBar.jsx
import { memo, useLayoutEffect, useRef } from 'react';
/**
* Полоса загрузки. Ширина заполнения задаётся CSS-переменной
* --workload-fill, которую выставляем императивно через style.setProperty.
* В JSX никаких inline-стилей - разметка чистая, а размерность живёт в CSS.
*
* memo-компонент: рендерится по одной на строку в дашборде загрузки,
* на порядок десятков строк. Все пропсы - примитивы (числа + строка
* варианта), поэтому memo сравнивает их по значению, и строки с
* неизменившейся загрузкой не перерисовываются при изменении соседних.
*/
const WorkloadBar = memo(function WorkloadBar({ value, max, variant = 'plan' }) {
const ref = useRef(null);
const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
useLayoutEffect(() => {
ref.current?.style.setProperty('--workload-fill', `${pct}%`);
}, [pct]);
return (
<div className="workload-bar">
<div
ref={ref}
className={`workload-bar-fill workload-bar-fill--${variant}`}
/>
</div>
);
});
export default WorkloadBar;
@@ -0,0 +1,33 @@
import { useDiscussion } from './context';
export default function CommentActions({ comment }) {
const {
policy, readOnly,
setEditingId, setEditText,
onReply, togglePin, onDelete,
} = useDiscussion();
if (readOnly) return null;
return (
<div className="cm-actions" onClick={(e) => e.stopPropagation()}>
<button className="link" onClick={() => onReply(comment.id)}>Ответить</button>
{policy.canEdit(comment) && (
<button
className="link"
onClick={() => { setEditingId(comment.id); setEditText(comment.text); }}
>
Редактировать
</button>
)}
{policy.canPin(comment) && (
<button className="link" onClick={() => togglePin(comment)}>
{comment.pinned ? 'Открепить' : 'Закрепить'}
</button>
)}
{policy.canDelete(comment) && (
<button className="link red-link" onClick={() => onDelete(comment)}>Удалить</button>
)}
</div>
);
}
Loaded 100 of 254 files, more files were not shown because too many files have changed in this diff. Show more