Initial commit
This commit is contained in:
1 parent
2d9431dc9d
commit
3372ca041f
254 files changed
+60975
No files matched your search
+20
@@ -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)
|
||||
```
|
||||
Generated
+58
@@ -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>
|
||||
@@ -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()],
|
||||
})
|
||||
|
||||
@@ -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.
Binary file not shown.
Generated
+4016
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
Binary file not shown.
+11944
File diff suppressed because it is too large.
Load diff
@@ -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 |
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user