diff --git a/app/api/server/index.js b/app/api/server/index.js index 7d7f9bf908ef..cd6e6426b3c7 100755 --- a/app/api/server/index.js +++ b/app/api/server/index.js @@ -43,5 +43,6 @@ import './v1/councils'; import './v1/working-groups'; import './v1/working-group-meetings'; import './v1/upload-files'; +import './v1/protocols'; export { API, APIClass, defaultRateLimiterOptions } from './api'; diff --git a/app/api/server/lib/protocols.js b/app/api/server/lib/protocols.js new file mode 100644 index 000000000000..ba2191a104ea --- /dev/null +++ b/app/api/server/lib/protocols.js @@ -0,0 +1,25 @@ +import { Protocols } from '../../../models/server/raw'; + +export async function findProtocols({ query = {}, pagination: { offset, count, sort } }) { + const cursor = await Protocols.find(query, { + sort: sort || { time: 1 }, + skip: offset, + limit: count, + }); + + const total = await cursor.count(); + + const protocols = await cursor.toArray(); + + return { + protocols, + count: protocols.length, + offset, + total, + }; +} + +export async function findProtocol(_id) { + const cursor = await Protocols.findOne({ _id }); + return cursor; +} diff --git a/app/api/server/v1/protocols.js b/app/api/server/v1/protocols.js new file mode 100644 index 000000000000..ca1fc61d239d --- /dev/null +++ b/app/api/server/v1/protocols.js @@ -0,0 +1,30 @@ +import { API } from '../api'; +import { findProtocols, findProtocol } from '../lib/protocols'; +import { hasPermission } from '../../../authorization'; + +API.v1.addRoute('protocols.list', { authRequired: true }, { + get() { + if (!hasPermission(this.userId, 'manage-protocols')) { + return API.v1.unauthorized(); + } + + const { offset, count } = this.getPaginationItems(); + const { sort, query } = this.parseJsonQuery(); + + return API.v1.success(Promise.await(findProtocols({ + query, + pagination: { + offset, + count, + sort, + }, + }))); + }, +}); + +API.v1.addRoute('protocols.findOne', { authRequired: true }, { + get() { + const { query } = this.parseJsonQuery(); + return API.v1.success(Promise.await(findProtocol(query._id))); + }, +}); diff --git a/app/authorization/server/startup.js b/app/authorization/server/startup.js index 79ae9b77d4b7..3fac13f43f03 100644 --- a/app/authorization/server/startup.js +++ b/app/authorization/server/startup.js @@ -60,6 +60,7 @@ Meteor.startup(function() { { _id: 'manage-own-outgoing-integrations', roles: ['admin'] }, { _id: 'manage-own-incoming-integrations', roles: ['admin'] }, { _id: 'manage-oauth-apps', roles: ['admin'] }, + { _id: 'manage-protocols', roles: ['admin', 'secretary'] }, { _id: 'manage-selected-settings', roles: ['admin'] }, { _id: 'manage-tags', roles: ['admin'] }, { _id: 'manage-working-group', roles: ['admin', 'secretary'] }, diff --git a/app/models/server/index.js b/app/models/server/index.js index 9798d7bf84f0..fb659184080d 100755 --- a/app/models/server/index.js +++ b/app/models/server/index.js @@ -43,6 +43,7 @@ import Tags from './models/Tags'; import Councils from './models/Councils'; import WorkingGroups from './models/WorkingGroups'; import WorkingGroupMeetings from './models/WorkingGroupMeetings'; +import Protocols from './models/Protocols'; export { AppsLogsModel } from './models/apps-logs-model'; export { AppsPersistenceModel } from './models/apps-persistence-model'; @@ -98,4 +99,5 @@ export { Councils, WorkingGroups, WorkingGroupMeetings, + Protocols, }; diff --git a/app/models/server/models/Protocols.js b/app/models/server/models/Protocols.js new file mode 100644 index 000000000000..8bb860d345fa --- /dev/null +++ b/app/models/server/models/Protocols.js @@ -0,0 +1,127 @@ +import { Base } from './_Base'; +import { ObjectID } from 'bson'; + +class Protocols extends Base { + constructor() { + super('protocols'); + } + + // INSERT + create(data) { + return this.insert(data); + } + + // REMOVE + removeById(_id) { + return this.remove({ _id }); + } + + // UPDATE + updateProtocol(_id, data) { + data._updatedAt = new Date(); + return this.update({ _id }, { $set: { ...data } }); + } + + createSection(protocolId, sectionData) { + const _id = new ObjectID().toHexString(); + sectionData._id = _id; + + const data = this.findOne({ _id: protocolId }); + data.sections = data.sections ? [...data.sections, sectionData] : [sectionData]; + data._updatedAt = new Date(); + this.update({ _id: protocolId }, { $set: { ...data } }); + + return _id; + } + + removeSectionById(protocolId, _id) { + const data = this.findOne({ _id: protocolId }); + + if (data.sections) { + data.sections = data.sections.filter(section => section._id !== _id); + data._updatedAt = new Date(); + this.update({ _id: protocolId }, { $set: { ...data }}); + } + } + + updateSection(protocolId, sectionData) { + const data = this.findOne({ _id: protocolId }); + + if (data.sections) { + data.sections = data.sections.map((section) => { + if (section._id === sectionData._id) { + return { ...sectionData, items: section.items }; + } + return section; + }); + + data._updatedAt = new Date(); + this.update({ _id: protocolId }, { $set: { ...data }}); + } + + return sectionData._id; + } + + createItem(protocolId, sectionId, item) { + const _id = new ObjectID().toHexString(); + item._id = _id; + + const data = this.findOne({ _id: protocolId }); + + if (data.sections) { + data._updatedAt = new Date(); + + data.sections.forEach((section) => { + if (section._id === sectionId) { + section.items = section.items ? [...section.items, item] : [item]; + } + }); + + this.update({ _id: protocolId }, { $set: { ...data } }) + } + + return _id; + } + + removeItemById(protocolId, sectionId, _id) { + const data = this.findOne({ _id: protocolId }); + + if (data.sections) { + data.sections = data.sections.map((section) => { + if (section._id === sectionId) { + section.items = section.items.filter(item => item._id !== _id); + } + return section; + }); + + data._updatedAt = new Date(); + this.update({ _id: protocolId }, { $set: { ...data }}); + } + } + + updateItem(protocolId, sectionId, itemData) { + const data = this.findOne({ _id: protocolId }); + + if (data.sections) { + data.sections = data.sections.map((section) => { + if (section._id === sectionId) { + section.items = section.items.map((item) => { + if (item._id === itemData._id) { + return { ...itemData }; + } + return item; + }) + } + return section; + }); + + data._updatedAt = new Date(); + this.update({ _id: protocolId }, { $set: { ...data }}); + } + + return itemData._id; + } + +} + +export default new Protocols(); diff --git a/app/models/server/raw/Protocols.js b/app/models/server/raw/Protocols.js new file mode 100644 index 000000000000..fb4309339f1b --- /dev/null +++ b/app/models/server/raw/Protocols.js @@ -0,0 +1,5 @@ +import { BaseRaw } from './BaseRaw'; + +export class ProtocolsRaw extends BaseRaw { + +} diff --git a/app/models/server/raw/index.ts b/app/models/server/raw/index.ts index 845d3a0d57a8..666e411373de 100755 --- a/app/models/server/raw/index.ts +++ b/app/models/server/raw/index.ts @@ -62,6 +62,8 @@ import WorkingGroupMeetingsModel from '../models/WorkingGroupMeetings'; import { WorkingGroupMeetingRaw } from './WorkingGroupMeetingRaw'; import UploadsFilesModel from '../models/Uploads'; import { UploadsFilesRaw } from './UploadsFilesRaw'; +import ProtocolsModel from '../models/Protocols'; +import { ProtocolsRaw } from './Protocols'; export const Permissions = new PermissionsRaw(PermissionsModel.model.rawCollection()); export const Roles = new RolesRaw(RolesModel.model.rawCollection()); @@ -95,3 +97,4 @@ export const Councils = new CouncilsRaw(CouncilsModel.model.rawCollection()); export const WorkingGroups = new WorkingGroupRaw(WorkingGroupsModel.model.rawCollection()); export const WorkingGroupMeetings = new WorkingGroupMeetingRaw(WorkingGroupMeetingsModel.model.rawCollection()); export const UploadFiles = new UploadsFilesRaw(UploadsFilesModel.model.rawCollection()); +export const Protocols = new ProtocolsRaw(ProtocolsModel.model.rawCollection()); diff --git a/app/protocols/client/views/AddItem.js b/app/protocols/client/views/AddItem.js new file mode 100644 index 000000000000..2feb8ddd37ce --- /dev/null +++ b/app/protocols/client/views/AddItem.js @@ -0,0 +1,94 @@ +import React, { useState, useCallback } from 'react'; +import { Field, TextAreaInput, Button, InputBox, ButtonGroup, TextInput } from '@rocket.chat/fuselage'; +import DatePicker, { registerLocale } from 'react-datepicker'; +import ru from 'date-fns/locale/ru'; +registerLocale('ru', ru); + +import { useToastMessageDispatch } from '../../../../client/contexts/ToastMessagesContext'; +import { useTranslation } from '../../../../client/contexts/TranslationContext'; +import { useRouteParameter } from '../../../../client/contexts/RouterContext'; +import { useMethod } from '../../../../client/contexts/ServerContext'; +import { validateItemData, createItemData } from './lib'; +import VerticalBar from '../../../../client/components/basic/VerticalBar'; + +export function AddItem({ goToNew, close, onChange, ...props }) { + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const [number, setNumber] = useState(''); + const [name, setName] = useState(''); + const [responsible, setResponsible] = useState(''); + const [expireAt, setExpireAt] = useState(''); + + const protocolId = useRouteParameter('id'); + const sectionId = useRouteParameter('sectionId'); + + const insertOrUpdateItem = useMethod('insertOrUpdateItem'); + + const saveAction = useCallback(async (number, name, responsible, expireAt) => { + const itemData = createItemData(number, name, responsible, expireAt); + const validation = validateItemData(itemData); + if (validation.length === 0) { + const _id = await insertOrUpdateItem(protocolId, sectionId, itemData); + return _id; + } + validation.forEach((error) => { throw new Error({ type: 'error', message: t('error-the-field-is-required', { field: t(error) }) }); }); + }, [dispatchToastMessage, insertOrUpdateItem, t]); + + const handleSave = useCallback(async () => { + try { + const result = await saveAction( + number, + name, + responsible, + expireAt + ); + dispatchToastMessage({ type: 'success', message: t('Item_Added_Successfully') }); + goToNew(sectionId, result)(); + onChange(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, [dispatchToastMessage, goToNew, number, name, responsible, expireAt, onChange, saveAction, t]); + + return + + {t('Item_Number')} + + setNumber(e.currentTarget.value)} placeholder={t('Item_Number')} /> + + + + {t('Item_Name')} + + setName(e.currentTarget.value)} placeholder={t('Item_Name')} /> + + + + {t('Item_Responsible')} + + setResponsible(e.currentTarget.value)} placeholder={t('Item_Responsible')} /> + + + + {t('Item_ExpireAt')} + + setExpireAt(newDate)} + customInput={} + locale='ru' + /> + + + + + + + + + + + ; +} diff --git a/app/protocols/client/views/AddProtocol.js b/app/protocols/client/views/AddProtocol.js new file mode 100644 index 000000000000..342cedd53559 --- /dev/null +++ b/app/protocols/client/views/AddProtocol.js @@ -0,0 +1,85 @@ +import React, { useState, useCallback } from 'react'; +import { Field, TextAreaInput, Button, InputBox, ButtonGroup, TextInput } from '@rocket.chat/fuselage'; +import DatePicker, { registerLocale } from 'react-datepicker'; +import ru from 'date-fns/locale/ru'; +registerLocale('ru', ru); + +import { useToastMessageDispatch } from '../../../../client/contexts/ToastMessagesContext'; +import { useTranslation } from '../../../../client/contexts/TranslationContext'; +import { useMethod } from '../../../../client/contexts/ServerContext'; +import { validate, createProtocolData } from './lib'; +import VerticalBar from '../../../../client/components/basic/VerticalBar'; + +require('react-datepicker/dist/react-datepicker.css'); + +export function AddProtocol({ goToNew, close, onChange, ...props }) { + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const [date, setDate] = useState(''); + const [number, setNumber] = useState(''); + const [place, setPlace] = useState(''); + + const insertOrUpdateProtocol = useMethod('insertOrUpdateProtocol'); + + const saveAction = useCallback(async (date, number, place) => { + const protocolData = createProtocolData(date, number, place); + const validation = validate(protocolData); + if (validation.length === 0) { + const _id = await insertOrUpdateProtocol(protocolData); + return _id; + } + validation.forEach((error) => { throw new Error({ type: 'error', message: t('error-the-field-is-required', { field: t(error) }) }); }); + }, [dispatchToastMessage, insertOrUpdateProtocol, t]); + + const handleSave = useCallback(async () => { + try { + const result = await saveAction( + date, + number, + place + ); + dispatchToastMessage({ type: 'success', message: t('Protocol_Added_Successfully') }); + goToNew(result)(); + onChange(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, [dispatchToastMessage, goToNew, date, number, place, onChange, saveAction, t]); + + return + + {t('Protocol_Number')} + + setNumber(e.currentTarget.value)} placeholder={t('Protocol_Number')} /> + + + + {t('Protocol_Date')} + + setDate(newDate)} + customInput={} + locale='ru' + /> + {/* setDate(e.currentTarget.value)} placeholder={t('Date')} />*/} + + + + {t('Protocol_Place')} + + setPlace(e.currentTarget.value)} placeholder={t('Protocol_Place')} /> + + + + + + + + + + + ; +} diff --git a/app/protocols/client/views/AddSection.js b/app/protocols/client/views/AddSection.js new file mode 100644 index 000000000000..9e8b04688b9f --- /dev/null +++ b/app/protocols/client/views/AddSection.js @@ -0,0 +1,68 @@ +import React, { useState, useCallback } from 'react'; +import { Field, TextAreaInput, Button, InputBox, ButtonGroup, TextInput } from '@rocket.chat/fuselage'; + +import { useToastMessageDispatch } from '../../../../client/contexts/ToastMessagesContext'; +import { useTranslation } from '../../../../client/contexts/TranslationContext'; +import { useRouteParameter } from '../../../../client/contexts/RouterContext'; +import { useMethod } from '../../../../client/contexts/ServerContext'; +import { validateSectionData, createSectionData } from './lib'; +import VerticalBar from '../../../../client/components/basic/VerticalBar'; + +export function AddSection({ goToNew, close, onChange, ...props }) { + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const [name, setName] = useState(''); + const [number, setNumber] = useState(''); + + const protocolId = useRouteParameter('id'); + + const insertOrUpdateSection = useMethod('insertOrUpdateSection'); + + const saveAction = useCallback(async (number, name) => { + const sectionData = createSectionData(number, name); + const validation = validateSectionData(sectionData); + if (validation.length === 0) { + const _id = await insertOrUpdateSection(protocolId, sectionData); + return _id; + } + validation.forEach((error) => { throw new Error({ type: 'error', message: t('error-the-field-is-required', { field: t(error) }) }); }); + }, [dispatchToastMessage, insertOrUpdateSection, t]); + + const handleSave = useCallback(async () => { + try { + const result = await saveAction( + number, + name + ); + dispatchToastMessage({ type: 'success', message: t('Section_Added_Successfully') }); + goToNew(result)(); + onChange(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, [dispatchToastMessage, goToNew, number, name, onChange, saveAction, t]); + + return + + {t('Section_Number')} + + setNumber(e.currentTarget.value)} placeholder={t('Section_Number')} /> + + + + {t('Section_Name')} + + setName(e.currentTarget.value)} placeholder={t('Section_Name')} /> + + + + + + + + + + + ; +} diff --git a/app/protocols/client/views/EditItem.js b/app/protocols/client/views/EditItem.js new file mode 100644 index 000000000000..c85d3619be08 --- /dev/null +++ b/app/protocols/client/views/EditItem.js @@ -0,0 +1,199 @@ +import React, { useCallback, useState, useMemo, useEffect } from 'react'; +import { + Box, + Button, + ButtonGroup, + Field, + Icon, + Skeleton, + Throbber, + InputBox, + TextAreaInput, + TextInput, + Modal +} from '@rocket.chat/fuselage'; +import DatePicker, { registerLocale } from 'react-datepicker'; +import ru from 'date-fns/locale/ru'; +registerLocale('ru', ru); + +import { useTranslation } from '../../../../client/contexts/TranslationContext'; +import { useMethod } from '../../../../client/contexts/ServerContext'; +import { useToastMessageDispatch } from '../../../../client/contexts/ToastMessagesContext'; +import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../../../client/hooks/useEndpointDataExperimental'; +import { validateItemData, createItemData } from './lib'; +import { useSetModal } from '../../../../client/contexts/ModalContext'; +import VerticalBar from '../../../../client/components/basic/VerticalBar'; + +require('react-datepicker/dist/react-datepicker.css'); + +const DeleteWarningModal = ({ onDelete, onCancel, ...props }) => { + const t = useTranslation(); + return + + + {t('Are_you_sure')} + + + + {t('Item_Delete_Warning')} + + + + + + + + ; +}; + +const SuccessModal = ({ onClose, ...props }) => { + const t = useTranslation(); + return + + + {t('Deleted')} + + + + {t('Item_Has_Been_Deleted')} + + + + + + + ; +}; + +export function EditItem({ protocolId, sectionId, _id, cache, onChange, ...props }) { + const query = useMemo(() => ({ + query: JSON.stringify({ _id: protocolId }), + }), [protocolId, _id, cache]); + + const { data, state, error } = useEndpointDataExperimental('protocols.findOne', query); + + if (state === ENDPOINT_STATES.LOADING) { + return + + + + + + + + + + + + ; + } + + if (error || !data) { + return {error}; + } + + return ; +} + +function EditItemWithData({ close, onChange, protocol, sectionId, itemId, ...props }) { + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const item = protocol.sections.find(s => s._id === sectionId).items.find(i => i._id === itemId); + + const { _id, num: previousNumber, name: previousName, responsible: previousResponsible, expireAt: previousExpireAt } = item || {}; + const previousItem = item || {}; + + const [number, setNumber] = useState(''); + const [name, setName] = useState(''); + const [responsible, setResponsible] = useState(''); + const [expireAt, setExpireAt] = useState(''); + const setModal = useSetModal(); + + useEffect(() => { + setNumber(previousNumber || ''); + setName(previousName || ''); + setResponsible(previousResponsible || ''); + setExpireAt(previousExpireAt ? new Date(previousExpireAt) : ''); + }, [previousNumber, previousName, previousResponsible, previousExpireAt, _id]); + + const deleteItem = useMethod('deleteItem'); + const insertOrUpdateItem = useMethod('insertOrUpdateItem'); + + const hasUnsavedChanges = useMemo(() => previousNumber !== number || previousName !== name || previousResponsible !== responsible || previousExpireAt !== expireAt, + [number, name, responsible, expireAt]); + + const saveAction = useCallback(async (number, name, responsible, expireAt) => { + const itemData = createItemData(number, name, responsible, expireAt, { previousNumber, previousName, _id }); + const validation = validateItemData(itemData); + if (validation.length === 0) { + const _id = await insertOrUpdateItem(protocol._id, sectionId, itemData); + } + validation.forEach((error) => { throw new Error({ type: 'error', message: t('error-the-field-is-required', { field: t(error) }) }); }); + }, [_id, dispatchToastMessage, insertOrUpdateItem, number, name, responsible, expireAt, previousNumber, previousName, previousResponsible, previousExpireAt, previousItem, t]); + + const handleSave = useCallback(async () => { + saveAction(number, name, responsible, expireAt); + onChange(); + }, [saveAction, onChange]); + + const onDeleteConfirm = useCallback(async () => { + try { + await deleteItem(protocol._id, sectionId, _id); + setModal(() => { setModal(undefined); close(); onChange(); }}/>); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + onChange(); + } + }, [_id, close, deleteItem, dispatchToastMessage, onChange]); + + const openConfirmDelete = () => setModal(() => setModal(undefined)}/>); + + return + + {t('Item_Number')} + + setNumber(e.currentTarget.value)} placeholder={t('Item_Number')} /> + + + + {t('Item_Name')} + + setName(e.currentTarget.value)} placeholder={t('Item_name')} /> + + + + {t('Item_Responsible')} + + setResponsible(e.currentTarget.value)} placeholder={t('Item_Responsible')} /> + + + + {t('Item_ExpireAt')} + + setExpireAt(newDate)} + customInput={} + locale='ru' + /> + + + + + + + + + + + + + + + + + + ; +} diff --git a/app/protocols/client/views/EditProtocol.js b/app/protocols/client/views/EditProtocol.js new file mode 100644 index 000000000000..c93298554a11 --- /dev/null +++ b/app/protocols/client/views/EditProtocol.js @@ -0,0 +1,189 @@ +import React, { useCallback, useState, useMemo, useEffect } from 'react'; +import { + Box, + Button, + ButtonGroup, + Field, + Icon, + Skeleton, + Throbber, + InputBox, + TextAreaInput, + TextInput, + Modal +} from '@rocket.chat/fuselage'; +import DatePicker, { registerLocale } from 'react-datepicker'; +import ru from 'date-fns/locale/ru'; +registerLocale('ru', ru); + +import { useTranslation } from '../../../../client/contexts/TranslationContext'; +import { useMethod } from '../../../../client/contexts/ServerContext'; +import { useToastMessageDispatch } from '../../../../client/contexts/ToastMessagesContext'; +import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../../../client/hooks/useEndpointDataExperimental'; +import { validate, createProtocolData } from './lib'; +import { useSetModal } from '../../../../client/contexts/ModalContext'; +import VerticalBar from '../../../../client/components/basic/VerticalBar'; + +require('react-datepicker/dist/react-datepicker.css'); + +const DeleteWarningModal = ({ onDelete, onCancel, ...props }) => { + const t = useTranslation(); + return + + + {t('Are_you_sure')} + + + + {t('Protocol_Delete_Warning')} + + + + + + + + ; +}; + +const SuccessModal = ({ onClose, ...props }) => { + const t = useTranslation(); + return + + + {t('Deleted')} + + + + {t('Protocol_Has_Been_Deleted')} + + + + + + + ; +}; + +export function EditProtocol({ _id, cache, onChange, ...props }) { + const query = useMemo(() => ({ + query: JSON.stringify({ _id }), + }), [_id, cache]); + + const { data, state, error } = useEndpointDataExperimental('protocols.list', query); + + if (state === ENDPOINT_STATES.LOADING) { + return + + + + + + + + + + + + ; + } + + if (error || !data || data.protocols.length < 1) { + return {error}; + } + + return ; +} + +function EditProtocolWithData({ close, onChange, protocol, ...props }) { + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const { _id, d: previousDate, num: previousNumber, place: previousPlace } = protocol || {}; + const previousProtocol = protocol || {}; + + const [date, setDate] = useState(new Date(previousDate)); + const [number, setNumber] = useState(previousNumber); + const [place, setPlace] = useState(previousPlace); + const setModal = useSetModal(); + + useEffect(() => { + setDate(new Date(previousDate) || ''); + setNumber(previousNumber || ''); + setPlace(previousPlace || ''); + }, [previousDate, previousNumber, previousPlace, _id]); + + const deleteProtocol = useMethod('deleteProtocol'); + const insertOrUpdateProtocol = useMethod('insertOrUpdateProtocol'); + + const hasUnsavedChanges = useMemo(() => previousDate !== date || previousNumber !== number || previousPlace !== place, + [date, number, place]); + + const saveAction = useCallback(async (date, number, place) => { + const protocolData = createProtocolData(date, number, place, { previousDate, previousNumber, previousPlace, _id }); + const validation = validate(protocolData); + if (validation.length === 0) { + const _id = await insertOrUpdateProtocol(protocolData); + } + validation.forEach((error) => { throw new Error({ type: 'error', message: t('error-the-field-is-required', { field: t(error) }) }); }); + }, [_id, dispatchToastMessage, insertOrUpdateProtocol, date, number, place, previousDate, previousNumber, previousPlace, previousProtocol, t]); + + const handleSave = useCallback(async () => { + saveAction(date, number, place); + onChange(); + }, [saveAction, onChange]); + + const onDeleteConfirm = useCallback(async () => { + try { + await deleteProtocol(_id); + setModal(() => { setModal(undefined); close(); onChange(); }}/>); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + onChange(); + } + }, [_id, close, deleteProtocol, dispatchToastMessage, onChange]); + + const openConfirmDelete = () => setModal(() => setModal(undefined)}/>); + + return + + {t('Protocol_Number')} + + setNumber(e.currentTarget.value)} placeholder={t('Protocol_Number')} /> + + + + {t('Protocol_Date')} + + setDate(newDate)} + customInput={} + locale='ru' + /> + + + + {t('Protocol_Place')} + + setPlace(e.currentTarget.value)} placeholder={t('Protocol_Place')} /> + + + + + + + + + + + + + + + + + + ; +} diff --git a/app/protocols/client/views/EditSection.js b/app/protocols/client/views/EditSection.js new file mode 100644 index 000000000000..bd5389681ff1 --- /dev/null +++ b/app/protocols/client/views/EditSection.js @@ -0,0 +1,177 @@ +import React, { useCallback, useState, useMemo, useEffect } from 'react'; +import { + Box, + Button, + ButtonGroup, + Field, + Icon, + Skeleton, + Throbber, + InputBox, + TextAreaInput, + TextInput, + Modal +} from '@rocket.chat/fuselage'; +import DatePicker, { registerLocale } from 'react-datepicker'; +import ru from 'date-fns/locale/ru'; +registerLocale('ru', ru); + +import { useTranslation } from '../../../../client/contexts/TranslationContext'; +import { useMethod } from '../../../../client/contexts/ServerContext'; +import { useToastMessageDispatch } from '../../../../client/contexts/ToastMessagesContext'; +import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../../../client/hooks/useEndpointDataExperimental'; +import { validateSectionData, createSectionData } from './lib'; +import { useSetModal } from '../../../../client/contexts/ModalContext'; +import VerticalBar from '../../../../client/components/basic/VerticalBar'; + +require('react-datepicker/dist/react-datepicker.css'); + +const DeleteWarningModal = ({ onDelete, onCancel, ...props }) => { + const t = useTranslation(); + return + + + {t('Are_you_sure')} + + + + {t('Section_Delete_Warning')} + + + + + + + + ; +}; + +const SuccessModal = ({ onClose, ...props }) => { + const t = useTranslation(); + return + + + {t('Deleted')} + + + + {t('Section_Has_Been_Deleted')} + + + + + + + ; +}; + +export function EditSection({ protocolId, _id, cache, onChange, ...props }) { + const query = useMemo(() => ({ + query: JSON.stringify({ _id: protocolId }), + }), [protocolId, _id, cache]); + + const { data, state, error } = useEndpointDataExperimental('protocols.findOne', query); + + if (state === ENDPOINT_STATES.LOADING) { + return + + + + + + + + + + + + ; + } + + if (error || !data) { + return {error}; + } + + return ; +} + +function EditSectionWithData({ close, onChange, protocol, sectionId, ...props }) { + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const section = protocol.sections.find(s => s._id === sectionId); + + const { _id, num: previousNumber, name: previousName } = section || {}; + const previousSection = section || {}; + + const [number, setNumber] = useState(''); + const [name, setName] = useState(''); + const setModal = useSetModal(); + + useEffect(() => { + setNumber(previousNumber || ''); + setName(previousName || ''); + }, [previousNumber, previousName, _id]); + + const deleteSection = useMethod('deleteSection'); + const insertOrUpdateSection = useMethod('insertOrUpdateSection'); + + const hasUnsavedChanges = useMemo(() => previousNumber !== number || previousName !== name, + [number, name]); + + const saveAction = useCallback(async (number, name) => { + const sectionData = createSectionData(number, name, { previousNumber, previousName, _id }); + const validation = validateSectionData(sectionData); + if (validation.length === 0) { + const _id = await insertOrUpdateSection(protocol._id, sectionData); + } + validation.forEach((error) => { throw new Error({ type: 'error', message: t('error-the-field-is-required', { field: t(error) }) }); }); + }, [_id, dispatchToastMessage, insertOrUpdateSection, number, name, previousNumber, previousName, previousSection, t]); + + const handleSave = useCallback(async () => { + saveAction(number, name); + onChange(); + }, [saveAction, onChange]); + + const onDeleteConfirm = useCallback(async () => { + try { + await deleteSection(protocol._id, _id); + setModal(() => { setModal(undefined); close(); onChange(); }}/>); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + onChange(); + } + }, [_id, close, deleteSection, dispatchToastMessage, onChange]); + + const openConfirmDelete = () => setModal(() => setModal(undefined)}/>); + + return + + {t('Section_Number')} + + setNumber(e.currentTarget.value)} placeholder={t('Section_Number')} /> + + + + {t('Section_Name')} + + setName(e.currentTarget.value)} placeholder={t('Section_Name')} /> + + + + + + + + + + + + + + + + + + ; +} diff --git a/app/protocols/client/views/Protocol.js b/app/protocols/client/views/Protocol.js new file mode 100644 index 000000000000..31689fb11669 --- /dev/null +++ b/app/protocols/client/views/Protocol.js @@ -0,0 +1,131 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import {Box, Button, Field, Icon, Scrollable, Tabs} from '@rocket.chat/fuselage'; + +import Page from '../../../../client/components/basic/Page'; +import { useTranslation } from '../../../../client/contexts/TranslationContext'; +import { useRoute, useRouteParameter } from '../../../../client/contexts/RouterContext'; +import { useEndpointData } from '../../../../client/hooks/useEndpointData'; +import { useFormatDate } from '../../../../client/hooks/useFormatDate'; +import { useMethod } from '../../../../client/contexts/ServerContext'; +import { Sections } from './Sections'; +import VerticalBar from "/client/components/basic/VerticalBar"; +import { AddSection } from './AddSection'; +import { AddItem } from './AddItem'; +import { EditSection } from './EditSection'; +import { EditItem } from './EditItem'; + +export function ProtocolPage() { + const t = useTranslation(); + const formatDate = useFormatDate(); + + const [cache, setCache] = useState(); + + const router = useRoute('protocol'); + const protocolId = useRouteParameter('id'); + const context = useRouteParameter('context'); + const sectionId = useRouteParameter('sectionId'); + const itemId = useRouteParameter('itemId'); + + const query = useMemo(() => ({ + query: JSON.stringify({ _id: protocolId }), + }), [protocolId, cache]); + + const data = useEndpointData('protocols.findOne', query) || {}; + + const title = t('Protocol').concat(' ').concat(data.num).concat(' ').concat(t('Date_to')).concat(' ').concat(formatDate(data.d)); + + const downloadProtocolParticipantsMethod = useMethod('downloadProtocolParticipants'); + + const downloadProtocolParticipants = (_id) => async (e) => { + e.preventDefault(); + try { + const res = await downloadProtocolParticipantsMethod({ _id }); + const url = window.URL.createObjectURL(new Blob([res])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', 'file.docx'); + document.body.appendChild(link); + link.click(); + } catch (e) { + console.error('[index.js].downloadProtocolParticipants :', e); + } + }; + + const onAddSectionClick = useCallback((context) => () => { + router.push({ id: protocolId, context: context }); + }, [router]); + + const onSectionClick = useCallback((_id) => () => { + router.push({ + id: protocolId, + context: 'edit-section', + sectionId: _id, + }); + }, [router]); + + const onChange = useCallback(() => { + setCache(new Date()); + }, []); + + const close = useCallback(() => { + router.push({ + id: protocolId, + }); + }, [router]); + + const onAddItemClick = useCallback((context, sectionId) => () => { + router.push({ + id: protocolId, + context: context, + sectionId: sectionId + }); + }, [router]); + + const onItemClick = useCallback((sectionId, _id) => () => { + router.push({ + id: protocolId, + context: 'edit-item', + sectionId: sectionId, + itemId: _id + }); + }, [router]); + + return + + + + + + + + {data.place} + + + + + + + + { context + && + + { context === 'new-section' && t('Section_Add') } + { context === 'new-item' && t('Item_Add') } + { context === 'edit-section' && t('Section_Info') } + { context === 'edit-item' && t('Item_Info') } + + + {context === 'new-section' && } + {context === 'new-item' && } + {context === 'edit-section' && } + {context === 'edit-item' && } + } + ; +} + +ProtocolPage.displayName = 'ProtocolPage'; + +export default ProtocolPage; diff --git a/app/protocols/client/views/Protocols.js b/app/protocols/client/views/Protocols.js new file mode 100644 index 000000000000..cc6c3f842060 --- /dev/null +++ b/app/protocols/client/views/Protocols.js @@ -0,0 +1,74 @@ +import React, { useMemo } from 'react'; +import { Box, Button, Icon, Table } from '@rocket.chat/fuselage'; +import { useMediaQuery } from '@rocket.chat/fuselage-hooks'; + +import { useTranslation } from '../../../../client/contexts/TranslationContext'; +import { GenericTable, Th } from '../../../../client/components/GenericTable'; +import { useFormatDateAndTime } from '../../../../client/hooks/useFormatDateAndTime'; +import { useFormatDate } from '../../../../client/hooks/useFormatDate'; +import { useMethod } from '../../../../client/contexts/ServerContext'; + +export function Protocols({ + data, + sort, + onClick, + onEditClick, + onHeaderClick, + setParams, + params, +}) { + const t = useTranslation(); + + const mediaQuery = useMediaQuery('(min-width: 768px)'); + + const downloadProtocolParticipantsMethod = useMethod('downloadProtocolParticipants'); + + const downloadProtocolParticipants = (_id) => async (e) => { + e.preventDefault(); + try { + const res = await downloadProtocolParticipantsMethod({ _id }); + const url = window.URL.createObjectURL(new Blob([res])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', 'file.docx'); + document.body.appendChild(link); + link.click(); + } catch (e) { + console.error('[index.js].downloadProtocolParticipants :', e); + } + }; + + const header = useMemo(() => [ + {t('Protocol_Number')}, + {t('Protocol_Date')}, + mediaQuery && {t('Protocol_Place')}, + mediaQuery && {t('Created_at')}, + , + // + ], [sort, mediaQuery]); + + const formatDate = useFormatDate(); + const formatDateAndTime = useFormatDateAndTime(); + + const renderRow = (protocol) => { + const { _id, d: date, num, place, ts } = protocol; + return + {num} + {formatDate(date)} + { mediaQuery && {place}} + { mediaQuery && {formatDateAndTime(ts)}} + + + + {/**/} + {/* */} + {/**/} + ; + }; + + return ; +} diff --git a/app/protocols/client/views/Sections.js b/app/protocols/client/views/Sections.js new file mode 100644 index 000000000000..96fa742f8260 --- /dev/null +++ b/app/protocols/client/views/Sections.js @@ -0,0 +1,57 @@ +import React from 'react'; +import {Box, Button, Scrollable, Tile} from '@rocket.chat/fuselage'; +import { useTranslation } from '../../../../client/contexts/TranslationContext'; +import { useFormatDate } from '../../../../client/hooks/useFormatDate'; + +export function Sections({ data, onSectionClick, onAddItemClick, onItemClick }) { + const t = useTranslation(); + const formatDate = useFormatDate(); + + const Item = (item) => <> + + {item.num}. {item.name} + { item.responsible && {t('Item_Responsible')}: {item.responsible} } + { item.expireAt && {t('Item_ExpireAt')}: {formatDate(item.expireAt)} } + + ; + + const Section = (section) => <> + + {section.num}. {section.name} + + + {( + section.items + ? section.items.map((props, index) => ) + : <> + )} + + + ; + + return <> + {data && !data.length + ? + {t('No_data_found')} + + : <> + + + {( + data + ? data.map((props, index) =>
) + : <> + )} + + + + } + ; +} diff --git a/app/protocols/client/views/index.js b/app/protocols/client/views/index.js new file mode 100644 index 000000000000..034787e34209 --- /dev/null +++ b/app/protocols/client/views/index.js @@ -0,0 +1,104 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { Button, Icon } from '@rocket.chat/fuselage'; +import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; + +import Page from '../../../../client/components/basic/Page'; +import { useTranslation } from '../../../../client/contexts/TranslationContext'; +import { Protocols } from './Protocols'; +import { useRoute, useRouteParameter } from '../../../../client/contexts/RouterContext'; +import VerticalBar from '../../../../client/components/basic/VerticalBar'; +import { EditProtocol } from './EditProtocol'; +import { AddProtocol } from './AddProtocol'; +import { useEndpointData } from '../../../../client/hooks/useEndpointData'; + +const sortDir = (sortDir) => (sortDir === 'asc' ? 1 : -1); + +const useQuery = ({ text, itemsPerPage, current }, [ column, direction ], cache) => useMemo(() => ({ + // query: JSON.stringify({ desc: { $regex: text || '', $options: 'i' } }), + sort: JSON.stringify({ [column]: sortDir(direction) }), + ...itemsPerPage && { count: itemsPerPage }, + ...current && { offset: current }, + // TODO: remove cache. Is necessary for data invalidation +}), [text, itemsPerPage, current, column, direction, cache]); + +export function ProtocolsPage() { + const t = useTranslation(); + + const routeName = 'protocols'; + + const [params, setParams] = useState({ text: '', current: 0, itemsPerPage: 25 }); + const [sort, setSort] = useState(['d', 'desc']); + const [cache, setCache] = useState(); + + const debouncedParams = useDebouncedValue(params, 500); + const debouncedSort = useDebouncedValue(sort, 500); + + const query = useQuery(debouncedParams, debouncedSort, cache); + + const data = useEndpointData('protocols.list', query) || { result: [] }; + + const router = useRoute(routeName); + + const context = useRouteParameter('context'); + const id = useRouteParameter('id'); + + const onClick = (_id) => () => { + FlowRouter.go(`/protocol/${ _id }`); + }; + + const onEditClick = useCallback((_id) => () => { + router.push({ + context: 'edit', + id: _id, + }); + }, [router]); + + const onHeaderClick = (id) => { + const [sortBy, sortDirection] = sort; + + if (sortBy === id) { + setSort([id, sortDirection === 'asc' ? 'desc' : 'asc']); + return; + } + setSort([id, 'asc']); + }; + + const handleHeaderButtonClick = useCallback((context) => () => { + router.push({ context }); + }, [router]); + + const close = useCallback(() => { + router.push({}); + }, [router]); + + const onChange = useCallback(() => { + setCache(new Date()); + }, []); + + return + + + + + + + + + { context + && + + { context === 'edit' && t('Protocol_Info') } + { context === 'new' && t('Protocol_Add') } + + + {context === 'edit' && } + {context === 'new' && } + } + ; +} + +ProtocolsPage.displayName = 'ProtocolsPage'; + +export default ProtocolsPage; diff --git a/app/protocols/client/views/lib.js b/app/protocols/client/views/lib.js new file mode 100644 index 000000000000..ad1e876a8eef --- /dev/null +++ b/app/protocols/client/views/lib.js @@ -0,0 +1,88 @@ +// Here previousData will define if it is an update or a new entry +export function validate(protocolData) { + const errors = []; + + if (!protocolData.d) { + errors.push('Date'); + } + + if (!protocolData.num) { + errors.push('Number'); + } + + if (!protocolData.place) { + errors.push('Place'); + } + + return errors; +} + +export function createProtocolData(date, number, place = '', previousData) { + const protocolData = { + }; + + if (previousData) { + protocolData._id = previousData._id; + } + protocolData.d = date; + protocolData.num = number; + protocolData.place = place; + + return protocolData; +} + +export function validateSectionData(sectionData) { + const errors = []; + + if (!sectionData.num) { + errors.push('Number'); + } + + if (!sectionData.name) { + errors.push('Name'); + } + + return errors; +} + +export function createSectionData(number, name = '', previousData) { + const sectionData = { + }; + + if (previousData) { + sectionData._id = previousData._id; + } + sectionData.num = number; + sectionData.name = name; + + return sectionData; +} + +export function validateItemData(itemData) { + const errors = []; + + if (!itemData.num) { + errors.push('Number'); + } + + if (!itemData.name) { + errors.push('Name'); + } + + return errors; +} + +export function createItemData(number, name, responsible, expireAt = '', previousData) { + const itemData = { + }; + + if (previousData) { + itemData._id = previousData._id; + } + itemData.num = number; + itemData.name = name; + itemData.responsible = responsible; + itemData.expireAt = expireAt; + + return itemData; +} diff --git a/app/protocols/server/index.js b/app/protocols/server/index.js new file mode 100644 index 000000000000..3e03e78e32cb --- /dev/null +++ b/app/protocols/server/index.js @@ -0,0 +1,7 @@ +import './methods/insertOrUpdateProtocol'; +import './methods/insertOrUpdateSection'; +import './methods/insertOrUpdateItem'; +import './methods/deleteProtocol'; +import './methods/deleteSection'; +import './methods/deleteItem'; +import './methods/downloadProtocolParticipants'; diff --git a/app/protocols/server/methods/deleteItem.js b/app/protocols/server/methods/deleteItem.js new file mode 100644 index 000000000000..2598c7d250f7 --- /dev/null +++ b/app/protocols/server/methods/deleteItem.js @@ -0,0 +1,24 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Protocols } from '../../../models'; + +Meteor.methods({ + deleteItem(protocolId, sectionId, _id) { + let protocol = null; + + if (hasPermission(this.userId, 'manage-protocols')) { + protocol = Protocols.findOneById(protocolId); + } else { + throw new Meteor.Error('not_authorized'); + } + + if (protocol == null) { + throw new Meteor.Error('Protocol_Error_Invalid_Protocol', 'Invalid protocol', { method: 'deleteSection' }); + } + + Protocols.removeItemById(protocolId, sectionId, _id); + + return true; + }, +}); diff --git a/app/protocols/server/methods/deleteProtocol.js b/app/protocols/server/methods/deleteProtocol.js new file mode 100644 index 000000000000..a827bef40d45 --- /dev/null +++ b/app/protocols/server/methods/deleteProtocol.js @@ -0,0 +1,24 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Protocols } from '../../../models'; + +Meteor.methods({ + deleteProtocol(protocolId) { + let protocol = null; + + if (hasPermission(this.userId, 'manage-protocols')) { + protocol = Protocols.findOneById(protocolId); + } else { + throw new Meteor.Error('not_authorized'); + } + + if (protocol == null) { + throw new Meteor.Error('Protocol_Error_Invalid_Protocol', 'Invalid protocol', { method: 'deleteProtocol' }); + } + + Protocols.removeById(protocolId); + + return true; + }, +}); diff --git a/app/protocols/server/methods/deleteSection.js b/app/protocols/server/methods/deleteSection.js new file mode 100644 index 000000000000..5353ef0d0448 --- /dev/null +++ b/app/protocols/server/methods/deleteSection.js @@ -0,0 +1,24 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Protocols } from '../../../models'; + +Meteor.methods({ + deleteSection(protocolId, _id) { + let protocol = null; + + if (hasPermission(this.userId, 'manage-protocols')) { + protocol = Protocols.findOneById(protocolId); + } else { + throw new Meteor.Error('not_authorized'); + } + + if (protocol == null) { + throw new Meteor.Error('Protocol_Error_Invalid_Protocol', 'Invalid protocol', { method: 'deleteSection' }); + } + + Protocols.removeSectionById(protocolId, _id); + + return true; + }, +}); diff --git a/app/protocols/server/methods/downloadProtocolParticipants.js b/app/protocols/server/methods/downloadProtocolParticipants.js new file mode 100644 index 000000000000..b9955509a617 --- /dev/null +++ b/app/protocols/server/methods/downloadProtocolParticipants.js @@ -0,0 +1,223 @@ +import { Meteor } from 'meteor/meteor'; +import s from 'underscore.string'; +import { + AlignmentType, + Document, + HeadingLevel, + Packer, + PageOrientation, + Paragraph, + Table, + TableCell, + TableRow, + TextRun, VerticalAlign, WidthType, +} from 'docx'; +import moment from 'moment'; + +import { hasPermission } from '../../../authorization'; +import { Protocols } from '../../../models'; + +Meteor.methods({ + async downloadProtocolParticipants({ _id }) { + if (!hasPermission(this.userId, 'manage-protocols')) { + throw new Meteor.Error('not_authorized'); + } + + if (!_id) { + throw new Meteor.Error('error-the-field-is-required', 'The field _id is required', { method: 'downloadProtocolParticipants', field: '_id' }); + } + + const protocol = Protocols.findOne({ _id }); + + if (!protocol) { + throw new Meteor.Error('error-protocol-does-not-exists', `The protocol with _id: ${ _id } doesn't exist`, { method: 'downloadProtocolParticipants', field: '_id' }); + } + + const doc = new Document(); + + let usersRows = [ + new TableRow({ + tableHeader: true, + children: [ + new TableCell({ + children: [new Paragraph({ text: '№', bold: true, alignment: AlignmentType.CENTER })], + verticalAlign: VerticalAlign.CENTER, + width: { + size: 5, + type: WidthType.PERCENTAGE, + }, + }), + new TableCell({ + children: [new Paragraph({ text: 'Участник', bold: true, alignment: AlignmentType.CENTER })], + verticalAlign: VerticalAlign.CENTER, + width: { + size: 19, + type: WidthType.PERCENTAGE, + }, + }), + new TableCell({ + children: [new Paragraph({ text: 'Должность с указанием названия организации', bold: true, alignment: AlignmentType.CENTER })], + verticalAlign: VerticalAlign.CENTER, + width: { + size: 19, + type: WidthType.PERCENTAGE, + }, + }), + new TableCell({ + children: [new Paragraph({ text: 'Контактное лицо', bold: true, alignment: AlignmentType.CENTER })], + verticalAlign: VerticalAlign.CENTER, + width: { + size: 19, + type: WidthType.PERCENTAGE, + }, + }), + new TableCell({ + children: [new Paragraph({ text: 'Электронная почта', bold: true, alignment: AlignmentType.CENTER })], + verticalAlign: VerticalAlign.CENTER, + width: { + size: 19, + type: WidthType.PERCENTAGE, + }, + }), + new TableCell({ + children: [new Paragraph({ text: 'Номер телефона', bold: true, alignment: AlignmentType.CENTER })], + verticalAlign: VerticalAlign.CENTER, + width: { + size: 19, + type: WidthType.PERCENTAGE, + }, + }), + new TableCell({ + children: [new Paragraph({ text: 'Заявлен', bold: true, alignment: AlignmentType.CENTER })], + verticalAlign: VerticalAlign.CENTER, + width: { + size: 19, + type: WidthType.PERCENTAGE, + }, + }), + ], + }), + ]; + if (protocol.invitedUsers) { + usersRows = usersRows.concat(protocol.invitedUsers.map((value, index) => { + const contactFace = value.contactPersonFirstName ? `${ value.contactPersonLastName } ${ value.contactPersonFirstName } ${ value.contactPersonPatronymicName }`.trim() : '-'; + return new TableRow({ + children: [ + new TableCell({ + children: [new Paragraph({ text: `${ index + 1 }`, alignment: AlignmentType.CENTER })], + verticalAlign: VerticalAlign.CENTER, + alignment: AlignmentType.CENTER, + width: { + size: 5, + type: WidthType.PERCENTAGE, + }, + }), + new TableCell({ + children: [new Paragraph({ text: `${ value.lastName } ${ value.firstName } ${ value.patronymic }`.trim(), alignment: AlignmentType.CENTER })], + verticalAlign: VerticalAlign.CENTER, + alignment: AlignmentType.CENTER, + width: { + size: 19, + type: WidthType.PERCENTAGE, + }, + }), + new TableCell({ + children: [new Paragraph({ text: `${ value.position }`, alignment: AlignmentType.CENTER })], + verticalAlign: VerticalAlign.CENTER, + alignment: AlignmentType.CENTER, + width: { + size: 19, + type: WidthType.PERCENTAGE, + }, + }), + new TableCell({ + children: [new Paragraph({ text: contactFace, alignment: AlignmentType.CENTER })], + verticalAlign: VerticalAlign.CENTER, + alignment: AlignmentType.CENTER, + width: { + size: 19, + type: WidthType.PERCENTAGE, + }, + }), + new TableCell({ + children: [new Paragraph({ text: `${ value.email }`, alignment: AlignmentType.CENTER })], + verticalAlign: VerticalAlign.CENTER, + alignment: AlignmentType.CENTER, + width: { + size: 19, + type: WidthType.PERCENTAGE, + }, + }), + new TableCell({ + children: [new Paragraph({ text: `${ value.phone }`, alignment: AlignmentType.CENTER })], + verticalAlign: VerticalAlign.CENTER, + alignment: AlignmentType.CENTER, + width: { + size: 19, + type: WidthType.PERCENTAGE, + }, + }), + new TableCell({ + children: [new Paragraph({ text: `${ moment(new Date(value.ts)).format('DD MMMM YYYY, HH:mm') }`, alignment: AlignmentType.CENTER })], + verticalAlign: VerticalAlign.CENTER, + alignment: AlignmentType.CENTER, + width: { + size: 19, + type: WidthType.PERCENTAGE, + }, + }), + ], + }); + }, + ), + ); + } + + + doc.addSection({ + size: { + orientation: PageOrientation.LANDSCAPE, + }, + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: `Отчет сформирован ${ moment(new Date()).format('DD MMMM YYYY, HH:mm') }`, + }), + ], + alignment: AlignmentType.RIGHT, + }), + new Paragraph({ + children: [ + new TextRun({ + text: 'Совещание', + }), + ], + heading: HeadingLevel.HEADING_1, + alignment: AlignmentType.CENTER, + }), + new Paragraph({ + children: [ + new TextRun({ + text: `От ${ moment(protocol.d).format('DD MMMM YYYY, HH:mm') }`, + }), + ], + heading: HeadingLevel.HEADING_2, + alignment: AlignmentType.CENTER, + }), + new Table({ + rows: usersRows, + width: { + size: 100, + type: WidthType.PERCENTAGE, + }, + cantSplit: true, + }), + ], + }); + + const buffer = await Packer.toBuffer(doc); + + return buffer; + }, +}); diff --git a/app/protocols/server/methods/insertOrUpdateItem.js b/app/protocols/server/methods/insertOrUpdateItem.js new file mode 100644 index 000000000000..4c3788aaedb3 --- /dev/null +++ b/app/protocols/server/methods/insertOrUpdateItem.js @@ -0,0 +1,35 @@ +import { Meteor } from 'meteor/meteor'; +import s from 'underscore.string'; + +import { hasPermission } from '../../../authorization'; +import { Protocols } from '../../../models'; + +Meteor.methods({ + insertOrUpdateItem(protocolId, sectionId, item) { + if (!hasPermission(this.userId, 'manage-protocols')) { + throw new Meteor.Error('not_authorized'); + } + + if (!protocolId) { + throw new Meteor.Error('error-the-field-is-required', 'The field ProtocolId is required', { method: 'insertOrUpdateSection', field: 'ProtocolId' }); + } + + if (!sectionId) { + throw new Meteor.Error('error-the-field-is-required', 'The field SectionId is required', { method: 'insertOrUpdateSection', field: 'SectionId' }); + } + + if (!s.trim(item.num)) { + throw new Meteor.Error('error-the-field-is-required', 'The field Number is required', { method: 'insertOrUpdateSection', field: 'Number' }); + } + + if (!s.trim(item.name)) { + throw new Meteor.Error('error-the-field-is-required', 'The field Name is required', { method: 'insertOrUpdateSection', field: 'Name' }); + } + + if (!item._id) { + return Protocols.createItem(protocolId, sectionId, item) + } + + return Protocols.updateItem(protocolId, sectionId, item); + }, +}); diff --git a/app/protocols/server/methods/insertOrUpdateProtocol.js b/app/protocols/server/methods/insertOrUpdateProtocol.js new file mode 100644 index 000000000000..3fc60394c69d --- /dev/null +++ b/app/protocols/server/methods/insertOrUpdateProtocol.js @@ -0,0 +1,48 @@ +import { Meteor } from 'meteor/meteor'; +import s from 'underscore.string'; + +import { hasPermission } from '../../../authorization'; +import { Protocols } from '../../../models'; + +Meteor.methods({ + insertOrUpdateProtocol(protocolData) { + if (!hasPermission(this.userId, 'manage-protocols')) { + throw new Meteor.Error('not_authorized'); + } + + if (!protocolData.d) { + throw new Meteor.Error('error-the-field-is-required', 'The field Date is required', { method: 'insertOrUpdateProtocol', field: 'Date' }); + } + + if (!s.trim(protocolData.num)) { + throw new Meteor.Error('error-the-field-is-required', 'The field Number is required', { method: 'insertOrUpdateProtocol', field: 'Number' }); + } + + if (!s.trim(protocolData.place)) { + throw new Meteor.Error('error-the-field-is-required', 'The field Place is required', { method: 'insertOrUpdateProtocol', field: 'Place' }); + } + + if (!protocolData._id) { + + const user = Meteor.user(); + + const createProtocol = { + ts: new Date(), + u: { + _id: user._id, + username: user.username, + }, + d: new Date(protocolData.d), + num: protocolData.num, + place: protocolData.place, + sections: [], + }; + + const _id = Protocols.create(createProtocol); + + return _id; + } + + return Protocols.updateProtocol(protocolData._id, protocolData); + }, +}); diff --git a/app/protocols/server/methods/insertOrUpdateSection.js b/app/protocols/server/methods/insertOrUpdateSection.js new file mode 100644 index 000000000000..69baa2150f91 --- /dev/null +++ b/app/protocols/server/methods/insertOrUpdateSection.js @@ -0,0 +1,31 @@ +import { Meteor } from 'meteor/meteor'; +import s from 'underscore.string'; + +import { hasPermission } from '../../../authorization'; +import { Protocols } from '../../../models'; + +Meteor.methods({ + insertOrUpdateSection(protocolId, section) { + if (!hasPermission(this.userId, 'manage-protocols')) { + throw new Meteor.Error('not_authorized'); + } + + if (!protocolId) { + throw new Meteor.Error('error-the-field-is-required', 'The field ProtocolId is required', { method: 'insertOrUpdateSection', field: 'ProtocolId' }); + } + + if (!s.trim(section.num)) { + throw new Meteor.Error('error-the-field-is-required', 'The field Number is required', { method: 'insertOrUpdateSection', field: 'Number' }); + } + + if (!s.trim(section.name)) { + throw new Meteor.Error('error-the-field-is-required', 'The field Name is required', { method: 'insertOrUpdateSection', field: 'Name' }); + } + + if (!section._id) { + return Protocols.createSection(protocolId, section) + } + + return Protocols.updateSection(protocolId, section); + }, +}); diff --git a/app/ui/client/views/app/home.js b/app/ui/client/views/app/home.js index ce6574e6455e..a45cc8ad1619 100644 --- a/app/ui/client/views/app/home.js +++ b/app/ui/client/views/app/home.js @@ -153,7 +153,17 @@ const toolbarButtons = () => [ menu.close(); FlowRouter.go('/errands/charged_to_me'); }, - }]; + }, + { + name: t('Protocols'), + icon: 'errand', + //condition: () => hasPermission('manage-working-errand'), + action: () => { + menu.close(); + FlowRouter.go('/protocols'); + }, + } +]; Template.home.helpers({ title() { diff --git a/client/routes.js b/client/routes.js index 52071286d305..5f921924db70 100755 --- a/client/routes.js +++ b/client/routes.js @@ -200,6 +200,26 @@ FlowRouter.route('/manual-mail-sender', { }], }); +FlowRouter.route('/protocols/:context?/:id?', { + name: 'protocols', + action: () => { + renderRouteComponent(() => import('../app/protocols/client/views/index'), { template: 'main', region: 'center' }); + }, + triggersExit: [function() { + $('.main-content').addClass('rc-old'); + }], +}); + +FlowRouter.route('/protocol/:id/:context?/:sectionId?/:itemId?', { + name: 'protocol', + action: () => { + renderRouteComponent(() => import('../app/protocols/client/views/Protocol'), { template: 'main', region: 'center' }); + }, + triggersExit: [function() { + $('.main-content').addClass('rc-old'); + }], +}); + FlowRouter.route('/directory/:tab?', { name: 'directory', action: () => { diff --git a/packages/rocketchat-i18n/i18n/ru.i18n.json b/packages/rocketchat-i18n/i18n/ru.i18n.json index 1650bdc6bb4c..2c3987d6d5ba 100755 --- a/packages/rocketchat-i18n/i18n/ru.i18n.json +++ b/packages/rocketchat-i18n/i18n/ru.i18n.json @@ -24,7 +24,6 @@ "Access_not_authorized": "Неавторизованный доступ", "Access_Token_URL": "Access Token URL", "Accessing_permissions": "Права доступа", - "Account": "Аккаунт", "Account_SID": "SID учетной записи", "Accounts": "Учётные записи", "Accounts_Admin_Email_Approval_Needed_Default": "

Зарегистрирован новый пользователь [name] ([email]) .

Пожалуйста, перейдите в раздел \"Администрирование -> Пользователи\" для его активации или удаления.

", @@ -37,10 +36,7 @@ "Accounts_AllowedDomainsList_Description": "Список разрешенных доменов, разделенный запятыми ", "Accounts_AllowEmailChange": "Разрешить изменять адрес электронной почты", "Accounts_AllowEmailNotifications": "Разрешить уведомления по электронной почте", - "Accounts_AllowOrganizationChange": "Разрешить смену организации", "Accounts_AllowPasswordChange": "Разрешить смену пароля", - "Accounts_AllowPositionChange": "Разрешить смену должности", - "Accounts_AllowPhoneChange": "Разрешить смену номера телефона", "Accounts_AllowRealNameChange": "Разрешить смену имени", "Accounts_AllowUserAvatarChange": "Разрешить пользователю изменять аватар", "Accounts_AllowUsernameChange": "Разрешить изменять логин", @@ -741,20 +737,15 @@ "could-not-access-webdav": "Не удалось получить доступ к WebDAV", "Council": "Мероприятие", "Council_Add": "Добавить мероприятие", - "Council_Add_Participant": "Добавить участника", "Council_info": "Информация о мероприятии", "Council_info_description": "Вы приглашены на мероприятие. Ознакомьтесь с его деталями.", "Council_date": "Дата мероприятия", - "Council_edit": "Редактирование мероприятия", - "Council_edited": "Мероприятие отредактировано", "Council_from": "Мероприятия от", "Council_invite_error": "Приглашения на данное мероприятие не существует", "Council_Added_Successfully": "Мероприятие успешно добавлено", "Council_Delete_Warning": "Удаление мероприятия нельзя отменить", - "Council_Delete_Participant_Warning": "Удалить участника?", "Council_Error_Invalid_Council": "Некорректное мероприятие", "Council_Has_Been_Deleted": "Мероприятие успешно удалено", - "Council_Participant_Has_Been_Deleted": "Участник успешно удален", "Council_Info": "Информация о мероприятии", "Council_Invited_Users": "Участники", "Council_Invited_Users_List": "Список участников мероприятия", @@ -1168,7 +1159,6 @@ "Details": "Подробности", "Different_Style_For_User_Mentions": "Другой стиль для упоминаний пользователей", "Direct_message_someone": "Личная переписка с кем-нибудь", - "Direct_Message": "Личное сообщение", "Direct_Messages": "Личная переписка", "Direct_Reply": "Прямой ответ", "Direct_Reply_Advice": "Вы можете напрямую ответить на это письмо. Не изменяйте предыдущие сообщения в потоке.", @@ -1272,9 +1262,7 @@ "Edit_Department": "Редактировать отдел", "Edit_previous_message": "'%s' - редактировать предыдущее сообщение", "Edit_Trigger": "Изменить триггер", - "Edit_User": "Редактировать пользователя", "edited": "отредактировано", - "Editing": "Редактирование", "Editing_room": "Редактирование комнаты", "Editing_user": "Редактирование пользователя", "Education": "Образование", @@ -1496,7 +1484,6 @@ "except_pinned": "(за исключением тех, которые закреплены)", "Execute_Synchronization_Now": "Выполнить синхронизацию сейчас", "Exit_Full_Screen": "Выход из полноэкранного режима", - "Export_Messages": "Экспорт сообщений", "Export_My_Data": "Экспорт моих данных", "expression": "Выражение", "Extended": "Расширенное", @@ -1600,7 +1587,6 @@ "FileUpload_Webdav_Proxy_Uploads": "Загрузка прокси", "FileUpload_Webdav_Proxy_Uploads_Description": "Передача файлов прокси-сервера через ваш сервер вместо прямого доступа к URL-адресу актива", "files": "файлы", - "Files": "файлы", "Files_only": "Удалять только прикрепленные файлы. Сообщения останутся", "FileSize_KB": "__fileSize__ КБ", "FileSize_MB": "__fileSize__ МБ", @@ -1617,7 +1603,6 @@ "Food_and_Drink": "Еда и питьё", "Footer": "Нижний колонтитул", "Footer_Direct_Reply": "Подвал, когда прямой ответ включен", - "For_example": "Например", "For_more_details_please_check_our_docs": "Для получения дополнительной информации, пожалуйста, посмотрите нашу документацию", "For_your_security_you_must_enter_your_current_password_to_continue": "Для вашей же безопасности, вы должны повторно ввести свой пароль, чтобы продолжить", "force-delete-message": "Принудительное удаление сообщений", @@ -1645,9 +1630,7 @@ "From_me": "От меня", "From_Email": "Адрес электронной почты отправителя", "From_email_warning": "Внимание: Поле От зависит от настроек вашего почтового сервера.", - "Full_Name": "Полное имя", "Full_Screen": "Полноэкранный", - "Further": "Далее", "Gaming": "Игорный бизнес", "General": "Общие настройки", "Get_link": "Получить ссылку", @@ -1689,7 +1672,6 @@ "Group_mentions_disabled_x_members": "Групповые упоминания, для `@all` и `@here` были отключены для комнат с более чем __total__ участниками.", "Group_mentions_only": "Только при упоминаниях в общих чатах", "Hash": "Хэш", - "Head_back": "Вернуться назад", "Header": "Шапка", "Header_and_Footer": "Шапка и подвал", "Healthcare_and_Pharmaceutical": "Здравоохранение/Фармацевтика", @@ -1731,7 +1713,6 @@ "if_they_are_from": "(если они из %s)", "If_this_email_is_registered": "Если этот адрес электронной почты зарегистрирован, мы отправим на него инструкцию по сбросу пароля. Если вы не получили электронное сообщение, попробуйте снова позже.", "If_you_are_sure_type_in_your_password": "Если вы уверены, введите пароль:", - "Members": "Пользователи", "Members_List": "Пользователи", "If_you_are_sure_type_in_your_username": "Если вы уверены, введите ваш логин:", "If_you_dont_have_one_send_an_email_to_omni_rocketchat_to_get_yours": "Если у вас нет одного, отправьте электронное сообщение на адрес [omni@rocket.chat] (mailto: omni@rocket.chat), чтобы получить свой.", @@ -1792,7 +1773,6 @@ "Inclusive": "Включительно", "Incoming_Livechats": "Входящие Livechat", "Incoming_WebHook": "Входящий webhook", - "Incorrect_phone_number_input": "Неккоректный ввод номера телефона", "Industry": "Промышленность", "Info": "Информация", "initials_avatar": "Инициалы аватара", @@ -1864,21 +1844,16 @@ "InternalHubot_Username_Description": "Должно быть действительным логином бота, зарегистрированным на сервере.", "Invalid_confirm_pass": "Пароли не совпадают", "Invalid_email": "Введен некорректный адрес электронной почты", - "Invalid_login": "Логин не может быть пустым", "Invalid_username": "Введенное имя пользователя недействительно.", "Invalid_Import_File_Type": "Недействительный тип импортируемого файла.", "Invalid_name": "Имя не может быть пустым", "Invalid_notification_setting_s": "Неверная настройка уведомлений: %s", "Invalid_or_expired_invite_token": "Недействительный или истекший срок действия жетона приглашения", - "Invalid_organization": "Организация не может быть пустым", "Invalid_pass": "Пароль не может быть пустым", - "Invalid_position": "Должность не может быть пустым", "Invalid_reason": "Причина присоединения не может быть пустой", - "Invalid_Require_Name_For_Sign_Up": "Логин не может быть использован", "Invalid_room_name": "%s недопустимое имя комнаты", "Invalid_secret_URL_message": "Предоставленный URL-адрес недействителен.", "Invalid_setting_s": "Неправильная настройка: %s", - "Invalid_surname": "Фамилия не может быть пустым", "Invalid_two_factor_code": "Неверный двухфакторный код", "invisible": "невидимый", "Invisible": "Невидимый", @@ -1920,6 +1895,15 @@ "It_works": "Оно работает", "italic": "Курсивный", "italics": "курсив", + "Item_Add": "Добавить пункт", + "Item_Added_Successfully": "Пункт успешно добавлен", + "Item_Delete_Warning": "Удаление пункта не может быть отменено", + "Item_ExpireAt": "Срок", + "Item_Has_Been_Deleted": "Пункт успешно удален", + "Item_Info": "Информация о пункте", + "Item_Number": "Номер", + "Item_Name": "Наименование", + "Item_Responsible": "Ответственные", "Items_per_page": "Элементов на странице", "Mobex_sms_gateway_address": "Mobex SMS Gateway Адрес", "Mobex_sms_gateway_address_desc": "IP или хост вашего сервиса Mobex с указанным портом. Например. `http: //192.168.1.1:1401` или` https: //www.example.com:1401`", @@ -1948,7 +1932,6 @@ "Join_the_given_channel": "Присоединиться к этому каналу", "Join_video_call": "Присоединиться к видеозвонку", "Joined": "Участвую", - "Joined_at": "Присоединился", "Jump": "Перейти", "Jump_to_first_unread": "Перейти к первому непрочитанному", "Jump_to_message": "Перейти к сообщению", @@ -2000,7 +1983,7 @@ "Launched_successfully": "Успешно запущен", "Layout": "Внешний вид", "Layout_Home_Body": "Текст на главной странице", - "Layout_Home_Title": "Рабочий стол", + "Layout_Home_Title": "Заголовок на главной странице", "Layout_Login_Terms": "Правила составления имения пользователя", "Layout_Privacy_Policy": "Политика конфиденциальности", "Layout_Sidenav_Footer": "Колонтитул навигационной панели", @@ -2168,8 +2151,6 @@ "Loading_suggestion": "Загрузка предпочтений", "Local_Domains": "Локальные домены", "Local_Password": "Локальный пароль", - "Local_Time": "Локальное время", - "Local_Time_time": "Локальное время: __time__", "Localization": "Язык", "Log_Exceptions_to_Channel_Description": "Канал, который получит все захваченные исключения. Оставьте пустым, чтобы игнорировать исключения.", "Log_Exceptions_to_Channel": "Логировать исключения на канале", @@ -2422,7 +2403,6 @@ "N_new_messages": "%s новых сообщений", "Name": "Имя", "Name_cant_be_empty": "Имя не может быть пустым", - "Name_login": "Логин", "Name_of_agent": "Имя представителя", "Name_optional": "Имя (опционально)", "Name_Placeholder": "Пожалуйста, введите ваше имя...", @@ -2452,19 +2432,14 @@ "New_visitor_navigation": "Новая навигация: __history__", "Newer_than": "Новее, чем", "Newer_than_may_not_exceed_Older_than": "«Новее, чем» не может превышать «Старше, чем»", - "Nickname": "Никнейм", "No_Limit": "Без Ограничений", "No_available_agents_to_transfer": "Нет доступных сотрудников для передачи", "No_channel_with_name_%s_was_found": "Канал с названием \"%s\" не найден!", "No_channels_yet": "Вы пока не участвуете ни в одном канале.", - "No_data_found": "Данные не найдены", "No_direct_messages_yet": "Нет личных переписок.", - "No_Discussions_found": "Обсуждения не найдены", "No_emojis_found": "Не найдено Emoji", "No_Encryption": "Без шифрования", - "No_errands_yet": "Поручения не найдены", "No_files_left_to_download": "Не осталось файлов для скачивания", - "No_files_selected": "Файлы не выбраны", "No_group_with_name_%s_was_found": "Закрытый канал с названием \"%s\" не найден!", "No_groups_yet": "Вы не состоите ни в одной приватной группе.", "No_integration_found": "Не найдена интеграция, соответствующая идентификатору", @@ -2488,11 +2463,9 @@ "Normal": "Обычный", "Not_authorized": "Не авторизован", "Not_Available": "Не доступен", - "Not_chosen": "Не выбрано", "Not_found_or_not_allowed": "Не найден или владелец ограничил доступ", "Not_following": "Не наблюдаю", "Not_started": "Не начато", - "Not_verified": "Не верифицирован", "Nothing": "Ничего", "Nothing_found": "Ничего не найдено", "Not_Imported_Messages_Title": "Следующие сообщения не были успешно импортированы", @@ -2562,7 +2535,6 @@ "or": "или", "Or_talk_as_anonymous": "Или говорить как аноним", "Order": "Сортировка", - "Organization": "Организация", "Organization_Email": "Электронная почта организации", "Organization_Info": "Информация об организации", "Organization_Name": "Название организации", @@ -2646,7 +2618,6 @@ "Please_wait_while_your_profile_is_being_saved": "Пожалуйста, подождите, пока ваш профиль сохраняется...", "Pool": "Pool", "Port": "Порт", - "Position": "Должность", "post-readonly": "Сообщение только для чтения", "post-readonly_description": "Разрешение на отправление сообщений на канале только для чтения", "Post_as": "Отправить от имени", @@ -2850,7 +2821,6 @@ "Role": "Роль", "Role_Editing": "Редактировать роль", "Role_removed": "Роль удалена", - "Roles": "Роли пользователя", "Room": "Комната", "Room_announcement_changed_successfully": "Объявление комнаты успешно изменено", "Room_archivation_state": "Статус", @@ -2954,7 +2924,6 @@ "seconds": "секунды", "Secret_token": "Секретный токен", "Security": "Безопасность", - "See_full_profile": "Посмотреть полный профиль", "Select_a_department": "Выберите отдел", "Select_a_user": "Выберите пользователя", "Select_an_avatar": "Выберите аватар", @@ -3041,8 +3010,7 @@ "Show_the_keyboard_shortcut_list": "Показывать список горячих клавиш", "Showing_archived_results": "

Показано %s архивных результатов

", "Showing_online_users": "Показано: __total_showing__. Подключенных: __online__. Всего: __total__ пользователей", - "Showing_results": "Показано результатов", - "Showing_results %s - %s of %s": "Показано результатов %s - %s из %s", + "Showing_results": "

Показано %s результатов

", "Sidebar": "Боковая панель", "Sidebar_list_mode": "Режим отображения списка каналов", "Sign_in_to_start_talking": "Войдите, чтобы начать разговор", @@ -3712,11 +3680,7 @@ "Wait_you": "Ожидаем вас на заседании:", "Working_group": "Рабочая группа", "Working_group_add": "Добавить участника", - "Working_group_add_register": "Участник рабочей группы?", - "Working_group_composition": "Справочник рабочих групп", - "Working_group_composition_add": "Добавить рабочую группу", - "Working_group_composition_added_successfully": "Рабочая группа успешно добавлена", - "Working_group_composition_count": "Количество рабочих групп", + "Working_group_composition": "Состав рабочей группы", "Working_group_composition_count_members": "Количество участников рабочей группы", "Working_group_delete": "Удалить участника", "Working_group_delete_warning": "Удаление участника рабочей группы нельзя отменить", @@ -3725,7 +3689,6 @@ "Working_group_meeting": "Заседание рабочей группы", "Working_group_meeting_add": "Добавить заседание", "Working_group_meeting_count_members": "Количество участников", - "Working_group_meeting_edit": "Редактировать заседание", "Working_group_meeting_invited_users": "Участники рабочей группы", "Working_group_meeting_list": "Список заседаний", "Working_group_meeting_pinned_files": "Прикрепленные файлы", diff --git a/server/importPackages.js b/server/importPackages.js index 4224798288be..da79b8ea0234 100644 --- a/server/importPackages.js +++ b/server/importPackages.js @@ -119,3 +119,4 @@ import '../app/councils/server'; import '../app/working-group/server'; import '../app/working-group-meetings/server'; import '../app/manual-mail-sender/server'; +import '../app/protocols/server';