diff --git a/application/Espo/Classes/Select/WorkingTimeRange/PrimaryFilters/Actual.php b/application/Espo/Classes/Select/WorkingTimeRange/PrimaryFilters/Actual.php new file mode 100644 index 0000000000..a173c51e08 --- /dev/null +++ b/application/Espo/Classes/Select/WorkingTimeRange/PrimaryFilters/Actual.php @@ -0,0 +1,48 @@ +where( + Expression::greaterOrEqual( + Expression::column('dateEnd'), + Date::createToday()->getString() + ) + ); + } +} diff --git a/application/Espo/Controllers/WorkingTimeCalendar.php b/application/Espo/Controllers/WorkingTimeCalendar.php new file mode 100644 index 0000000000..08be60abcb --- /dev/null +++ b/application/Espo/Controllers/WorkingTimeCalendar.php @@ -0,0 +1,32 @@ +throwTooFewArguments(2); + } + + /** @var mixed[] $evaluatedArgs */ + $evaluatedArgs = $this->evaluate($args); + + $stringValue = $evaluatedArgs[0]; + $days = $evaluatedArgs[1]; + + if (!is_string($stringValue)) { + $this->throwBadArgumentType(1, 'string'); + } + + if (!is_int($days) && !is_float($days)) { + $this->throwBadArgumentType(2, 'int'); + } + + if (is_float($days)) { + $days = (int) $days; + } + + if ($days <= 0) { + $this->throwBadArgumentValue(2, 'Days value should be greater than 0.'); + } + + $calendar = $this->createCalendar($evaluatedArgs, 2); + + $dateTime = DateTimeOptional::fromString($stringValue); + + if ($dateTime->isAllDay()) { + $dateTime = $dateTime->withTimezone($calendar->getTimezone()); + } + + $dateTime = DateTime::fromDateTime($dateTime->getDateTime()); + + $result = $this->createCalendarUtility($calendar)->addWorkingDays($dateTime, $days); + + if (!$result) { + return null; + } + + return $result->getString(); + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/Base.php b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/Base.php new file mode 100644 index 0000000000..ae84e3c77a --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/Base.php @@ -0,0 +1,126 @@ +injectableFactory->create(CalendarFactory::class); + } + + protected function createCalendarUtility(Calendar $calendar): CalendarUtility + { + return $this->injectableFactory->createWithBinding( + CalendarUtility::class, + BindingContainerBuilder::create() + ->bindInstance(Calendar::class, $calendar) + ->build() + ); + } + + /** + * @param mixed[] $evaluatedArgs + * @throws BadArgumentType + * @throws BadArgumentValue + * @throws Error + */ + protected function createCalendar(array $evaluatedArgs, int $argumentPosition = 1): Calendar + { + $target = $this->obtainTarget($evaluatedArgs, $argumentPosition); + + if ($target instanceof User) { + return $this->getCalendarFactory()->createForUser($target); + } + + if ($target instanceof Team) { + return $this->getCalendarFactory()->createForTeam($target); + } + + return $this->getCalendarFactory()->createGlobal(); + } + + /** + * @param mixed[] $evaluatedArgs + * @throws BadArgumentType + * @throws BadArgumentValue + * @throws Error + */ + private function obtainTarget(array $evaluatedArgs, int $argumentPosition = 1): ?Entity + { + if (count($evaluatedArgs) < $argumentPosition + 2) { + return null; + } + + $entityType = $evaluatedArgs[$argumentPosition]; + $entityId = $evaluatedArgs[$argumentPosition + 1]; + + if (!is_string($entityType)) { + $this->throwBadArgumentType($argumentPosition + 1, 'string'); + } + + if (!is_string($entityId)) { + $this->throwBadArgumentType($argumentPosition + 2, 'string'); + } + + if (!in_array($entityType, [User::ENTITY_TYPE, Team::ENTITY_TYPE])) { + $this->throwBadArgumentValue($argumentPosition + 1); + } + + $entity = $this->entityManager->getEntityById($entityType, $entityId); + + if (!$entity) { + $this->throwError("Entity {$entityType} {$entityId} not found."); + } + + return $entity; + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/FindClosestWorkingTimeType.php b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/FindClosestWorkingTimeType.php new file mode 100644 index 0000000000..01eeabb21c --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/FindClosestWorkingTimeType.php @@ -0,0 +1,71 @@ +throwTooFewArguments(1); + } + + /** @var mixed[] $evaluatedArgs */ + $evaluatedArgs = $this->evaluate($args); + + $stringValue = $evaluatedArgs[0]; + + if (!is_string($stringValue)) { + $this->throwBadArgumentType(1, 'string'); + } + + $calendar = $this->createCalendar($evaluatedArgs); + + $dateTime = DateTimeOptional::fromString($stringValue); + + if ($dateTime->isAllDay()) { + $dateTime = $dateTime->withTimezone($calendar->getTimezone()); + } + + $dateTime = DateTime::fromDateTime($dateTime->getDateTime()); + + $result = $this->createCalendarUtility($calendar)->findClosestWorkingTime($dateTime); + + if (!$result) { + return null; + } + + return $result->getString(); + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/GetSummedWorkingHoursType.php b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/GetSummedWorkingHoursType.php new file mode 100644 index 0000000000..9049174cdb --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/GetSummedWorkingHoursType.php @@ -0,0 +1,76 @@ +throwTooFewArguments(2); + } + + /** @var mixed[] $evaluatedArgs */ + $evaluatedArgs = $this->evaluate($args); + + $stringValue1 = $evaluatedArgs[0]; + $stringValue2 = $evaluatedArgs[1]; + + if (!is_string($stringValue1)) { + $this->throwBadArgumentType(1, 'string'); + } + + if (!is_string($stringValue2)) { + $this->throwBadArgumentType(2, 'string'); + } + + $calendar = $this->createCalendar($evaluatedArgs, 2); + + $dateTime1 = DateTimeOptional::fromString($stringValue1); + $dateTime2 = DateTimeOptional::fromString($stringValue2); + + if ($dateTime1->isAllDay()) { + $dateTime1 = $dateTime1->withTimezone($calendar->getTimezone()); + } + + if ($dateTime2->isAllDay()) { + $dateTime2 = $dateTime2->withTimezone($calendar->getTimezone()); + } + + $dateTime1 = DateTime::fromDateTime($dateTime1->getDateTime()); + $dateTime2 = DateTime::fromDateTime($dateTime2->getDateTime()); + + return $this->createCalendarUtility($calendar)->getSummedWorkingHours($dateTime1, $dateTime2); + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/GetWorkingDaysType.php b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/GetWorkingDaysType.php new file mode 100644 index 0000000000..3faeaa1f6a --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/GetWorkingDaysType.php @@ -0,0 +1,76 @@ +throwTooFewArguments(2); + } + + /** @var mixed[] $evaluatedArgs */ + $evaluatedArgs = $this->evaluate($args); + + $stringValue1 = $evaluatedArgs[0]; + $stringValue2 = $evaluatedArgs[1]; + + if (!is_string($stringValue1)) { + $this->throwBadArgumentType(1, 'string'); + } + + if (!is_string($stringValue2)) { + $this->throwBadArgumentType(2, 'string'); + } + + $calendar = $this->createCalendar($evaluatedArgs, 2); + + $dateTime1 = DateTimeOptional::fromString($stringValue1); + $dateTime2 = DateTimeOptional::fromString($stringValue2); + + if ($dateTime1->isAllDay()) { + $dateTime1 = $dateTime1->withTimezone($calendar->getTimezone()); + } + + if ($dateTime2->isAllDay()) { + $dateTime2 = $dateTime2->withTimezone($calendar->getTimezone()); + } + + $dateTime1 = DateTime::fromDateTime($dateTime1->getDateTime()); + $dateTime2 = DateTime::fromDateTime($dateTime2->getDateTime()); + + return $this->createCalendarUtility($calendar)->getWorkingDays($dateTime1, $dateTime2); + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/HasWorkingTimeType.php b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/HasWorkingTimeType.php new file mode 100644 index 0000000000..620e791384 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/HasWorkingTimeType.php @@ -0,0 +1,76 @@ +throwTooFewArguments(2); + } + + /** @var mixed[] $evaluatedArgs */ + $evaluatedArgs = $this->evaluate($args); + + $stringValue1 = $evaluatedArgs[0]; + $stringValue2 = $evaluatedArgs[1]; + + if (!is_string($stringValue1)) { + $this->throwBadArgumentType(1, 'string'); + } + + if (!is_string($stringValue2)) { + $this->throwBadArgumentType(2, 'string'); + } + + $calendar = $this->createCalendar($evaluatedArgs, 2); + + $dateTime1 = DateTimeOptional::fromString($stringValue1); + $dateTime2 = DateTimeOptional::fromString($stringValue2); + + if ($dateTime1->isAllDay()) { + $dateTime1 = $dateTime1->withTimezone($calendar->getTimezone()); + } + + if ($dateTime2->isAllDay()) { + $dateTime2 = $dateTime2->withTimezone($calendar->getTimezone()); + } + + $dateTime1 = DateTime::fromDateTime($dateTime1->getDateTime()); + $dateTime2 = DateTime::fromDateTime($dateTime2->getDateTime()); + + return $this->createCalendarUtility($calendar)->hasWorkingTime($dateTime1, $dateTime2); + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/IsWorkingDayType.php b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/IsWorkingDayType.php new file mode 100644 index 0000000000..7047aefef3 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/IsWorkingDayType.php @@ -0,0 +1,65 @@ +throwTooFewArguments(1); + } + + /** @var mixed[] $evaluatedArgs */ + $evaluatedArgs = $this->evaluate($args); + + $stringValue = $evaluatedArgs[0]; + + if (!is_string($stringValue)) { + $this->throwBadArgumentType(1, 'string'); + } + + $calendar = $this->createCalendar($evaluatedArgs); + + $dateTime = DateTimeOptional::fromString($stringValue); + + if ($dateTime->isAllDay()) { + $dateTime = $dateTime->withTimezone($calendar->getTimezone()); + } + + $dateTime = DateTime::fromDateTime($dateTime->getDateTime()); + + return $this->createCalendarUtility($calendar)->isWorkingDay($dateTime); + } +} diff --git a/application/Espo/Entities/Team.php b/application/Espo/Entities/Team.php index ee9bb215c3..0a42eea2b7 100644 --- a/application/Espo/Entities/Team.php +++ b/application/Espo/Entities/Team.php @@ -29,7 +29,15 @@ namespace Espo\Entities; +use Espo\Core\Field\Link; + class Team extends \Espo\Core\ORM\Entity { public const ENTITY_TYPE = 'Team'; + + public function getWorkingTimeCalendar(): ?Link + { + /** @var ?Link */ + return $this->getValueObject('workingTimeCalendar'); + } } diff --git a/application/Espo/Entities/User.php b/application/Espo/Entities/User.php index 694a7c595c..27bb05fb1a 100644 --- a/application/Espo/Entities/User.php +++ b/application/Espo/Entities/User.php @@ -149,6 +149,12 @@ class User extends Person return $this->getValueObject('defaultTeam'); } + public function getWorkingTimeCalendar(): ?Link + { + /** @var ?Link */ + return $this->getValueObject('workingTimeCalendar'); + } + public function getTeams(): LinkMultiple { /** @var LinkMultiple */ diff --git a/application/Espo/Entities/WorkingTimeCalendar.php b/application/Espo/Entities/WorkingTimeCalendar.php new file mode 100644 index 0000000000..5d7fb3247e --- /dev/null +++ b/application/Espo/Entities/WorkingTimeCalendar.php @@ -0,0 +1,131 @@ +get('timeZone'); + + if (!$string) { + return null; + } + + return new DateTimeZone($string); + } + + /** + * @return TimeRange[] + */ + public function getTimeRanges(): array + { + return self::convertRanges($this->get('timeRanges')); + } + + /** + * @param int<0,6> $weekday + */ + private function hasCustomWeekdayRanges(int $weekday): bool + { + $attribute = 'weekday' . $weekday . 'TimeRanges'; + + return $this->get($attribute) !== null && $this->get($attribute) !== []; + } + + /** + * @param int<0,6> $weekday + * @return TimeRange[] + */ + private function getWeekdayTimeRanges(int $weekday): array + { + $attribute = 'weekday' . $weekday . 'TimeRanges'; + + $raw = $this->hasCustomWeekdayRanges($weekday) ? + $this->get($attribute) : + $this->get('timeRanges'); + + return self::convertRanges($raw); + } + + /** + * @return WorkingWeekday[] + */ + public function getWorkingWeekdays(): array + { + $list = []; + + for ($i = 0; $i <= 6; $i++) { + if (!$this->get('weekday' . $i)) { + continue; + } + + $list[] = new WorkingWeekday($i, $this->getWeekdayTimeRanges($i)); + } + + return $list; + } + + /** + * @param array{string,string}[] $ranges + * @return TimeRange[] + */ + private static function convertRanges(array $ranges): array + { + $list = []; + + foreach ($ranges as $range) { + $list[] = new TimeRange( + self::convertTime($range[0]), + self::convertTime($range[1]) + ); + } + + return $list; + } + + private static function convertTime(string $time): Time + { + /** @var int<0,23> $h */ + $h = (int) explode(':', $time)[0]; + /** @var int<0,59> $m */ + $m = (int) explode(':', $time)[1]; + + return new Time($h, $m); + } +} diff --git a/application/Espo/Entities/WorkingTimeRange.php b/application/Espo/Entities/WorkingTimeRange.php new file mode 100644 index 0000000000..bef08e561e --- /dev/null +++ b/application/Espo/Entities/WorkingTimeRange.php @@ -0,0 +1,123 @@ +get('type'); + + if (!$type) { + throw new RuntimeException(); + } + + return $type; + } + + public function getDateStart(): Date + { + /** @var ?Date $value */ + $value = $this->getValueObject('dateStart'); + + if (!$value) { + throw new RuntimeException(); + } + + return $value; + } + + public function getDateEnd(): Date + { + /** @var ?Date $value */ + $value = $this->getValueObject('dateEnd'); + + if (!$value) { + throw new RuntimeException(); + } + + return $value; + } + + /** + * @return ?TimeRange[] + */ + public function getTimeRanges(): ?array + { + $ranges = self::convertRanges($this->get('timeRanges') ?? []); + + if ($ranges === []) { + return null; + } + + return $ranges; + } + + /** + * @param array{string,string}[] $ranges + * @return TimeRange[] + */ + private static function convertRanges(array $ranges): array + { + $list = []; + + foreach ($ranges as $range) { + $list[] = new TimeRange( + self::convertTime($range[0]), + self::convertTime($range[1]) + ); + } + + return $list; + } + + private static function convertTime(string $time): Time + { + /** @var int<0,23> $h */ + $h = (int) explode(':', $time)[0]; + /** @var int<0,59> $m */ + $m = (int) explode(':', $time)[1]; + + return new Time($h, $m); + } +} diff --git a/application/Espo/Modules/Crm/Controllers/Activities.php b/application/Espo/Modules/Crm/Controllers/Activities.php index 245e76c919..918fd6b2af 100644 --- a/application/Espo/Modules/Crm/Controllers/Activities.php +++ b/application/Espo/Modules/Crm/Controllers/Activities.php @@ -29,53 +29,54 @@ namespace Espo\Modules\Crm\Controllers; -use Espo\Core\Exceptions\{ - Error, - Forbidden, - BadRequest, - NotFound, -}; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\NotFound; -use Espo\Core\{ - Api\Request, - Acl, - Record\SearchParamsFetcher, -}; +use Espo\Core\Api\Request; +use Espo\Core\Acl; +use Espo\Core\Field\DateTime; +use Espo\Core\Record\SearchParamsFetcher; +use Espo\Modules\Crm\Tools\Calendar\FetchParams; use Espo\Modules\Crm\Services\Activities as Service; +use Espo\Modules\Crm\Tools\Calendar\Item as CalendarItem; +use Espo\Modules\Crm\Tools\Calendar\Service as CalendarService; use Espo\Entities\User; use stdClass; +use Exception; class Activities { private const MAX_CALENDAR_RANGE = 123; private User $user; - private Acl $acl; - private SearchParamsFetcher $searchParamsFetcher; - private Service $service; + private CalendarService $calendarService; public function __construct( User $user, Acl $acl, SearchParamsFetcher $searchParamsFetcher, - Service $service + Service $service, + CalendarService $calendarService ) { $this->user = $user; $this->acl = $acl; $this->searchParamsFetcher = $searchParamsFetcher; $this->service = $service; + $this->calendarService = $calendarService; } /** - * @return array> + * @return array * @throws Forbidden * @throws BadRequest - * @throws \Exception + * @throws Exception */ public function getActionListCalendarEvents(Request $request): array { @@ -85,6 +86,7 @@ class Activities $from = $request->getQueryParam('from'); $to = $request->getQueryParam('to'); + $isAgenda = $request->getQueryParam('agenda') === 'true'; if (empty($from) || empty($to)) { throw new BadRequest(); @@ -104,29 +106,55 @@ class Activities $userIdList = $request->getQueryParam('userIdList'); $teamIdList = $request->getQueryParam('teamIdList'); + $fetchParams = FetchParams + ::create( + DateTime::fromString($from . ':00'), + DateTime::fromString($to . ':00') + ) + ->withScopeList($scopeList); + if ($teamIdList) { $teamIdList = explode(',', $teamIdList); - return $userResultList = $this->service->getTeamsEventList($teamIdList, $from, $to, $scopeList); + return self::itemListToRaw( + $this->calendarService->fetchForTeams($teamIdList, $fetchParams) + ); } if ($userIdList) { $userIdList = explode(',', $userIdList); - return $this->service->getUsersEventList($userIdList, $from, $to, $scopeList); - } - else { - if (!$userId) { - $userId = $this->user->getId(); - } + return self::itemListToRaw( + $this->calendarService->fetchForUsers($userIdList, $fetchParams) + ); } - return $this->service->getEventList($userId, $from, $to, $scopeList); + if (!$userId) { + $userId = $this->user->getId(); + } + + $fetchParams = $fetchParams + ->withIsAgenda($isAgenda) + ->withWorkingTimeRanges(); + + return self::itemListToRaw( + $this->calendarService->fetch($userId, $fetchParams) + ); + } + + /** + * @param CalendarItem[] $itemList + * @return stdClass[] + */ + private static function itemListToRaw(array $itemList): array + { + return array_map(fn (CalendarItem $item) => $item->getRaw(), $itemList); } /** * @throws BadRequest * @throws Forbidden + * @throws Exception */ public function getActionGetTimeline(Request $request): stdClass { @@ -165,7 +193,22 @@ class Activities $userIdList[] = $userId; } - return $this->service->getUsersTimeline($userIdList, $from, $to, $scopeList); + $fetchParams = FetchParams + ::create( + DateTime::fromString($from . ':00'), + DateTime::fromString($to . ':00') + ) + ->withScopeList($scopeList); + + $map = $this->calendarService->fetchTimelineForUsers($userIdList, $fetchParams); + + $result = (object) []; + + foreach ($map as $userId => $itemList) { + $result->$userId = self::itemListToRaw($itemList); + } + + return $result; } /** @@ -285,6 +328,12 @@ class Activities return (object) $this->service->getActivities($entityType, $id, $methodParams); } + /** + * @throws BadRequest + * @throws Error + * @throws Forbidden + * @throws NotFound + */ public function getActionEntityTypeList(Request $request): stdClass { $params = $request->getRouteParams(); @@ -336,6 +385,11 @@ class Activities ]; } + /** + * @throws BadRequest + * @throws Forbidden + * @throws Error + */ public function getActionBusyRanges(Request $request): stdClass { $from = $request->getQueryParam('from'); @@ -348,12 +402,20 @@ class Activities $userIdList = explode(',', $userIdListString); - return $this->service->getBusyRanges( + $map = $this->calendarService->fetchBusyRangesForUsers( $userIdList, - $from, - $to, + DateTime::fromString($from . ':00'), + DateTime::fromString($to . ':00'), $request->getQueryParam('entityType'), $request->getQueryParam('entityId') ); + + $result = (object) []; + + foreach ($map as $userId => $itemList) { + $result->$userId = self::itemListToRaw($itemList); + } + + return $result; } } diff --git a/application/Espo/Modules/Crm/Services/Activities.php b/application/Espo/Modules/Crm/Services/Activities.php index c6168890bb..5d4d4c2601 100644 --- a/application/Espo/Modules/Crm/Services/Activities.php +++ b/application/Espo/Modules/Crm/Services/Activities.php @@ -33,32 +33,26 @@ use Espo\Core\Exceptions\Error; use Espo\Core\Exceptions\NotFound; use Espo\Core\Exceptions\Forbidden; -use Espo\Core\ORM\Entity as CoreEntity; - use Espo\ORM\Query\UnionBuilder; use Espo\ORM\Query\SelectBuilder; -use Espo\ORM\{ - Entity, - Query\Select, - Query\Part\Order, -}; +use Espo\ORM\Entity; +use Espo\ORM\Query\Select; +use Espo\ORM\Query\Part\Order; -use Espo\Core\{ - Record\Collection as RecordCollection, - Select\SearchParams, - Select\Where\Item as WhereItem, - Select\Where\ConverterFactory as WhereConverterFactory, - Select\SelectBuilderFactory, - FieldProcessing\ListLoadProcessor, - FieldProcessing\Loader\Params as FieldLoaderParams, - Di, - Record\ServiceContainer as RecordServiceContainer, -}; +use Espo\Core\Acl\Table; +use Espo\Core\Record\Collection as RecordCollection; +use Espo\Core\Select\SearchParams; +use Espo\Core\Select\Where\Item as WhereItem; +use Espo\Core\Select\Where\ConverterFactory as WhereConverterFactory; +use Espo\Core\Select\SelectBuilderFactory; +use Espo\Core\FieldProcessing\ListLoadProcessor; +use Espo\Core\FieldProcessing\Loader\Params as FieldLoaderParams; +use Espo\Core\Di; +use Espo\Core\Record\ServiceContainer as RecordServiceContainer; +use Espo\Core\ORM\Entity as CoreEntity; -use Espo\{ - Entities\User as UserEntity, -}; +use Espo\Entities\User as UserEntity; use PDO; use Exception; @@ -83,19 +77,12 @@ class Activities implements use Di\UserSetter; const UPCOMING_ACTIVITIES_FUTURE_DAYS = 1; - const UPCOMING_ACTIVITIES_TASK_FUTURE_DAYS = 7; - const REMINDER_PAST_HOURS = 24; - const BUSY_RANGES_MAX_RANGE_DAYS = 10; - private WhereConverterFactory $whereConverterFactory; - private ListLoadProcessor $listLoadProcessor; - private RecordServiceContainer $recordServiceContainer; - private SelectBuilderFactory $selectBuilderFactory; public function __construct( @@ -760,17 +747,21 @@ class Activities implements ]; } + /** + * @throws Forbidden + */ protected function accessCheck(Entity $entity): void { if ($entity instanceof UserEntity) { if (!$this->acl->checkUserPermission($entity, 'user')) { throw new Forbidden(); } + + return; } - else { - if (!$this->acl->check($entity, 'read')) { - throw new Forbidden(); - } + + if (!$this->acl->check($entity, Table::ACTION_READ)) { + throw new Forbidden(); } } @@ -1048,334 +1039,6 @@ class Activities implements return $result; } - protected function getCalendarMeetingQuery(string $userId, string $from, string $to, bool $skipAcl): Select - { - $builder = $this->selectBuilderFactory - ->create() - ->from('Meeting'); - - if (!$skipAcl) { - $builder->withStrictAccessControl(); - } - - $select = [ - ['"Meeting"', 'scope'], - 'id', - 'name', - ['dateStart', 'dateStart'], - ['dateEnd', 'dateEnd'], - 'status', - ['dateStartDate', 'dateStartDate'], - ['dateEndDate', 'dateEndDate'], - 'parentType', - 'parentId', - 'createdAt', - ]; - - $seed = $this->entityManager->getNewEntity('Meeting'); - - $additionalAttributeList = $this->metadata->get( - ['app', 'calendar', 'additionalAttributeList'] - ) ?? []; - - foreach ($additionalAttributeList as $attribute) { - $select[] = $seed->hasAttribute($attribute) ? - [$attribute, $attribute] : - ['""', $attribute]; - } - - return $builder - ->buildQueryBuilder() - ->select($select) - ->leftJoin('users') - ->where([ - 'usersMiddle.userId' => $userId, - 'usersMiddle.status!=' => 'Declined', - 'OR' => [ - [ - 'dateStart>=' => $from, - 'dateStart<' => $to, - ], - [ - 'dateEnd>=' => $from, - 'dateEnd<' => $to, - ], - [ - 'dateStart<=' => $from, - 'dateEnd>=' => $to, - ], - ], - ]) - ->build(); - } - - protected function getCalendarCallQuery(string $userId, string $from, string $to, bool $skipAcl): Select - { - $builder = $this->selectBuilderFactory - ->create() - ->from('Call'); - - if (!$skipAcl) { - $builder->withStrictAccessControl(); - } - - $select = [ - ['"Call"', 'scope'], - 'id', - 'name', - ['dateStart', 'dateStart'], - ['dateEnd', 'dateEnd'], - 'status', - ['""', 'dateStartDate'], - ['""', 'dateEndDate'], - 'parentType', - 'parentId', - 'createdAt', - ]; - - $seed = $this->entityManager->getNewEntity('Call'); - - $additionalAttributeList = $this->metadata->get( - ['app', 'calendar', 'additionalAttributeList'] - ) ?? []; - - foreach ($additionalAttributeList as $attribute) { - $select[] = $seed->hasAttribute($attribute) ? - [$attribute, $attribute] : - ['""', $attribute]; - } - - return $builder - ->buildQueryBuilder() - ->select($select) - ->leftJoin('users') - ->where([ - 'usersMiddle.userId' => $userId, - 'usersMiddle.status!=' => 'Declined', - 'OR' => [ - [ - 'dateStart>=' => $from, - 'dateStart<' => $to, - ], - [ - 'dateEnd>=' => $from, - 'dateEnd<' => $to, - ], - [ - 'dateStart<=' => $from, - 'dateEnd>=' => $to, - ], - ], - ]) - ->build(); - } - - protected function getCalendarTaskQuery(string $userId, string $from, string $to, bool $skipAcl): Select - { - $builder = $this->selectBuilderFactory - ->create() - ->from('Task'); - - if (!$skipAcl) { - $builder->withStrictAccessControl(); - } - - $select = [ - ['"Task"', 'scope'], - 'id', - 'name', - ['dateStart', 'dateStart'], - ['dateEnd', 'dateEnd'], - 'status', - ['dateStartDate', 'dateStartDate'], - ['dateEndDate', 'dateEndDate'], - 'parentType', - 'parentId', - 'createdAt', - ]; - - $seed = $this->entityManager->getNewEntity('Task'); - - $additionalAttributeList = $this->metadata->get( - ['app', 'calendar', 'additionalAttributeList'] - ) ?? []; - - foreach ($additionalAttributeList as $attribute) { - $select[] = $seed->hasAttribute($attribute) ? - [$attribute, $attribute] : - ['""', $attribute]; - } - - $queryBuilder = $builder - ->buildQueryBuilder() - ->select($select) - ->where([ - 'OR' => [ - [ - 'dateEnd' => null, - 'dateStart>=' => $from, - 'dateStart<' => $to, - ], - [ - 'dateEnd>=' => $from, - 'dateEnd<' => $to, - ], - [ - 'dateEndDate!=' => null, - 'dateEndDate>=' => $from, - 'dateEndDate<' => $to, - ], - ], - ]); - - if ( - $this->metadata->get(['entityDefs', 'Task', 'fields', 'assignedUsers', 'type']) === 'linkMultiple' - && - !$this->metadata->get(['entityDefs', 'Task', 'fields', 'assignedUsers', 'disabled']) - ) { - $queryBuilder - ->distinct() - ->leftJoin('assignedUsers', 'assignedUsers') - ->where([ - 'assignedUsers.id' => $userId, - ]); - } - else { - $queryBuilder->where([ - 'assignedUserId' => $userId, - ]); - } - - return $queryBuilder->build(); - } - - protected function getCalenderBaseQuery( - string $scope, - string $userId, - string $from, - string $to, - bool $skipAcl = false - ): Select { - - $builder = $this->selectBuilderFactory - ->create() - ->from($scope); - - if (!$skipAcl) { - $builder->withStrictAccessControl(); - } - - $seed = $this->entityManager->getNewEntity($scope); - - $select = [ - ['"' . $scope . '"', 'scope'], - 'id', - 'name', - ['dateStart', 'dateStart'], - ['dateEnd', 'dateEnd'], - ($seed->hasAttribute('status') ? ['status', 'status'] : ['""', 'status']), - ($seed->hasAttribute('dateStartDate') ? ['dateStartDate', 'dateStartDate'] : ['""', 'dateStartDate']), - ($seed->hasAttribute('dateEndDate') ? ['dateEndDate', 'dateEndDate'] : ['""', 'dateEndDate']), - ($seed->hasAttribute('parentType') ? ['parentType', 'parentType'] : ['""', 'parentType']), - ($seed->hasAttribute('parentId') ? ['parentId', 'parentId'] : ['""', 'parentId']), - 'createdAt', - ]; - - $additionalAttributeList = $this->metadata->get( - ['app', 'calendar', 'additionalAttributeList'] - ) ?? []; - - foreach ($additionalAttributeList as $attribute) { - $select[] = $seed->hasAttribute($attribute) ? - [$attribute, $attribute] : - ['""', $attribute]; - } - - $orGroup = [ - 'assignedUserId' => $userId, - ]; - - if ($seed->hasRelation('users')) { - $orGroup['usersMiddle.userId'] = $userId; - } - - if ($seed->hasRelation('assignedUsers')) { - $orGroup['assignedUsersMiddle.userId'] = $userId; - } - - $queryBuilder = $builder - ->buildQueryBuilder() - ->select($select) - ->where([ - 'OR' => $orGroup, - [ - 'OR' => [ - [ - 'dateEnd' => null, - 'dateStart>=' => $from, - 'dateStart<' => $to, - ], - [ - 'dateStart>=' => $from, - 'dateStart<' => $to, - ], - [ - 'dateEnd>=' => $from, - 'dateEnd<' => $to, - ], - [ - 'dateStart<=' => $from, - 'dateEnd>=' => $to, - ], - [ - 'dateEndDate!=' => null, - 'dateEndDate>=' => $from, - 'dateEndDate<' => $to, - ], - ], - ], - ]); - - if ($seed->hasRelation('users')) { - $queryBuilder - ->distinct() - ->leftJoin('users'); - } - - if ($seed->hasRelation('assignedUsers')) { - $queryBuilder - ->distinct() - ->leftJoin('assignedUsers'); - } - - return $queryBuilder->build(); - } - - protected function getCalendarQuery( - string $scope, - string $userId, - string $from, - string $to, - bool $skipAcl = false - ): Select { - - if ($this->serviceFactory->checkExists($scope)) { - $service = $this->serviceFactory->create($scope); - - if (method_exists($service, 'getCalenderQuery')) { - return $service->getCalenderQuery($userId, $from, $to, $skipAcl); - } - } - - $methodName = 'getCalendar' . $scope . 'Query'; - - if (method_exists($this, $methodName)) { - return $this->$methodName($userId, $from, $to, $skipAcl); - } - - return $this->getCalenderBaseQuery($scope, $userId, $from, $to, $skipAcl); - } - /** * @param string[] $statusList * @return Select|Select[] @@ -1455,468 +1118,6 @@ class Activities implements return $builder->build(); } - /** - * @param string[] $userIdList - * @param ?string[] $scopeList - * @throws Exception - */ - public function getUsersTimeline( - array $userIdList, - string $from, - string $to, - ?array $scopeList = null - ): stdClass { - - $brScopeList = $this->config->get('busyRangesEntityList') ?? ['Meeting', 'Call']; - - if ($scopeList) { - foreach ($scopeList as $s) { - if (!in_array($s, $brScopeList)) { - $brScopeList[] = $s; - } - } - } - - $resultData = (object) []; - - foreach ($userIdList as $userId) { - $userData = (object) [ - 'eventList' => [], - 'busyRangeList' => [] - ]; - - try { - $userData->eventList = $this->getEventList($userId, $from, $to, $scopeList); - - $userData->busyRangeList = $this->getBusyRangeList( - $userId, - $from, - $to, - $brScopeList, - $userData->eventList - ); - } - catch (Exception $e) { - if ($e instanceof Forbidden) { - continue; - } - - throw new Exception($e->getMessage(), $e->getCode(), $e); - } - - $resultData->$userId = $userData; - } - - return $resultData; - } - - /** - * @param string[] $userIdList - * @throws Forbidden - * @throws Error - * @throws Exception - */ - public function getBusyRanges( - array $userIdList, - string $from, - string $to, - ?string $entityType = null, - ?string $ignoreId = null - ): stdClass { - - $scopeList = $this->config->get('busyRangesEntityList') ?? ['Meeting', 'Call']; - - if ($entityType) { - if (!$this->acl->check($entityType)) { - throw new Forbidden(); - } - - if (!in_array($entityType, $scopeList)) { - $scopeList[] = $entityType; - } - } - - try { - $dtFrom = new \DateTime($from); - $dtTo = new \DateTime($to); - $diff = $dtTo->diff($dtFrom, true); - - if ($diff->days > $this->config->get('busyRangesMaxRange', self::BUSY_RANGES_MAX_RANGE_DAYS)) { - return (object) []; - } - } - catch (Exception $e) { - throw new Error("BusyRanges: Bad date range."); - } - - $ignoreList = null; - - if ($entityType && $ignoreId) { - $ignoreList = [ - [ - 'id' => $ignoreId, - 'scope' => $entityType, - ] - ]; - } - - $resultData = (object) []; - - foreach ($userIdList as $userId) { - try { - $busyRangeList = $this->getBusyRangeList($userId, $from, $to, $scopeList, $ignoreList); - } - catch (Exception $e) { - if ($e instanceof Forbidden) { - continue; - } - - throw new Exception($e->getMessage(), $e->getCode(), $e); - } - - $resultData->$userId = $busyRangeList; - } - - return $resultData; - } - - /** - * @param string[] $userIdList - * @param ?string[] $scopeList - * @return array[] - * @throws Exception - */ - public function getEventsForUsers(array $userIdList, string $from, string $to, ?array $scopeList = null): array - { - return $this->getUsersEventList($userIdList, $from, $to, $scopeList); - } - - /** - * @param string[] $userIdList - * @param ?string[] $scopeList - * @return array[] - * @throws Exception - */ - public function getUsersEventList(array $userIdList, string $from, string $to, ?array $scopeList = null): array - { - $resultList = []; - - foreach ($userIdList as $userId) { - try { - $userResultList = $this->getEvents($userId, $from, $to, $scopeList); - } - catch (Exception $e) { - if ($e instanceof Forbidden) { - continue; - } - - throw new Exception($e->getMessage(), $e->getCode(), $e); - } - - foreach ($userResultList as $item) { - $item['userId'] = $userId; - - $resultList[] = $item; - } - } - - return $resultList; - } - - /** - * @param string[] $teamIdList - * @param ?string[] $scopeList - * @return array> - * @throws Forbidden - * @throws NotFound - */ - public function getEventsForTeams(array $teamIdList, string $from, string $to, ?array $scopeList = null): array - { - return $this->getTeamsEventList($teamIdList, $from, $to, $scopeList); - } - - /** - * @param string[] $teamIdList - * @param ?string[] $scopeList - * @return array> - * @throws Forbidden - * @throws NotFound - */ - public function getTeamsEventList(array $teamIdList, string $from, string $to, ?array $scopeList = null): array - { - if ($this->acl->get('userPermission') === 'no') { - throw new Forbidden("User Permission not allowing to view calendars of other users."); - } - - if ($this->acl->get('userPermission') === 'team') { - $userTeamIdList = $this->user->getLinkMultipleIdList('teams') ?? []; - - foreach ($teamIdList as $teamId) { - if (!in_array($teamId, $userTeamIdList)) { - throw new Forbidden("User Permission not allowing to view calendars of other teams."); - } - } - } - - $userIdList = []; - - $userList = $this->entityManager - ->getRDBRepository('User') - ->select(['id', 'name']) - ->leftJoin('teams') - ->where([ - 'isActive' => true, - 'teamsMiddle.teamId' => $teamIdList - ]) - ->distinct() - ->find(); - - $userNames = (object) []; - - foreach ($userList as $user) { - $userIdList[] = $user->getId(); - $userNames->{$user->getId()} = $user->get('name'); - } - - $eventList = []; - - foreach ($userIdList as $userId) { - $userEventList = $this->getEventList($userId, $from, $to, $scopeList); - - foreach ($userEventList as $event) { - foreach ($eventList as &$e) { - if ($e['scope'] == $event['scope'] && $e['id'] == $event['id']) { - $e['userIdList'][] = $userId; - - continue 2; - } - } - - $event['userIdList'] = [$userId]; - - $eventList[] = $event; - } - } - - foreach ($eventList as &$event) { - $eventUserNames = (object) []; - - foreach ($event['userIdList'] as $userId) { - $eventUserNames->$userId = $userNames->$userId; - } - - $event['userNameMap'] = $eventUserNames; - } - - return $eventList; - } - - /** - * @param ?string[] $scopeList - * @param ?array> $ignoreEventList - * @return stdClass[] - * @throws NotFound - * @throws Forbidden - */ - public function getBusyRangeList( - string $userId, - string $from, - string $to, - ?array $scopeList = null, - ?array $ignoreEventList = null - ): array { - - $rangeList = []; - - $eventListInitial = $this->getEventList($userId, $from, $to, $scopeList, true); - - $ignoreHash = (object) []; - - if ($ignoreEventList) { - foreach ($ignoreEventList as $item) { - $ignoreHash->{$item['id']} = true; - } - } - - $canceledStatusList = $this->metadata->get('app.calendar.canceledStatusList') ?? []; - - /** @var \stdClass[] $eventList */ - $eventList = []; - - foreach ($eventListInitial as $i => $item) { - $eventList[$i] = (object) $item; - } - - foreach ($eventList as $event) { - if (empty($event->dateStart) || empty($event->dateEnd)) { - continue; - } - - if (in_array($event->status ?? null, $canceledStatusList)) { - continue; - } - - if (isset($ignoreHash->{$event->id})) { - continue; - } - - try { - $start = new DateTime($event->dateStart); - $end = new DateTime($event->dateEnd); - - foreach ($rangeList as &$range) { - if ( - $start->getTimestamp() < $range->start->getTimestamp() - && - $end->getTimestamp() > $range->end->getTimestamp() - ) { - $range->dateStart = $event->dateStart; - $range->start = $start; - $range->dateEnd = $event->dateEnd; - $range->end = $end; - - continue 2; - } - - if ( - $start->getTimestamp() < $range->start->getTimestamp() - && - $end->getTimestamp() > $range->start->getTimestamp() - ) { - $range->dateStart = $event->dateStart; - $range->start = $start; - - if ($end->getTimestamp() > $range->end->getTimestamp()) { - $range->dateEnd = $event->dateEnd; - $range->end = $end; - } - - continue 2; - } - - if ( - $start->getTimestamp() < $range->end->getTimestamp() && - $end->getTimestamp() > $range->end->getTimestamp() - ) { - $range->dateEnd = $event->dateEnd; - $range->end = $end; - - if ($start->getTimestamp() < $range->start->getTimestamp()) { - $range->dateStart = $event->dateStart; - $range->start = $start; - } - - continue 2; - } - } - - $busyItem = (object) [ - 'dateStart' => $event->dateStart, - 'dateEnd' => $event->dateEnd, - 'start' => $start, - 'end' => $end, - ]; - - $rangeList[] = $busyItem; - } - catch (Exception $e) {} - } - - foreach ($rangeList as &$item) { - unset($item->start); - unset($item->end); - } - - return $rangeList; - } - - /** - * @param ?string[] $scopeList - * @return array> - * @throws NotFound - * @throws Forbidden - */ - public function getEventList( - string $userId, - string $from, - string $to, - ?array $scopeList = null, - bool $skipAcl = false - ): array { - - $user = $this->entityManager->getEntity('User', $userId); - - if (!$user) { - throw new NotFound(); - } - - $this->accessCheck($user); - - $calendarEntityList = $this->config->get('calendarEntityList', []); - - if (is_null($scopeList)) { - $scopeList = $calendarEntityList; - } - - $queryList = []; - - foreach ($scopeList as $scope) { - if (!in_array($scope, $calendarEntityList)) { - continue; - } - - if (!$this->acl->checkScope($scope)) { - continue; - } - - if (!$this->metadata->get(['scopes', $scope, 'calendar'])) { - continue; - } - - $subItem = [ - $this->getCalendarQuery($scope, $userId, $from, $to, $skipAcl) - ]; - - $queryList = array_merge($queryList, $subItem); - } - - if (empty($queryList)) { - return []; - } - - $builder = $this->entityManager->getQueryBuilder() - ->union(); - - foreach ($queryList as $query) { - $builder->query($query); - } - - $unionQuery = $builder->build(); - - $sth = $this->entityManager->getQueryExecutor()->execute($unionQuery); - - $rowList = $sth->fetchAll(PDO::FETCH_ASSOC) ?: []; - - return $rowList; - } - - /** - * @param ?string[] $scopeList - * @return array> - * @throws NotFound - * @throws Forbidden - */ - public function getEvents( - string $userId, - string $from, - string $to, - ?array $scopeList = null, - bool $skipAcl = false - ): array { - - return $this->getEventList($userId, $from, $to, $scopeList, $skipAcl); - } - public function removeReminder(string $id): void { $builder = $this->entityManager diff --git a/application/Espo/Modules/Crm/Tools/Calendar/FetchParams.php b/application/Espo/Modules/Crm/Tools/Calendar/FetchParams.php new file mode 100644 index 0000000000..b4790b4da3 --- /dev/null +++ b/application/Espo/Modules/Crm/Tools/Calendar/FetchParams.php @@ -0,0 +1,136 @@ +from = $from; + $this->to = $to; + } + + public static function create(DateTime $from, DateTime $to): self + { + return new self($from, $to); + } + + public function withIsAgenda(bool $isAgenda = true): self + { + $obj = clone $this; + $obj->isAgenda = $isAgenda; + + return $obj; + } + + public function withSkipAcl(bool $skipAcl = true): self + { + $obj = clone $this; + $obj->skipAcl = $skipAcl; + + return $obj; + } + + /** + * @param ?string[] $scopeList + */ + public function withScopeList(?array $scopeList): self + { + $obj = clone $this; + $obj->scopeList = $scopeList; + + return $obj; + } + + public function withWorkingTimeRanges(bool $workingTimeRanges = true): self + { + $obj = clone $this; + $obj->workingTimeRanges = $workingTimeRanges; + + return $obj; + } + + public function withWorkingTimeRangesInverted(bool $workingTimeRangesInverted = true): self + { + $obj = clone $this; + $obj->workingTimeRangesInverted = $workingTimeRangesInverted; + + return $obj; + } + + public function getFrom(): DateTime + { + return $this->from; + } + + public function getTo(): DateTime + { + return $this->to; + } + + public function isAgenda(): bool + { + return $this->isAgenda; + } + + public function skipAcl(): bool + { + return $this->skipAcl; + } + + /** + * @return ?string[] + */ + public function getScopeList(): ?array + { + return $this->scopeList; + } + + public function workingTimeRanges(): bool + { + return $this->workingTimeRanges; + } + + public function workingTimeRangesInverted(): bool + { + return $this->workingTimeRangesInverted; + } +} diff --git a/application/Espo/Modules/Crm/Tools/Calendar/Item.php b/application/Espo/Modules/Crm/Tools/Calendar/Item.php new file mode 100644 index 0000000000..40e41ea65b --- /dev/null +++ b/application/Espo/Modules/Crm/Tools/Calendar/Item.php @@ -0,0 +1,39 @@ +start = $start; + $this->end = $end; + } + + public function getStart(): DateTime + { + return $this->start; + } + + public function getEnd(): DateTime + { + return $this->end; + } + + public function getRaw(): stdClass + { + return (object) [ + 'dateStart' => $this->start->getString(), + 'dateEnd' => $this->end->getString(), + 'isBusyRange' => true, + ]; + } +} diff --git a/application/Espo/Modules/Crm/Tools/Calendar/Items/Event.php b/application/Espo/Modules/Crm/Tools/Calendar/Items/Event.php new file mode 100644 index 0000000000..686658f36a --- /dev/null +++ b/application/Espo/Modules/Crm/Tools/Calendar/Items/Event.php @@ -0,0 +1,160 @@ + */ + private array $attributes; + /** @var string[] */ + private array $userIdList = []; + /** @var array */ + private array $userNameMap = []; + + /** + * @param array $attributes + */ + public function __construct(?DateTime $start, ?DateTime $end, string $entityType, array $attributes) + { + $this->start = $start; + $this->end = $end; + $this->entityType = $entityType; + $this->attributes = $attributes; + } + + public function getRaw(): stdClass + { + $obj = (object) [ + 'scope' => $this->entityType, + 'dateStart' => $this->start ? $this->start->getString() : null, + 'dateEnd' => $this->end ? $this->end->getString() : null, + ]; + + if ($this->userIdList !== []) { + $obj->userIdList = $this->userIdList; + $obj->userNameMap = (object) $this->userNameMap; + } + + foreach ($this->attributes as $key => $value) { + $obj->$key = $obj->$key ?? $value; + } + + return $obj; + } + + /** + * @param mixed $value + */ + public function withAttribute(string $name, $value): self + { + $obj = clone $this; + $obj->attributes[$name] = $value; + + return $obj; + } + + public function withUserIdAdded(string $userId): self + { + $obj = clone $this; + $obj->userIdList[] = $userId; + + return $obj; + } + + /** + * @param array $userNameMap + */ + public function withUserNameMap(array $userNameMap): self + { + $obj = clone $this; + $obj->userNameMap = $userNameMap; + + return $obj; + } + + public function getId(): string + { + $id = $this->attributes['id'] ?? null; + + if (!$id) { + throw new RuntimeException(); + } + + return $id; + } + + public function getStart(): ?DateTime + { + return $this->start; + } + + public function getEnd(): ?DateTime + { + return $this->end; + } + + public function getEntityType(): string + { + return $this->entityType; + } + + /** + * @return array + */ + public function getAttributes(): array + { + return $this->attributes; + } + + /** + * @return string[] + */ + public function getUserIdList(): array + { + return $this->userIdList; + } + + /** + * @return mixed + */ + public function getAttribute(string $name) + { + return $this->attributes[$name] ?? null; + } +} diff --git a/application/Espo/Modules/Crm/Tools/Calendar/Items/NonWorkingRange.php b/application/Espo/Modules/Crm/Tools/Calendar/Items/NonWorkingRange.php new file mode 100644 index 0000000000..b1335dd63c --- /dev/null +++ b/application/Espo/Modules/Crm/Tools/Calendar/Items/NonWorkingRange.php @@ -0,0 +1,65 @@ +start = $start; + $this->end = $end; + } + + public function getStart(): DateTime + { + return $this->start; + } + + public function getEnd(): DateTime + { + return $this->end; + } + + public function getRaw(): stdClass + { + return (object) [ + 'dateStart' => $this->start->getString(), + 'dateEnd' => $this->end->getString(), + 'isNonWorkingRange' => true, + ]; + } +} diff --git a/application/Espo/Modules/Crm/Tools/Calendar/Items/WorkingRange.php b/application/Espo/Modules/Crm/Tools/Calendar/Items/WorkingRange.php new file mode 100644 index 0000000000..99280bc565 --- /dev/null +++ b/application/Espo/Modules/Crm/Tools/Calendar/Items/WorkingRange.php @@ -0,0 +1,66 @@ +start = $start; + $this->end = $end; + } + + public function getStart(): DateTime + { + return $this->start; + } + + public function getEnd(): DateTime + { + return $this->end; + } + + public function getRaw(): stdClass + { + return (object) [ + 'dateStart' => $this->start->getString(), + 'dateEnd' => $this->end->getString(), + 'isWorkingRange' => true, + ]; + } +} diff --git a/application/Espo/Modules/Crm/Tools/Calendar/Service.php b/application/Espo/Modules/Crm/Tools/Calendar/Service.php new file mode 100644 index 0000000000..b74ebc1574 --- /dev/null +++ b/application/Espo/Modules/Crm/Tools/Calendar/Service.php @@ -0,0 +1,999 @@ +entityManager = $entityManager; + $this->config = $config; + $this->workingCalendarFactory = $workingCalendarFactory; + $this->acl = $acl; + $this->metadata = $metadata; + $this->selectBuilderFactory = $selectBuilderFactory; + $this->user = $user; + $this->serviceFactory = $serviceFactory; + } + + /** + * @todo Return array of objects. + * + * @return (Event|NonWorkingRange|WorkingRange)[] + * @throws NotFound + * @throws Forbidden + */ + public function fetch(string $userId, FetchParams $fetchParams): array + { + $from = $fetchParams->getFrom()->getString(); + $to = $fetchParams->getTo()->getString(); + $scopeList = $fetchParams->getScopeList(); + $skipAcl = $fetchParams->skipAcl(); + + /** @var ?User $user */ + $user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $userId); + + if (!$user) { + throw new NotFound(); + } + + $this->accessCheck($user); + + $calendarEntityList = $this->config->get('calendarEntityList', []); + + if (is_null($scopeList)) { + $scopeList = $calendarEntityList; + } + + $workingRangeItemList = []; + + if ($fetchParams->workingTimeRanges() || $fetchParams->workingTimeRangesInverted()) { + $workingCalendar = $this->workingCalendarFactory->createForUser($user); + + $workingRangeItemList = $workingCalendar->isAvailable() ? + $this->getWorkingRangeList($workingCalendar, $fetchParams) : []; + } + + $queryList = []; + + foreach ($scopeList as $scope) { + if (!in_array($scope, $calendarEntityList)) { + continue; + } + + if (!$this->acl->checkScope($scope)) { + continue; + } + + if (!$this->metadata->get(['scopes', $scope, 'calendar'])) { + continue; + } + + $subItem = [ + $this->getCalendarQuery($scope, $userId, $from, $to, $skipAcl) + ]; + + $queryList = array_merge($queryList, $subItem); + } + + if ($queryList === []) { + return $workingRangeItemList; + } + + $builder = $this->entityManager + ->getQueryBuilder() + ->union(); + + foreach ($queryList as $query) { + $builder->query($query); + } + + $unionQuery = $builder->build(); + + $sth = $this->entityManager->getQueryExecutor()->execute($unionQuery); + + $rowList = $sth->fetchAll(PDO::FETCH_ASSOC) ?: []; + + $eventList = []; + + foreach ($rowList as $row) { + $eventList[] = new Event( + $row['dateStart'] ? DateTimeField::fromString($row['dateStart']) : null, + $row['dateEnd'] ? DateTimeField::fromString($row['dateEnd']) : null, + $row['scope'], + $row + ); + } + + return array_merge($eventList, $workingRangeItemList); + } + + /** + * @throws Forbidden + */ + private function accessCheck(Entity $entity): void + { + if ($entity instanceof User) { + if (!$this->acl->checkUserPermission($entity, 'user')) { + throw new Forbidden(); + } + + return; + } + + if (!$this->acl->check($entity, Table::ACTION_READ)) { + throw new Forbidden(); + } + } + + private function getCalendarQuery( + string $scope, + string $userId, + string $from, + string $to, + bool $skipAcl = false + ): Select { + + if ($this->serviceFactory->checkExists($scope)) { + // For backward compatibility. + $service = $this->serviceFactory->create($scope); + + if (method_exists($service, 'getCalenderQuery')) { + return $service->getCalenderQuery($userId, $from, $to, $skipAcl); + } + } + + if ($scope === Meeting::ENTITY_TYPE) { + return $this->getCalendarMeetingQuery($userId, $from, $to, $skipAcl); + } + + if ($scope === Call::ENTITY_TYPE) { + return $this->getCalendarCallQuery($userId, $from, $to, $skipAcl); + } + + if ($scope === Task::ENTITY_TYPE) { + return $this->getCalendarTaskQuery($userId, $from, $to, $skipAcl); + } + + return $this->getCalenderBaseQuery($scope, $userId, $from, $to, $skipAcl); + } + + protected function getCalenderBaseQuery( + string $scope, + string $userId, + string $from, + string $to, + bool $skipAcl = false + ): Select { + + $builder = $this->selectBuilderFactory + ->create() + ->from($scope); + + if (!$skipAcl) { + $builder->withStrictAccessControl(); + } + + $seed = $this->entityManager->getNewEntity($scope); + + $select = [ + ['"' . $scope . '"', 'scope'], + 'id', + 'name', + ['dateStart', 'dateStart'], + ['dateEnd', 'dateEnd'], + ($seed->hasAttribute('status') ? ['status', 'status'] : ['""', 'status']), + ($seed->hasAttribute('dateStartDate') ? ['dateStartDate', 'dateStartDate'] : ['""', 'dateStartDate']), + ($seed->hasAttribute('dateEndDate') ? ['dateEndDate', 'dateEndDate'] : ['""', 'dateEndDate']), + ($seed->hasAttribute('parentType') ? ['parentType', 'parentType'] : ['""', 'parentType']), + ($seed->hasAttribute('parentId') ? ['parentId', 'parentId'] : ['""', 'parentId']), + 'createdAt', + ]; + + $additionalAttributeList = $this->metadata->get( + ['app', 'calendar', 'additionalAttributeList'] + ) ?? []; + + foreach ($additionalAttributeList as $attribute) { + $select[] = $seed->hasAttribute($attribute) ? + [$attribute, $attribute] : + ['""', $attribute]; + } + + $orGroup = [ + 'assignedUserId' => $userId, + ]; + + if ($seed->hasRelation('users')) { + $orGroup['usersMiddle.userId'] = $userId; + } + + if ($seed->hasRelation('assignedUsers')) { + $orGroup['assignedUsersMiddle.userId'] = $userId; + } + + $queryBuilder = $builder + ->buildQueryBuilder() + ->select($select) + ->where([ + 'OR' => $orGroup, + [ + 'OR' => [ + [ + 'dateEnd' => null, + 'dateStart>=' => $from, + 'dateStart<' => $to, + ], + [ + 'dateStart>=' => $from, + 'dateStart<' => $to, + ], + [ + 'dateEnd>=' => $from, + 'dateEnd<' => $to, + ], + [ + 'dateStart<=' => $from, + 'dateEnd>=' => $to, + ], + [ + 'dateEndDate!=' => null, + 'dateEndDate>=' => $from, + 'dateEndDate<' => $to, + ], + ], + ], + ]); + + if ($seed->hasRelation('users')) { + $queryBuilder + ->distinct() + ->leftJoin('users'); + } + + if ($seed->hasRelation('assignedUsers')) { + $queryBuilder + ->distinct() + ->leftJoin('assignedUsers'); + } + + return $queryBuilder->build(); + } + + protected function getCalendarMeetingQuery(string $userId, string $from, string $to, bool $skipAcl): Select + { + $builder = $this->selectBuilderFactory + ->create() + ->from(Meeting::ENTITY_TYPE); + + if (!$skipAcl) { + $builder->withStrictAccessControl(); + } + + $select = [ + ['"Meeting"', 'scope'], + 'id', + 'name', + ['dateStart', 'dateStart'], + ['dateEnd', 'dateEnd'], + 'status', + ['dateStartDate', 'dateStartDate'], + ['dateEndDate', 'dateEndDate'], + 'parentType', + 'parentId', + 'createdAt', + ]; + + $seed = $this->entityManager->getNewEntity(Meeting::ENTITY_TYPE); + + $additionalAttributeList = $this->metadata->get( + ['app', 'calendar', 'additionalAttributeList'] + ) ?? []; + + foreach ($additionalAttributeList as $attribute) { + $select[] = $seed->hasAttribute($attribute) ? + [$attribute, $attribute] : + ['""', $attribute]; + } + + return $builder + ->buildQueryBuilder() + ->select($select) + ->leftJoin('users') + ->where([ + 'usersMiddle.userId' => $userId, + 'usersMiddle.status!=' => 'Declined', + 'OR' => [ + [ + 'dateStart>=' => $from, + 'dateStart<' => $to, + ], + [ + 'dateEnd>=' => $from, + 'dateEnd<' => $to, + ], + [ + 'dateStart<=' => $from, + 'dateEnd>=' => $to, + ], + ], + ]) + ->build(); + } + + protected function getCalendarCallQuery(string $userId, string $from, string $to, bool $skipAcl): Select + { + $builder = $this->selectBuilderFactory + ->create() + ->from(Call::ENTITY_TYPE); + + if (!$skipAcl) { + $builder->withStrictAccessControl(); + } + + $select = [ + ['"Call"', 'scope'], + 'id', + 'name', + ['dateStart', 'dateStart'], + ['dateEnd', 'dateEnd'], + 'status', + ['""', 'dateStartDate'], + ['""', 'dateEndDate'], + 'parentType', + 'parentId', + 'createdAt', + ]; + + $seed = $this->entityManager->getNewEntity(Call::ENTITY_TYPE); + + $additionalAttributeList = $this->metadata->get( + ['app', 'calendar', 'additionalAttributeList'] + ) ?? []; + + foreach ($additionalAttributeList as $attribute) { + $select[] = $seed->hasAttribute($attribute) ? + [$attribute, $attribute] : + ['""', $attribute]; + } + + return $builder + ->buildQueryBuilder() + ->select($select) + ->leftJoin('users') + ->where([ + 'usersMiddle.userId' => $userId, + 'usersMiddle.status!=' => 'Declined', + 'OR' => [ + [ + 'dateStart>=' => $from, + 'dateStart<' => $to, + ], + [ + 'dateEnd>=' => $from, + 'dateEnd<' => $to, + ], + [ + 'dateStart<=' => $from, + 'dateEnd>=' => $to, + ], + ], + ]) + ->build(); + } + + protected function getCalendarTaskQuery(string $userId, string $from, string $to, bool $skipAcl): Select + { + $builder = $this->selectBuilderFactory + ->create() + ->from(Task::ENTITY_TYPE); + + if (!$skipAcl) { + $builder->withStrictAccessControl(); + } + + $select = [ + ['"Task"', 'scope'], + 'id', + 'name', + ['dateStart', 'dateStart'], + ['dateEnd', 'dateEnd'], + 'status', + ['dateStartDate', 'dateStartDate'], + ['dateEndDate', 'dateEndDate'], + 'parentType', + 'parentId', + 'createdAt', + ]; + + $seed = $this->entityManager->getNewEntity(Task::ENTITY_TYPE); + + $additionalAttributeList = $this->metadata->get( + ['app', 'calendar', 'additionalAttributeList'] + ) ?? []; + + foreach ($additionalAttributeList as $attribute) { + $select[] = $seed->hasAttribute($attribute) ? + [$attribute, $attribute] : + ['""', $attribute]; + } + + $queryBuilder = $builder + ->buildQueryBuilder() + ->select($select) + ->where([ + 'OR' => [ + [ + 'dateEnd' => null, + 'dateStart>=' => $from, + 'dateStart<' => $to, + ], + [ + 'dateEnd>=' => $from, + 'dateEnd<' => $to, + ], + [ + 'dateEndDate!=' => null, + 'dateEndDate>=' => $from, + 'dateEndDate<' => $to, + ], + ], + ]); + + if ( + $this->metadata->get(['entityDefs', 'Task', 'fields', 'assignedUsers', 'type']) === 'linkMultiple' && + !$this->metadata->get(['entityDefs', 'Task', 'fields', 'assignedUsers', 'disabled']) + ) { + $queryBuilder + ->distinct() + ->leftJoin('assignedUsers', 'assignedUsers') + ->where([ + 'assignedUsers.id' => $userId, + ]); + } + else { + $queryBuilder->where([ + 'assignedUserId' => $userId, + ]); + } + + return $queryBuilder->build(); + } + + /** + * @param string[] $userIdList + * @return array + * @throws Exception + */ + public function fetchTimelineForUsers(array $userIdList, FetchParams $fetchParams): array + { + $scopeList = $fetchParams->getScopeList(); + + $brScopeList = $this->config->get('busyRangesEntityList') ?? [Meeting::ENTITY_TYPE, Call::ENTITY_TYPE]; + + if ($scopeList) { + foreach ($scopeList as $s) { + if (!in_array($s, $brScopeList)) { + $brScopeList[] = $s; + } + } + } + + $itemFetchParams = $fetchParams + ->withIsAgenda() + ->withWorkingTimeRangesInverted(); + + $resultData = []; + + foreach ($userIdList as $userId) { + try { + $eventList = $this->fetch($userId, $itemFetchParams); + + $busyRangeList = $this->fetchBusyRanges( + $userId, + $fetchParams->withScopeList($brScopeList), + array_filter($eventList, fn (Item $item) => $item instanceof Event) + ); + } + catch (Exception $e) { + if ($e instanceof Forbidden) { + continue; + } + + throw new Exception($e->getMessage(), $e->getCode(), $e); + } + + $resultData[$userId] = array_merge($eventList, $busyRangeList); + } + + return $resultData; + } + + /** + * @param string[] $teamIdList + * @return Item[] + * @throws Forbidden + * @throws NotFound + */ + public function fetchForTeams(array $teamIdList, FetchParams $fetchParams): array + { + if ($this->acl->getPermissionLevel('userPermission') === Table::LEVEL_NO) { + throw new Forbidden("User Permission not allowing to view calendars of other users."); + } + + if ($this->acl->getPermissionLevel('userPermission') === Table::LEVEL_TEAM) { + $userTeamIdList = $this->user->getLinkMultipleIdList('teams') ?? []; + + foreach ($teamIdList as $teamId) { + if (!in_array($teamId, $userTeamIdList)) { + throw new Forbidden("User Permission not allowing to view calendars of other teams."); + } + } + } + + $userIdList = []; + + $userList = $this->entityManager + ->getRDBRepository(User::ENTITY_TYPE) + ->select(['id', 'name']) + ->leftJoin('teams') + ->where([ + 'isActive' => true, + 'teamsMiddle.teamId' => $teamIdList, + ]) + ->distinct() + ->find(); + + $userNames = []; + + foreach ($userList as $user) { + $userIdList[] = $user->getId(); + $userNames[$user->getId()] = $user->getName(); + } + + /** @var Event[] $eventList */ + $eventList = []; + + foreach ($userIdList as $userId) { + $userEventList = $this->fetch($userId, $fetchParams); + + foreach ($userEventList as $event) { + if (!$event instanceof Event) { + continue; + } + + foreach ($eventList as $i => $e) { + if ( + $e->getEntityType() === $event->getEntityType() && + $e->getId() === $event->getId() + ) { + $eventList[$i] = $e->withUserIdAdded($userId); + + continue 2; + } + } + + $eventList[] = $event->withUserIdAdded($userId); + } + } + + foreach ($eventList as $i => $event) { + $eventUserNames = []; + + foreach ($event->getUserIdList() as $userId) { + $name = $userNames[$userId] ?? null; + + if ($name !== null) { + $eventUserNames[$userId] = $name; + } + } + + $eventList[$i] = $event->withUserNameMap($eventUserNames); + } + + return array_merge( + $eventList, + $this->fetchWorkingRangeListForTeams($teamIdList, $fetchParams) + ); + } + + /** + * @param string[] $teamIdList + * @param FetchParams $fetchParams + * @return NonWorkingRange[] + */ + private function fetchWorkingRangeListForTeams(array $teamIdList, FetchParams $fetchParams): array + { + $teamList = iterator_to_array( + $this->entityManager + ->getRDBRepositoryByClass(Team::class) + ->where(['id' => $teamIdList]) + ->find() + ); + + if (!count($teamList)) { + return []; + } + + $workingTimeCalendarIdList = []; + + foreach ($teamList as $team) { + $workingTimeCalendarLink = $team->getWorkingTimeCalendar(); + + $workingTimeCalendarId = $workingTimeCalendarLink ? $workingTimeCalendarLink->getId() : null; + + if ($workingTimeCalendarId) { + $workingTimeCalendarIdList[] = $workingTimeCalendarId; + } + } + + if ( + count($workingTimeCalendarIdList) !== count($teamList) || + count(array_unique($workingTimeCalendarIdList)) !== 1 + ) { + return []; + } + + $workingCalendar = $this->workingCalendarFactory->createForTeam($teamList[0]); + + if (!$workingCalendar->isAvailable()) { + return []; + } + + /** @var NonWorkingRange[] */ + return $this->getWorkingRangeList($workingCalendar, $fetchParams); + } + + /** + * @param string[] $userIdList + * @return Item[] + * @throws Exception + */ + public function fetchForUsers(array $userIdList, FetchParams $fetchParams): array + { + $itemList = []; + + foreach ($userIdList as $userId) { + try { + $userItemList = $this->fetch($userId, $fetchParams); + } + catch (Exception $e) { + if ($e instanceof Forbidden) { + continue; + } + + throw new Exception($e->getMessage(), $e->getCode(), $e); + } + + foreach ($userItemList as $event) { + if (!$event instanceof Event) { + continue; + } + + $itemList[] = $event->withAttribute('userId', $userId); + } + } + + return $itemList; + } + + /** + * @param Event[] $ignoreEventList + * @return BusyRange[] + * @throws NotFound + * @throws Forbidden + */ + public function fetchBusyRanges(string $userId, FetchParams $fetchParams, array $ignoreEventList = []): array + { + $rangeList = []; + + $eventList = $this->fetch($userId, $fetchParams->withSkipAcl(true)); + + $ignoreHash = (object) []; + + foreach ($ignoreEventList as $event) { + $id = $event->getAttribute('id'); + + if ($id) { + $ignoreHash->$id = true; + } + } + + $canceledStatusList = $this->metadata->get(['app', 'calendar', 'canceledStatusList']) ?? []; + + foreach ($eventList as $event) { + if (!$event instanceof Event) { + continue; + } + + $start = $event->getStart(); + $end = $event->getEnd(); + $status = $event->getAttribute('status'); + $id = $event->getAttribute('id'); + + if (!$start || !$end) { + continue; + } + + if (in_array($status, $canceledStatusList)) { + continue; + } + + if (isset($ignoreHash->$id)) { + continue; + } + + try { + foreach ($rangeList as &$range) { + if ( + $start->getTimestamp() < $range->start->getTimestamp() && + $end->getTimestamp() > $range->end->getTimestamp() + ) { + $range->dateStart = $start->getString(); + $range->start = $start; + $range->dateEnd = $end->getString(); + $range->end = $end; + + continue 2; + } + + if ( + $start->getTimestamp() < $range->start->getTimestamp() && + $end->getTimestamp() > $range->start->getTimestamp() + ) { + $range->dateStart = $start->getString(); + $range->start = $start; + + if ($end->getTimestamp() > $range->end->getTimestamp()) { + $range->dateEnd = $end->getString(); + $range->end = $end; + } + + continue 2; + } + + if ( + $start->getTimestamp() < $range->end->getTimestamp() && + $end->getTimestamp() > $range->end->getTimestamp() + ) { + $range->dateEnd = $end->getString(); + $range->end = $end; + + if ($start->getTimestamp() < $range->start->getTimestamp()) { + $range->dateStart = $start->getString(); + $range->start = $start; + } + + continue 2; + } + } + + $busyItem = (object) [ + 'dateStart' => $start->getString(), + 'dateEnd' => $end->getString(), + 'start' => $start, + 'end' => $end, + ]; + + $rangeList[] = $busyItem; + } + catch (Exception $e) {} + } + + return array_map( + function ($item) { + return new BusyRange( + DateTimeField::fromString($item->dateStart), + DateTimeField::fromString($item->dateEnd) + ); + }, + $rangeList + ); + } + + /** + * @return array + */ + private function getWorkingRangeList(WorkingCalendar $calendar, FetchParams $fetchParams): array + { + $from = $fetchParams->getFrom(); + $to = $fetchParams->getTo(); + + $extractor = new Extractor(); + + $itemList = $fetchParams->workingTimeRangesInverted() ? + ( + $fetchParams->isAgenda() ? + $extractor->extractInversion($calendar, $from, $to) : + $extractor->extractAllDayInversion($calendar, $from, $to) + ) : + ( + $fetchParams->isAgenda() ? + $extractor->extract($calendar, $from, $to) : + $extractor->extractAllDay($calendar, $from, $to) + ); + + $list = []; + + foreach ($itemList as $item) { + if ($fetchParams->workingTimeRangesInverted()) { + $list[] = new NonWorkingRange($item[0], $item[1]); + + continue; + } + + $list[] = new WorkingRange($item[0], $item[1]); + } + + return $list; + } + + /** + * @param string[] $userIdList + * @return array + * @throws Forbidden + * @throws Error + * @throws Exception + */ + public function fetchBusyRangesForUsers( + array $userIdList, + DateTimeField $from, + DateTimeField $to, + ?string $entityType = null, + ?string $ignoreId = null + ): array { + + $scopeList = $this->config->get('busyRangesEntityList') ?? [Meeting::ENTITY_TYPE, Call::ENTITY_TYPE]; + + if ($entityType) { + if (!$this->acl->check($entityType)) { + throw new Forbidden(); + } + + if (!in_array($entityType, $scopeList)) { + $scopeList[] = $entityType; + } + } + + try { + $diff = $to->getDateTime()->diff($from->getDateTime(), true); + + if ($diff->days > $this->config->get('busyRangesMaxRange', self::BUSY_RANGES_MAX_RANGE_DAYS)) { + return []; + } + } + catch (Exception $e) { + throw new Error("BusyRanges: Bad date range."); + } + + $ignoreList = []; + + if ($entityType && $ignoreId) { + $ignoreList[] = new Event( + DateTimeField::createNow(), + DateTimeField::createNow(), + $entityType, + ['id' => $ignoreId] + ); + } + + $result = []; + + $fetchParams = FetchParams + ::create($from, $to) + ->withScopeList($scopeList); + + foreach ($userIdList as $userId) { + $user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $userId); + + if (!$user) { + continue; + } + + $workingCalendar = $this->workingCalendarFactory->createForUser($user); + + /** @var NonWorkingRange[] $workingRangeItemList */ + $workingRangeItemList = $workingCalendar->isAvailable() ? + $this->getWorkingRangeList( + $workingCalendar, + $fetchParams + ->withWorkingTimeRangesInverted() + ->withIsAgenda() + ) : + []; + + try { + $busyRangeList = $this->fetchBusyRanges($userId, $fetchParams, $ignoreList); + } + catch (Exception $e) { + if ($e instanceof Forbidden) { + continue; + } + + throw new Exception($e->getMessage(), $e->getCode(), $e); + } + + $result[$userId] = array_merge($busyRangeList, $workingRangeItemList); + } + + return $result; + } +} diff --git a/application/Espo/Resources/i18n/en_US/Admin.json b/application/Espo/Resources/i18n/en_US/Admin.json index eb0b53e636..a57fce855e 100644 --- a/application/Espo/Resources/i18n/en_US/Admin.json +++ b/application/Espo/Resources/i18n/en_US/Admin.json @@ -75,6 +75,7 @@ "Email Addresses": "Email Addresses", "Phone Numbers": "Phone Numbers", "Layout Sets": "Layout Sets", + "Working Time Calendars": "Working Time Calendars", "Success": "Success", "Fail": "Fail", "Configuration Instructions": "Configuration Instructions", @@ -284,6 +285,7 @@ "phoneNumbers": "All phone numbers stored in the system.", "dashboardTemplates": "Deploy dashboards to users.", "layoutSets": "Collections of layouts that can be assigned to teams & portals.", + "workingTimeCalendars": "Working schedule.", "jobsSettings": "Job processing settings. Jobs execute tasks in the background.", "sms": "SMS settings.", "pdfTemplates": "Templates for printing to PDF.", diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index a81e11b1af..cb100656b1 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -55,7 +55,9 @@ "Currency": "Currency", "LayoutSet": "Layout Set", "Webhook": "Webhook", - "Mass Action": "Mass Action" + "Mass Action": "Mass Action", + "WorkingTimeCalendar": "Working Time Calendar", + "WorkingTimeRange": "Working Time Range" }, "scopeNamesPlural": { "Note": "Notes", @@ -101,7 +103,9 @@ "DashboardTemplate": "Dashboard Templates", "Currency": "Currency", "LayoutSet": "Layout Sets", - "Webhook": "Webhooks" + "Webhook": "Webhooks", + "WorkingTimeCalendar": "Working Time Calendars", + "WorkingTimeRange": "Working Time Ranges" }, "labels": { "Sort": "Sort", diff --git a/application/Espo/Resources/i18n/en_US/Settings.json b/application/Espo/Resources/i18n/en_US/Settings.json index 345b4c802a..e36c2fc4e8 100644 --- a/application/Espo/Resources/i18n/en_US/Settings.json +++ b/application/Espo/Resources/i18n/en_US/Settings.json @@ -139,7 +139,8 @@ "passwordStrengthBothCases": "Password must contain letters of both upper and lower case", "auth2FA": "Enable 2-Factor Authentication", "auth2FAForced": "Force regular users to set up 2FA", - "auth2FAMethodList": "Available 2FA methods" + "auth2FAMethodList": "Available 2FA methods", + "workingTimeCalendar": "Working Time Calendar" }, "options": { "currencyFormat": { @@ -165,6 +166,7 @@ } }, "tooltips": { + "workingTimeCalendar": "A working time calendar that will be applied to all users by default.", "displayListViewRecordCount": "A total number of records will be shown on the list view.", "currencyList": "What currencies will be available in the system.", "activitiesEntityList": "What records will be available in the Activities panel.", diff --git a/application/Espo/Resources/i18n/en_US/Team.json b/application/Espo/Resources/i18n/en_US/Team.json index 5c6ecc043f..f59609a6a2 100644 --- a/application/Espo/Resources/i18n/en_US/Team.json +++ b/application/Espo/Resources/i18n/en_US/Team.json @@ -3,6 +3,7 @@ "name": "Name", "roles": "Roles", "layoutSet": "Layout Set", + "workingTimeCalendar": "Working Time Calendar", "positionList": "Position List" }, "links": { @@ -10,9 +11,11 @@ "notes": "Notes", "roles": "Roles", "layoutSet": "Layout Set", + "workingTimeCalendar": "Working Time Calendar", "inboundEmails": "Group Email Accounts" }, "tooltips": { + "workingTimeCalendar": "A calendar will be applied to users who have this team set as a Default Team.", "layoutSet": "Provides the ability to have layouts that differ from standard ones. Layout Set will be applied to users who have this team set as Default Team.", "roles": "Access Roles. Users of this team obtain access control level from selected roles.", "positionList": "Available positions in this team. E.g. Salesperson, Manager." diff --git a/application/Espo/Resources/i18n/en_US/User.json b/application/Espo/Resources/i18n/en_US/User.json index 7027f81263..4055c739c2 100644 --- a/application/Espo/Resources/i18n/en_US/User.json +++ b/application/Espo/Resources/i18n/en_US/User.json @@ -35,6 +35,7 @@ "apiKey": "API Key", "secretKey": "Secret Key", "dashboardTemplate": "Dashboard Template", + "workingTimeCalendar": "Working Time Calendar", "auth2FA": "2FA", "authMethod": "Authentication Method", "auth2FAEnable": "Enable 2-Factor Authentication", @@ -53,7 +54,9 @@ "account": "Account (Primary)", "tasks": "Tasks", "userData": "User Data", - "dashboardTemplate": "Dashboard Template" + "dashboardTemplate": "Dashboard Template", + "workingTimeCalendar": "Working Time Calendar", + "workingTimeRanges": "Working Time Ranges" }, "labels": { "Create User": "Create User", diff --git a/application/Espo/Resources/i18n/en_US/WorkingTimeCalendar.json b/application/Espo/Resources/i18n/en_US/WorkingTimeCalendar.json new file mode 100644 index 0000000000..1ec6997472 --- /dev/null +++ b/application/Espo/Resources/i18n/en_US/WorkingTimeCalendar.json @@ -0,0 +1,27 @@ +{ + "labels": { + "Create WorkingTimeCalendar": "Create Calendar", + "Ranges": "Ranges" + }, + "fields": { + "timeZone": "Time Zone", + "timeRanges": "Workday Schedule", + "weekday0": "Sun", + "weekday1": "Mon", + "weekday2": "Tue", + "weekday3": "Wed", + "weekday4": "Thu", + "weekday5": "Fri", + "weekday6": "Sat", + "weekday0TimeRanges": "Sun Schedule", + "weekday1TimeRanges": "Mon Schedule", + "weekday2TimeRanges": "Tue Schedule", + "weekday3TimeRanges": "Wed Schedule", + "weekday4TimeRanges": "Thu Schedule", + "weekday5TimeRanges": "Fri Schedule", + "weekday6TimeRanges": "Sat Schedule" + }, + "links": { + "ranges": "Ranges" + } +} diff --git a/application/Espo/Resources/i18n/en_US/WorkingTimeRange.json b/application/Espo/Resources/i18n/en_US/WorkingTimeRange.json new file mode 100644 index 0000000000..3283ff6d9b --- /dev/null +++ b/application/Espo/Resources/i18n/en_US/WorkingTimeRange.json @@ -0,0 +1,27 @@ +{ + "labels": { + "Create WorkingTimeRange": "Create Range", + "Calendars": "Calendars" + }, + "fields": { + "timeRanges": "Schedule", + "dateStart": "Date Start", + "dateEnd": "Date End", + "type": "Type", + "calendars": "Calendars", + "users": "Users" + }, + "links": { + "calendars": "Calendars", + "users": "Users" + }, + "options": { + "type": { + "Non-working": "Non-working", + "Working": "Working" + } + }, + "presetFilters": { + "actual": "Actual" + } +} diff --git a/application/Espo/Resources/layouts/Settings/settings.json b/application/Espo/Resources/layouts/Settings/settings.json index 0aef17d9c8..861fb49382 100644 --- a/application/Espo/Resources/layouts/Settings/settings.json +++ b/application/Espo/Resources/layouts/Settings/settings.json @@ -38,7 +38,8 @@ "label": "Activities", "rows": [ [{"name": "calendarEntityList"}, {"name": "activitiesEntityList"}], - [{"name": "busyRangesEntityList"}, {"name": "historyEntityList"}] + [{"name": "busyRangesEntityList"}, {"name": "historyEntityList"}], + [{"name": "workingTimeCalendar"}, false] ] }, { diff --git a/application/Espo/Resources/layouts/Team/detail.json b/application/Espo/Resources/layouts/Team/detail.json index f8888defaf..13baf9a6bc 100644 --- a/application/Espo/Resources/layouts/Team/detail.json +++ b/application/Espo/Resources/layouts/Team/detail.json @@ -11,7 +11,7 @@ ], [ {"name": "layoutSet"}, - false + {"name": "workingTimeCalendar"} ] ] } diff --git a/application/Espo/Resources/layouts/WorkingTimeCalendar/defaultSidePanel.json b/application/Espo/Resources/layouts/WorkingTimeCalendar/defaultSidePanel.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/application/Espo/Resources/layouts/WorkingTimeCalendar/defaultSidePanel.json @@ -0,0 +1 @@ +[] diff --git a/application/Espo/Resources/layouts/WorkingTimeCalendar/detail.json b/application/Espo/Resources/layouts/WorkingTimeCalendar/detail.json new file mode 100644 index 0000000000..60b993f0ba --- /dev/null +++ b/application/Espo/Resources/layouts/WorkingTimeCalendar/detail.json @@ -0,0 +1,28 @@ +[ + { + "rows": [ + [{"name": "name"}, false] + ] + }, + { + "rows": [ + [{"name": "timeRanges"}, {"name": "timeZone"}] + ] + }, + { + "rows": [ + [{"name": "weekday1"}, {"name": "weekday1TimeRanges"}], + [{"name": "weekday2"}, {"name": "weekday2TimeRanges"}], + [{"name": "weekday3"}, {"name": "weekday3TimeRanges"}], + [{"name": "weekday4"}, {"name": "weekday4TimeRanges"}], + [{"name": "weekday5"}, {"name": "weekday5TimeRanges"}], + [{"name": "weekday6"}, {"name": "weekday6TimeRanges"}], + [{"name": "weekday0"}, {"name": "weekday0TimeRanges"}] + ] + }, + { + "rows": [ + [{"name": "description"}] + ] + } +] diff --git a/application/Espo/Resources/layouts/WorkingTimeCalendar/detailSmall.json b/application/Espo/Resources/layouts/WorkingTimeCalendar/detailSmall.json new file mode 100644 index 0000000000..f79ce6c67c --- /dev/null +++ b/application/Espo/Resources/layouts/WorkingTimeCalendar/detailSmall.json @@ -0,0 +1,17 @@ +[ + { + "rows": [ + [{"name": "name"}] + ] + }, + { + "rows": [ + [{"name": "timeRanges"}, {"name": "timeZone"}] + ] + }, + { + "rows": [ + [{"name": "description"}] + ] + } +] diff --git a/application/Espo/Resources/layouts/WorkingTimeCalendar/list.json b/application/Espo/Resources/layouts/WorkingTimeCalendar/list.json new file mode 100644 index 0000000000..ac1822864f --- /dev/null +++ b/application/Espo/Resources/layouts/WorkingTimeCalendar/list.json @@ -0,0 +1,6 @@ +[ + { + "name": "name", + "link": true + } +] diff --git a/application/Espo/Resources/layouts/WorkingTimeCalendar/relationships.json b/application/Espo/Resources/layouts/WorkingTimeCalendar/relationships.json new file mode 100644 index 0000000000..4cbbe81f7c --- /dev/null +++ b/application/Espo/Resources/layouts/WorkingTimeCalendar/relationships.json @@ -0,0 +1,3 @@ +[ + "ranges" +] diff --git a/application/Espo/Resources/layouts/WorkingTimeRange/detail.json b/application/Espo/Resources/layouts/WorkingTimeRange/detail.json new file mode 100644 index 0000000000..f74a975089 --- /dev/null +++ b/application/Espo/Resources/layouts/WorkingTimeRange/detail.json @@ -0,0 +1,25 @@ +[ + { + "rows": [ + [ + {"name": "type"}, + {"name": "name"} + ], + [ + {"name": "dateStart"}, + {"name": "dateEnd"} + ], + [ + {"name": "timeRanges"}, + {"name": "calendars"} + ], + [ + false, + {"name": "users"} + ], + [ + {"name": "description"} + ] + ] + } +] diff --git a/application/Espo/Resources/layouts/WorkingTimeRange/detailSmall.json b/application/Espo/Resources/layouts/WorkingTimeRange/detailSmall.json new file mode 100644 index 0000000000..5b35c0348a --- /dev/null +++ b/application/Espo/Resources/layouts/WorkingTimeRange/detailSmall.json @@ -0,0 +1,23 @@ +[ + { + "rows": [ + [ + {"name": "type"}, + {"name": "name"} + ], + [ + {"name": "dateStart"}, + {"name": "dateEnd"} + ], + [ + {"name": "timeRanges"} + ], + [ + {"name": "calendars"} + ], + [ + {"name": "description"} + ] + ] + } +] diff --git a/application/Espo/Resources/layouts/WorkingTimeRange/filters.json b/application/Espo/Resources/layouts/WorkingTimeRange/filters.json new file mode 100644 index 0000000000..62db9f4e62 --- /dev/null +++ b/application/Espo/Resources/layouts/WorkingTimeRange/filters.json @@ -0,0 +1,8 @@ +[ + "type", + "dateStart", + "dateEnd", + "users", + "calendars", + "createdAt" +] diff --git a/application/Espo/Resources/layouts/WorkingTimeRange/list.json b/application/Espo/Resources/layouts/WorkingTimeRange/list.json new file mode 100644 index 0000000000..752b41d658 --- /dev/null +++ b/application/Espo/Resources/layouts/WorkingTimeRange/list.json @@ -0,0 +1,28 @@ +[ + { + "name": "type", + "width": 16, + "link": true + }, + { + "name": "dateStart", + "width": 14 + }, + { + "name": "dateEnd", + "width": 14 + }, + { + "name": "name" + }, + { + "name": "calendars", + "notSortable": true, + "width": 14 + }, + { + "name": "users", + "notSortable": true, + "width": 14 + } +] diff --git a/application/Espo/Resources/layouts/WorkingTimeRange/listSmall.json b/application/Espo/Resources/layouts/WorkingTimeRange/listSmall.json new file mode 100644 index 0000000000..995cb7d8d6 --- /dev/null +++ b/application/Espo/Resources/layouts/WorkingTimeRange/listSmall.json @@ -0,0 +1,18 @@ +[ + { + "name": "type", + "width": 25, + "link": true + }, + { + "name": "dateStart", + "width": 20 + }, + { + "name": "dateEnd", + "width": 20 + }, + { + "name": "name" + } +] diff --git a/application/Espo/Resources/metadata/app/acl.json b/application/Espo/Resources/metadata/app/acl.json index 46afdd2f83..50e550f6ed 100644 --- a/application/Espo/Resources/metadata/app/acl.json +++ b/application/Espo/Resources/metadata/app/acl.json @@ -54,7 +54,8 @@ }, "Role": false, "PortalRole": false, - "ImportError": "Import" + "ImportError": "Import", + "WorkingTimeRange": "WorkingTimeCalendar" }, "fieldLevel": { }, @@ -77,6 +78,7 @@ }, "User": { "dashboardTemplate": false, + "workingTimeCalendar": false, "password": false, "passwordConfirm": false, "auth2FA": false, diff --git a/application/Espo/Resources/metadata/app/aclPortal.json b/application/Espo/Resources/metadata/app/aclPortal.json index 40f80abb89..c866fbcbbf 100644 --- a/application/Espo/Resources/metadata/app/aclPortal.json +++ b/application/Espo/Resources/metadata/app/aclPortal.json @@ -89,6 +89,7 @@ }, "User": { "dashboardTemplate": false, + "workingTimeCalendar": false, "password": false, "authMethod": false, "apiKey": false, diff --git a/application/Espo/Resources/metadata/app/adminPanel.json b/application/Espo/Resources/metadata/app/adminPanel.json index 55d0db366c..c375e74a31 100644 --- a/application/Espo/Resources/metadata/app/adminPanel.json +++ b/application/Espo/Resources/metadata/app/adminPanel.json @@ -286,6 +286,12 @@ "description": "layoutSets" }, + { + "url": "#Admin/workingTimeCalendar", + "label": "Working Time Calendars", + "iconClass": "far fa-calendar-alt", + "description": "workingTimeCalendars" + }, { "url": "#Admin/attachments", "label": "Attachments", diff --git a/application/Espo/Resources/metadata/app/config.json b/application/Espo/Resources/metadata/app/config.json index 73a361c5f5..a709655c08 100644 --- a/application/Espo/Resources/metadata/app/config.json +++ b/application/Espo/Resources/metadata/app/config.json @@ -18,6 +18,9 @@ }, "smsProvider": { "level": "admin" + }, + "workingTimeCalendar": { + "level": "admin" } } } diff --git a/application/Espo/Resources/metadata/app/formula.json b/application/Espo/Resources/metadata/app/formula.json index 8b7ad38943..7b971a8c3c 100644 --- a/application/Espo/Resources/metadata/app/formula.json +++ b/application/Espo/Resources/metadata/app/formula.json @@ -457,6 +457,36 @@ "insertText": "ext\\pdf\\generate(ENTITY_TYPE, ENTITY_ID, TEMPLATE_ID, FILENAME)", "returnType": "string" }, + { + "name": "ext\\workingCalendar\\addWorkingDays", + "insertText": "ext\\workingCalendar\\addWorkingDays(DATE, DAYS)", + "returnType": "string|null" + }, + { + "name": "ext\\workingCalendar\\findClosestWorkingTime", + "insertText": "ext\\workingCalendar\\findClosestWorkingTime(DATE)", + "returnType": "string|null" + }, + { + "name": "ext\\workingCalendar\\getSummedWorkingHours", + "insertText": "ext\\workingCalendar\\getSummedWorkingHours(FROM, TO)", + "returnType": "float" + }, + { + "name": "ext\\workingCalendar\\getWorkingDays", + "insertText": "ext\\workingCalendar\\getWorkingDays(FROM, TO)", + "returnType": "int" + }, + { + "name": "ext\\workingCalendar\\hasWorkingTime", + "insertText": "ext\\workingCalendar\\hasWorkingTime(FROM, TO)", + "returnType": "bool" + }, + { + "name": "ext\\workingCalendar\\isWorkingDay", + "insertText": "ext\\workingCalendar\\isWorkingDay(DATE)", + "returnType": "bool" + }, { "name": "ext\\user\\sendAccessInfo", "insertText": "ext\\user\\sendAccessInfo(USER_ID)" diff --git a/application/Espo/Resources/metadata/clientDefs/WorkingTimeCalendar.json b/application/Espo/Resources/metadata/clientDefs/WorkingTimeCalendar.json new file mode 100644 index 0000000000..f13a6eea8b --- /dev/null +++ b/application/Espo/Resources/metadata/clientDefs/WorkingTimeCalendar.json @@ -0,0 +1,99 @@ +{ + "controller": "controllers/record", + "searchPanelDisabled": true, + "menu": { + "list": { + "buttons": [ + { + "name": "ranges", + "label": "Ranges", + "link": "#WorkingTimeRange" + } + ] + } + }, + "dynamicLogic": { + "fields": { + "weekday0TimeRanges": { + "visible": { + "conditionGroup": [ + { + "type": "isTrue", + "attribute": "weekday0" + } + ] + } + }, + "weekday1TimeRanges": { + "visible": { + "conditionGroup": [ + { + "type": "isTrue", + "attribute": "weekday1" + } + ] + } + }, + "weekday2TimeRanges": { + "visible": { + "conditionGroup": [ + { + "type": "isTrue", + "attribute": "weekday2" + } + ] + } + }, + "weekday3TimeRanges": { + "visible": { + "conditionGroup": [ + { + "type": "isTrue", + "attribute": "weekday3" + } + ] + } + }, + "weekday4TimeRanges": { + "visible": { + "conditionGroup": [ + { + "type": "isTrue", + "attribute": "weekday4" + } + ] + } + }, + "weekday5TimeRanges": { + "visible": { + "conditionGroup": [ + { + "type": "isTrue", + "attribute": "weekday5" + } + ] + } + }, + "weekday6TimeRanges": { + "visible": { + "conditionGroup": [ + { + "type": "isTrue", + "attribute": "weekday6" + } + ] + } + }, + "teams": { + "visible": { + "conditionGroup": [ + { + "type": "isNotEmpty", + "attribute": "teamsIds" + } + ] + } + } + } + } +} diff --git a/application/Espo/Resources/metadata/clientDefs/WorkingTimeRange.json b/application/Espo/Resources/metadata/clientDefs/WorkingTimeRange.json new file mode 100644 index 0000000000..1e34e716ae --- /dev/null +++ b/application/Espo/Resources/metadata/clientDefs/WorkingTimeRange.json @@ -0,0 +1,35 @@ +{ + "controller": "controllers/record", + "viewSetupHandlers": { + "record/edit": "handlers/working-time-range" + }, + "menu": { + "list": { + "buttons": [ + { + "name": "calendars", + "label": "Calendars", + "link": "#WorkingTimeCalendar" + } + ] + } + }, + "dynamicLogic": { + "fields": { + "timeRanges": { + "visible": { + "conditionGroup": [ + { + "type": "equals", + "attribute": "type", + "value": "Working" + } + ] + } + } + } + }, + "filterList": [ + "actual" + ] +} diff --git a/application/Espo/Resources/metadata/entityAcl/User.json b/application/Espo/Resources/metadata/entityAcl/User.json index fb01fc9fed..ed394972b3 100644 --- a/application/Espo/Resources/metadata/entityAcl/User.json +++ b/application/Espo/Resources/metadata/entityAcl/User.json @@ -47,6 +47,9 @@ "contact": { "nonAdminReadOnly": true }, + "workingTimeCalendar": { + "onlyAdmin": true + }, "accounts": { "nonAdminReadOnly": true }, @@ -67,6 +70,9 @@ "roles": { "onlyAdmin": true }, + "workingTimeRanges": { + "nonAdminReadOnly": true + }, "portalRoles": { "onlyAdmin": true }, diff --git a/application/Espo/Resources/metadata/entityDefs/Settings.json b/application/Espo/Resources/metadata/entityDefs/Settings.json index befb468142..d865ac00b5 100644 --- a/application/Espo/Resources/metadata/entityDefs/Settings.json +++ b/application/Espo/Resources/metadata/entityDefs/Settings.json @@ -750,6 +750,11 @@ "smsProvider": { "type": "enum", "view": "views/settings/fields/sms-provider" + }, + "workingTimeCalendar": { + "type": "link", + "tooltip": true, + "entity": "WorkingTimeCalendar" } } } diff --git a/application/Espo/Resources/metadata/entityDefs/Team.json b/application/Espo/Resources/metadata/entityDefs/Team.json index 430ae9fc82..76848775ee 100644 --- a/application/Espo/Resources/metadata/entityDefs/Team.json +++ b/application/Espo/Resources/metadata/entityDefs/Team.json @@ -24,6 +24,10 @@ "type": "link", "tooltip": true }, + "workingTimeCalendar": { + "type": "link", + "tooltip": true + }, "createdAt": { "type": "datetime", "readOnly": true @@ -61,6 +65,11 @@ "type": "belongsTo", "entity": "LayoutSet", "foreign": "teams" + }, + "workingTimeCalendar": { + "type": "belongsTo", + "entity": "WorkingTimeCalendar", + "foreign": "teams" } }, "collection": { diff --git a/application/Espo/Resources/metadata/entityDefs/User.json b/application/Espo/Resources/metadata/entityDefs/User.json index ed6683b512..8d8b6043e3 100644 --- a/application/Espo/Resources/metadata/entityDefs/User.json +++ b/application/Espo/Resources/metadata/entityDefs/User.json @@ -359,6 +359,12 @@ "layoutListDisabled": true, "customizationAuditedDisabled": true }, + "workingTimeCalendar": { + "type": "link", + "layoutDetailDisabled": true, + "layoutListDisabled": true, + "customizationAuditedDisabled": true + }, "auth2FA": { "type": "foreign", "link": "userData", @@ -429,6 +435,15 @@ "type": "belongsTo", "entity": "DashboardTemplate" }, + "workingTimeCalendar": { + "type": "belongsTo", + "entity": "WorkingTimeCalendar" + }, + "workingTimeRanges": { + "type": "hasMany", + "foreign": "users", + "entity": "WorkingTimeRange" + }, "preferences": { "type": "hasOne", "entity": "Preferences", diff --git a/application/Espo/Resources/metadata/entityDefs/WorkingTimeCalendar.json b/application/Espo/Resources/metadata/entityDefs/WorkingTimeCalendar.json new file mode 100644 index 0000000000..ab4a0f2562 --- /dev/null +++ b/application/Espo/Resources/metadata/entityDefs/WorkingTimeCalendar.json @@ -0,0 +1,133 @@ +{ + "fields": { + "name": { + "type": "varchar", + "required": true + }, + "description": { + "type": "text" + }, + "timeZone": { + "type": "enum", + "default": "", + "view": "views/preferences/fields/time-zone" + }, + "timeRanges": { + "type": "jsonArray", + "default": [ + ["9:00", "17:00"] + ], + "view": "views/working-time-calendar/fields/time-ranges", + "required": true + }, + "weekday0": { + "type": "bool", + "default": false + }, + "weekday1": { + "type": "bool", + "default": true + }, + "weekday2": { + "type": "bool", + "default": true + }, + "weekday3": { + "type": "bool", + "default": true + }, + "weekday4": { + "type": "bool", + "default": true + }, + "weekday5": { + "type": "bool", + "default": true + }, + "weekday6": { + "type": "bool", + "default": false + }, + "weekday0TimeRanges": { + "type": "jsonArray", + "default": null, + "view": "views/working-time-calendar/fields/time-ranges" + }, + "weekday1TimeRanges": { + "type": "jsonArray", + "default": null, + "view": "views/working-time-calendar/fields/time-ranges" + }, + "weekday2TimeRanges": { + "type": "jsonArray", + "default": null, + "view": "views/working-time-calendar/fields/time-ranges" + }, + "weekday3TimeRanges": { + "type": "jsonArray", + "default": null, + "view": "views/working-time-calendar/fields/time-ranges" + }, + "weekday4TimeRanges": { + "type": "jsonArray", + "default": null, + "view": "views/working-time-calendar/fields/time-ranges" + }, + "weekday5TimeRanges": { + "type": "jsonArray", + "default": null, + "view": "views/working-time-calendar/fields/time-ranges" + }, + "weekday6TimeRanges": { + "type": "jsonArray", + "default": null, + "view": "views/working-time-calendar/fields/time-ranges" + }, + "teams": { + "type": "linkMultiple", + "readOnly": true + }, + "createdAt": { + "type": "datetime", + "readOnly": true + }, + "modifiedAt": { + "type": "datetime", + "readOnly": true + }, + "createdBy": { + "type": "link", + "readOnly": true + }, + "modifiedBy": { + "type": "link", + "readOnly": true + } + }, + "links": { + "ranges": { + "type": "hasMany", + "foreign": "calendars", + "entity": "WorkingTimeRange" + }, + "teams": { + "type": "hasMany", + "foreign": "workingTimeCalendar", + "entity": "Team", + "readOnly": true + }, + "createdBy": { + "type": "belongsTo", + "entity": "User" + }, + "modifiedBy": { + "type": "belongsTo", + "entity": "User" + } + }, + "collection": { + "orderBy": "name", + "order": "asc", + "textFilterFields": ["name"] + } +} diff --git a/application/Espo/Resources/metadata/entityDefs/WorkingTimeRange.json b/application/Espo/Resources/metadata/entityDefs/WorkingTimeRange.json new file mode 100644 index 0000000000..ff90c74f22 --- /dev/null +++ b/application/Espo/Resources/metadata/entityDefs/WorkingTimeRange.json @@ -0,0 +1,89 @@ +{ + "fields": { + "timeRanges": { + "type": "jsonArray", + "default": null, + "view": "views/working-time-calendar/fields/time-ranges" + }, + "dateStart": { + "type": "date", + "required": true + }, + "dateEnd": { + "type": "date", + "required": true, + "view": "views/working-time-range/fields/date-end" + }, + "type": { + "type": "enum", + "options": [ + "Non-working", + "Working" + ], + "default": "Non-working", + "index": true, + "maxLength": 11 + }, + "name": { + "type": "varchar" + }, + "description": { + "type": "text" + }, + "calendars": { + "type": "linkMultiple" + }, + "users": { + "type": "linkMultiple", + "view": "views/working-time-range/fields/users" + }, + "createdAt": { + "type": "datetime", + "readOnly": true + }, + "modifiedAt": { + "type": "datetime", + "readOnly": true + }, + "createdBy": { + "type": "link", + "readOnly": true + }, + "modifiedBy": { + "type": "link", + "readOnly": true + } + }, + "links": { + "calendars": { + "type": "hasMany", + "foreign": "ranges", + "entity": "WorkingTimeCalendar" + }, + "users": { + "type": "hasMany", + "foreign": "workingTimeRanges", + "entity": "User" + }, + "createdBy": { + "type": "belongsTo", + "entity": "User" + }, + "modifiedBy": { + "type": "belongsTo", + "entity": "User" + } + }, + "collection": { + "orderBy": "dateStart", + "order": "asc" + }, + "indexes": { + "typeRange": { + "columns": ["type", "dateStart", "dateEnd"] + }, + "type": { + "columns": ["type"] + } + } +} diff --git a/application/Espo/Resources/metadata/scopes/WorkingTimeCalendar.json b/application/Espo/Resources/metadata/scopes/WorkingTimeCalendar.json new file mode 100644 index 0000000000..19d64a0a6d --- /dev/null +++ b/application/Espo/Resources/metadata/scopes/WorkingTimeCalendar.json @@ -0,0 +1,7 @@ +{ + "entity": true, + "acl": "boolean", + "tab": true, + "layouts": false, + "customizable": false +} diff --git a/application/Espo/Resources/metadata/scopes/WorkingTimeRange.json b/application/Espo/Resources/metadata/scopes/WorkingTimeRange.json new file mode 100644 index 0000000000..99ca249991 --- /dev/null +++ b/application/Espo/Resources/metadata/scopes/WorkingTimeRange.json @@ -0,0 +1,7 @@ +{ + "entity": true, + "acl": false, + "tab": false, + "layouts": false, + "customizable": false +} diff --git a/application/Espo/Resources/metadata/selectDefs/WorkingTimeCalendar.json b/application/Espo/Resources/metadata/selectDefs/WorkingTimeCalendar.json new file mode 100644 index 0000000000..e229c9bf61 --- /dev/null +++ b/application/Espo/Resources/metadata/selectDefs/WorkingTimeCalendar.json @@ -0,0 +1,3 @@ +{ + "accessControlFilterResolverClassName": "Espo\\Core\\Select\\AccessControl\\FilterResolvers\\Boolean" +} diff --git a/application/Espo/Resources/metadata/selectDefs/WorkingTimeRange.json b/application/Espo/Resources/metadata/selectDefs/WorkingTimeRange.json new file mode 100644 index 0000000000..a5c2d39643 --- /dev/null +++ b/application/Espo/Resources/metadata/selectDefs/WorkingTimeRange.json @@ -0,0 +1,6 @@ +{ + "accessControlFilterResolverClassName": "Espo\\Core\\Select\\AccessControl\\FilterResolvers\\Boolean", + "primaryFilterClassNameMap": { + "actual": "Espo\\Classes\\Select\\WorkingTimeRange\\PrimaryFilters\\Actual" + } +} diff --git a/application/Espo/Resources/metadata/themes/Dark.json b/application/Espo/Resources/metadata/themes/Dark.json index 246feda0f0..57a12dc4d5 100644 --- a/application/Espo/Resources/metadata/themes/Dark.json +++ b/application/Espo/Resources/metadata/themes/Dark.json @@ -10,6 +10,7 @@ "chartColorAlternativeList": ["#7492cc", "#c29c4a", "#a1404a", "#6a5f96", "#b07e53"], "calendarColors": { "": "#a58dc7a0", + "bg": "#3e485ab3", "Meeting": "#697da5", "Call": "#a1404a", "Task": "#5d8a55" diff --git a/application/Espo/Resources/metadata/themes/Espo.json b/application/Espo/Resources/metadata/themes/Espo.json index 0b89c51613..cf2f3f4e52 100644 --- a/application/Espo/Resources/metadata/themes/Espo.json +++ b/application/Espo/Resources/metadata/themes/Espo.json @@ -41,7 +41,8 @@ "chartColorList": ["#6FA8D6", "#4E6CAD", "#EDC555", "#ED8F42", "#DE6666", "#7CC4A4", "#8A7CC2", "#D4729B", "#bfbfbf"], "chartColorAlternativeList": ["#6FA8D6", "#EDC555", "#ED8F42", "#7CC4A4", "#D4729B"], "calendarColors": { - "": "#a58dc7a0" + "": "#a58dc7a0", + "bg": "#d5ddf6a0" }, "isDark": false } diff --git a/application/Espo/Resources/metadata/themes/Glass.json b/application/Espo/Resources/metadata/themes/Glass.json index 7b0a9a5b8e..68348be5fb 100644 --- a/application/Espo/Resources/metadata/themes/Glass.json +++ b/application/Espo/Resources/metadata/themes/Glass.json @@ -10,6 +10,7 @@ "chartColorAlternativeList": ["#7492cc", "#c29c4a", "#a1404a", "#6a5f96", "#b07e53"], "calendarColors": { "": "#a58dc7a0", + "bg": "#45528166", "Meeting": "#6680b3d1", "Call": "#a1404ad1", "Task": "#5d8a55d1" diff --git a/application/Espo/Tools/WorkingTime/Calendar.php b/application/Espo/Tools/WorkingTime/Calendar.php new file mode 100644 index 0000000000..de26cabd8f --- /dev/null +++ b/application/Espo/Tools/WorkingTime/Calendar.php @@ -0,0 +1,66 @@ + + */ + private int $hour; + + /** + * @var int<0,59> + */ + private int $minute; + + /** + * @param int<0,23> $hour + * @param int<0,59> $minute + */ + public function __construct(int $hour, int $minute) + { + $this->hour = $hour; + $this->minute = $minute; + } + + /** + * @return int<0,23> + */ + public function getHour(): int + { + return $this->hour; + } + + /** + * @return int<0,59> + */ + public function getMinute(): int + { + return $this->minute; + } +} diff --git a/application/Espo/Tools/WorkingTime/Calendar/TimeRange.php b/application/Espo/Tools/WorkingTime/Calendar/TimeRange.php new file mode 100644 index 0000000000..ee5f6d7e53 --- /dev/null +++ b/application/Espo/Tools/WorkingTime/Calendar/TimeRange.php @@ -0,0 +1,53 @@ +start = $start; + $this->end = $end; + } + + public function getStart(): Time + { + return $this->start; + } + + public function getEnd(): Time + { + return $this->end; + } +} diff --git a/application/Espo/Tools/WorkingTime/Calendar/WorkingDate.php b/application/Espo/Tools/WorkingTime/Calendar/WorkingDate.php new file mode 100644 index 0000000000..611b78ef29 --- /dev/null +++ b/application/Espo/Tools/WorkingTime/Calendar/WorkingDate.php @@ -0,0 +1,64 @@ +date = $date; + $this->ranges = $ranges; + } + + public function getDate(): Date + { + return $this->date; + } + + /** + * @return TimeRange[] + */ + public function getRanges(): array + { + return $this->ranges; + } +} diff --git a/application/Espo/Tools/WorkingTime/Calendar/WorkingWeekday.php b/application/Espo/Tools/WorkingTime/Calendar/WorkingWeekday.php new file mode 100644 index 0000000000..7606587553 --- /dev/null +++ b/application/Espo/Tools/WorkingTime/Calendar/WorkingWeekday.php @@ -0,0 +1,69 @@ + + */ + private int $weekday; + + /** + * @var TimeRange[] + */ + private array $ranges; + + /** + * @param int<0,6> $weekday + * @param TimeRange[] $ranges + */ + public function __construct(int $weekday, array $ranges) + { + $this->weekday = $weekday; + $this->ranges = $ranges; + } + + /** + * @return int<0,6> + */ + public function getWeekday(): int + { + return $this->weekday; + } + + /** + * @return TimeRange[] + */ + public function getRanges(): array + { + return $this->ranges; + } +} diff --git a/application/Espo/Tools/WorkingTime/CalendarFactory.php b/application/Espo/Tools/WorkingTime/CalendarFactory.php new file mode 100644 index 0000000000..010ce0fa6c --- /dev/null +++ b/application/Espo/Tools/WorkingTime/CalendarFactory.php @@ -0,0 +1,68 @@ +injectableFactory = $injectableFactory; + } + + public function createGlobal(): GlobalCalendar + { + return $this->injectableFactory->create(GlobalCalendar::class); + } + + public function createForUser(User $user): UserCalendar + { + $binding = BindingContainerBuilder::create() + ->bindInstance(User::class, $user) + ->build(); + + return $this->injectableFactory->createWithBinding(UserCalendar::class, $binding); + } + + public function createForTeam(Team $team): TeamCalendar + { + $binding = BindingContainerBuilder::create() + ->bindInstance(Team::class, $team) + ->build(); + + return $this->injectableFactory->createWithBinding(TeamCalendar::class, $binding); + } +} diff --git a/application/Espo/Tools/WorkingTime/CalendarUtility.php b/application/Espo/Tools/WorkingTime/CalendarUtility.php new file mode 100644 index 0000000000..f8363b6f9a --- /dev/null +++ b/application/Espo/Tools/WorkingTime/CalendarUtility.php @@ -0,0 +1,149 @@ +calendar = $calendar; + $this->extractor = $extractor; + } + + public function isWorkingDay(DateTime $time): bool + { + $point = $time + ->withTimezone($this->calendar->getTimezone()) + ->withTime(0, 0, 0); + + return $this->extractor->extractAllDay($this->calendar, $point, $point->modify('+1 day')) !== []; + } + + public function hasWorkingTime(DateTime $from, DateTime $to): bool + { + return $this->extractor->extract($this->calendar, $from, $to) !== []; + } + + public function getSummedWorkingHours(DateTime $from, DateTime $to): float + { + $ranges = $this->extractor->extract($this->calendar, $from, $to); + + $sum = 0.0; + + foreach ($ranges as $range) { + $sum += ($range[1]->getTimestamp() - $range[0]->getTimestamp()) / 3600; + } + + return $sum; + } + + public function getWorkingDays(DateTime $from, DateTime $to): int + { + $ranges = $this->extractor->extractAllDay($this->calendar, $from, $to); + + return count($ranges); + } + + public function findClosestWorkingTime(DateTime $time): ?DateTime + { + $step = 10; + $max = $time->modify('+' . self::MAX_FIND_DAYS_PERIOD . ' days'); + + $point = $time; + + while ($point->isLessThan($max)) { + $from = $point; + $to = $point->modify('+' . $step . ' days'); + + $ranges = $this->extractor->extract($this->calendar, $from, $to); + + if (count($ranges)) { + return $time->isGreaterThan($ranges[0][0]) ? + $time : + $ranges[0][0]; + } + + $point = $to; + } + + return null; + } + + /** + * @param int<1, max> $days + */ + public function addWorkingDays(DateTime $time, int $days): ?DateTime + { + /** @var int $days */ + + if ($days <= 0) { + throw new InvalidArgumentException("Can't add non-positive days number."); + } + + $step = max(30, $days); + $max = $time->modify('+' . self::MAX_YEARS_PERIOD . ' years'); + + $point = $time + ->withTimezone($this->calendar->getTimezone()) + ->modify('+1 day') + ->withTime(0, 0, 0); + + $counter = 0; + + while ($point->isLessThan($max)) { + $from = $point; + $to = $point->modify('+' . $step . ' days'); + + $ranges = $this->extractor->extractAllDay($this->calendar, $from, $to); + + foreach ($ranges as $range) { + $counter++; + + if ($counter === $days) { + return $range[0]; + } + } + + $point = $to; + } + + return null; + } +} diff --git a/application/Espo/Tools/WorkingTime/Extractor.php b/application/Espo/Tools/WorkingTime/Extractor.php new file mode 100644 index 0000000000..0f60d6bb0b --- /dev/null +++ b/application/Espo/Tools/WorkingTime/Extractor.php @@ -0,0 +1,393 @@ +withTimezone($calendar->getTimezone()); + + $fromDate = Date::fromDateTime($from->modify('-1 day')->getDateTime()); + $toDate = Date::fromDateTime($from->modify('+1 day')->getDateTime()); + + $workingDates = $calendar->getWorkingDates($fromDate, $toDate); + $nonWorkingDates = $calendar->getNonWorkingDates($fromDate, $toDate); + $workingWeekdays = $calendar->getWorkingWeekdays(); + + $list = []; + + $end = $to->withTimezone($calendar->getTimezone())->withTime(23, 59, 59); + + while ($pointer->getTimestamp() < $end->getTimestamp()) { + $list = array_merge( + $list, + $this->extractIteration( + $pointer, + $workingDates, + $nonWorkingDates, + $workingWeekdays, + ), + ); + + $pointer = $pointer->modify('+1 day'); + } + + return $this->trim($list, $from, $to, $calendar); + } + + /** + * @param array{DateTime,DateTime}[] $list + * @return array{DateTime,DateTime}[] + */ + private function trim(array $list, DateTime $from, DateTime $to, Calendar $calendar): array + { + $wasUnset = false; + + foreach ($list as $i => $pair) { + if ($from->isLessThan($pair[0]) || $from->isEqualTo($pair[0])) { + break; + } + + if ($from->isGreaterThan($pair[1]) || $from->isEqualTo($pair[1])) { + unset($list[$i]); + $wasUnset = true; + + continue; + } + + if ($from->isLessThan($pair[1])) { + $list[$i][0] = $from->withTimezone($calendar->getTimezone()); + } + + break; + } + + if ($wasUnset) { + $list = array_values($list); + + $wasUnset = false; + } + + for ($i = count($list) - 1; $i >= 0; $i--) { + $pair = $list[$i]; + + if ($to->isGreaterThan($pair[1]) || $to->isEqualTo($pair[1])) { + break; + } + + if ($to->isLessThan($pair[0]) || $to->isEqualTo($pair[0])) { + unset($list[$i]); + $wasUnset = true; + + continue; + } + + if ($to->isGreaterThan($pair[0])) { + $list[$i][1] = $to->withTimezone($calendar->getTimezone()); + } + + break; + } + + if ($wasUnset) { + $list = array_values($list); + } + + return $list; + } + + /** + * @return array{DateTime,DateTime}[] + */ + public function extractAllDay(Calendar $calendar, DateTime $from, DateTime $to): array + { + $pointer = $from + ->withTimezone($calendar->getTimezone()) + ->withTime(0, 0, 0); + + $fromDate = Date::fromDateTime($from->modify('-1 day')->getDateTime()); + $toDate = Date::fromDateTime($from->modify('+1 day')->getDateTime()); + + $workingDates = $calendar->getWorkingDates($fromDate, $toDate); + $nonWorkingDates = $calendar->getNonWorkingDates($fromDate, $toDate); + $workingWeekdays = $calendar->getWorkingWeekdays(); + + $list = []; + + $end = $to->withTimezone($calendar->getTimezone())->withTime(23, 59, 59); + + while ($pointer->getTimestamp() < $end->getTimestamp()) { + $isWorkingDay = $this->isWorkingDay( + $pointer, + $workingDates, + $nonWorkingDates, + $workingWeekdays, + ); + + $nextPointer = $pointer->modify('+1 day'); + + if ($isWorkingDay) { + $list[] = [ + $pointer, + $nextPointer + ]; + } + + $pointer = $nextPointer; + } + + return $list; + } + + /** + * @return array{DateTime,DateTime}[] + */ + public function extractInversion(Calendar $calendar, DateTime $from, DateTime $to): array + { + $list = $this->extract($calendar, $from, $to); + + $timezone = $calendar->getTimezone(); + + if ($list === []) { + return [ + [ + $from->withTimezone($timezone), + $to->withTimezone($timezone), + ] + ]; + } + + $listInverted = []; + + $count = count($list); + + $listInverted[] = [ + $from->withTimezone($calendar->getTimezone()), + $list[0][0] + ]; + + for ($i = 0; $i < $count - 1; $i++) { + $item1 = $list[$i]; + $item2 = $list[$i + 1]; + + $listInverted[] = [ + $item1[1], + $item2[0] + ]; + } + + $listInverted[] = [ + $list[$count - 1][1], + $to->withTimezone($timezone) + ]; + + return $listInverted; + } + + /** + * @return array{DateTime,DateTime}[] + */ + public function extractAllDayInversion(Calendar $calendar, DateTime $from, DateTime $to): array + { + $list = $this->extractAllDay($calendar, $from, $to); + + if ($list === []) { + return [[$from, $to]]; + } + + $count = count($list); + + $listInverted = []; + + $listInverted[] = [ + $from, + $list[0][0] + ]; + + for ($i = 0; $i < $count - 1; $i++) { + $item1 = $list[$i]; + $item2 = $list[$i + 1]; + + $listInverted[] = [ + $item1[1], + $item2[0] + ]; + } + + $listInverted[] = [ + $list[$count - 1][1], + $to + ]; + + return $listInverted; + } + + /** + * @param WorkingDate[] $workingDates + * @param WorkingDate[] $nonWorkingDates + * @param WorkingWeekday[] $workingWeekdays + */ + private function isWorkingDay( + DateTime $pointer, + array $workingDates, + array $nonWorkingDates, + array $workingWeekdays + ): bool { + + if ($this->findInDateList($pointer, $nonWorkingDates)) { + return false; + } + + $day1 = $this->findInDateList($pointer, $workingDates); + + if ($day1) { + return true; + } + + $day2 = $this->findInWeekdayList($pointer, $workingWeekdays); + + if ($day2) { + return true; + } + + return false; + } + + /** + * @param WorkingDate[] $workingDates + * @param WorkingDate[] $nonWorkingDates + * @param WorkingWeekday[] $workingWeekdays + * @return array{DateTime,DateTime}[] + */ + private function extractIteration( + DateTime $pointer, + array $workingDates, + array $nonWorkingDates, + array $workingWeekdays + ): array { + + if ($this->findInDateList($pointer, $nonWorkingDates)) { + return []; + } + + $day1 = $this->findInDateList($pointer, $workingDates); + + if ($day1) { + return $this->extractFromDay($pointer, $day1); + } + + $day2 = $this->findInWeekdayList($pointer, $workingWeekdays); + + if ($day2) { + return $this->extractFromDay($pointer, $day2); + } + + return []; + } + + /** + * @param DateTime $pointer + * @param WorkingDate[] $dateList + */ + private function findInDateList(DateTime $pointer, array $dateList): ?WorkingDate + { + $day = $pointer->getDay(); + $month = $pointer->getMonth(); + $year = $pointer->getYear(); + + foreach ($dateList as $item) { + $date = $item->getDate(); + + if ( + $date->getDay() === $day && + $date->getMonth() === $month && + $date->getYear() === $year + ) { + return $item; + } + } + + return null; + } + + /** + * @param DateTime $pointer + * @param WorkingWeekday[] $dayList + */ + private function findInWeekdayList(DateTime $pointer, array $dayList): ?WorkingWeekday + { + $dow = $pointer->getDayOfWeek(); + + foreach ($dayList as $item) { + if ($item->getWeekday() === $dow) { + return $item; + } + } + + return null; + } + + /** + * @return array{DateTime,DateTime}[] + */ + private function extractFromDay(DateTime $dateTime, HavingRanges $day): array + { + $pointer = $dateTime->getDateTime(); + + $list = []; + + foreach ($day->getRanges() as $range) { + $start = $range->getStart(); + $end = $range->getEnd(); + + $list[] = [ + DateTime::fromDateTime( + $pointer->setTime($start->getHour(), $start->getMinute()) + ), + DateTime::fromDateTime( + $pointer->setTime($end->getHour(), $end->getMinute()) + ) + ]; + } + + return $list; + } +} diff --git a/application/Espo/Tools/WorkingTime/GlobalCalendar.php b/application/Espo/Tools/WorkingTime/GlobalCalendar.php new file mode 100644 index 0000000000..03df523e60 --- /dev/null +++ b/application/Espo/Tools/WorkingTime/GlobalCalendar.php @@ -0,0 +1,226 @@ +entityManager = $entityManager; + $this->config = $config; + + $this->timezone = new DateTimeZone($config->get('timeZone')); + + $this->initDefault(); + + if ($this->workingTimeCalendar) { + $this->util = new CalendarUtil($this->workingTimeCalendar); + + $this->timezone = $this->workingTimeCalendar->getTimeZone() ?? $this->timezone; + } + } + + private function initDefault(): void + { + $id = $this->config->get('workingTimeCalendarId'); + + if (!$id) { + return; + } + + $this->workingTimeCalendar = $this->entityManager->getEntityById(WorkingTimeCalendar::ENTITY_TYPE, $id); + } + + public function isAvailable(): bool + { + return $this->workingTimeCalendar !== null; + } + + public function getTimezone(): DateTimeZone + { + return $this->timezone; + } + + /** + * @return WorkingWeekday[] + */ + public function getWorkingWeekdays(): array + { + if ($this->workingTimeCalendar === null) { + return []; + } + + return $this->workingTimeCalendar->getWorkingWeekdays(); + } + + /** + * @return WorkingDate[] + */ + public function getNonWorkingDates(Date $from, Date $to): array + { + if ($this->workingTimeCalendar === null) { + return []; + } + + return $this->getDates($from, $to)[0]; + } + + /** + * @return WorkingDate[] + */ + public function getWorkingDates(Date $from, Date $to): array + { + if ($this->workingTimeCalendar === null) { + return []; + } + + return $this->getDates($from, $to)[1]; + } + + /** + * @return array{WorkingDate[], WorkingDate[]} + */ + private function getDates(Date $from, Date $to): array + { + $cacheKey = $from->getString() . '-' . $to->getString(); + + if ($this->cacheKey === $cacheKey) { + assert($this->cache !== null); + + return $this->cache; + } + + $notWorkingList = []; + $workingList = []; + + $list = $this->fetchRanges($from, $to); + + foreach ($list as $range) { + $dates = $this->rangeToDates($range); + + if ($range->getType() === WorkingTimeRange::TYPE_NON_WORKING) { + $notWorkingList = array_merge($notWorkingList, $dates); + + continue; + } + + $workingList = array_merge($workingList, $dates); + } + + $this->cacheKey = $cacheKey; + $this->cache = [$notWorkingList, $workingList]; + + return $this->cache; + } + + /** + * @param WorkingTimeRange $range + * @return WorkingDate[] + */ + private function rangeToDates(WorkingTimeRange $range): array + { + if (!$this->util) { + return []; + } + + return $this->util->rangeToDates($range); + } + + /** + * @return WorkingTimeRange[] + */ + private function fetchRanges(Date $from, Date $to): array + { + if ($this->workingTimeCalendar === null) { + return []; + } + + $list = []; + + $collection = $this->entityManager + ->getRDBRepositoryByClass(WorkingTimeRange::class) + ->leftJoin('calendars') + ->where( + Condition::equal( + Expression::column('calendars.id'), + $this->workingTimeCalendar->getId() + ) + ) + ->where( + OrGroup::create( + Condition::greaterOrEqual( + Expression::column('dateEnd'), + $from->getString() + ), + Condition::lessOrEqual( + Expression::column('dateStart'), + $to->getString() + ), + ) + ) + ->find(); + + foreach ($collection as $entity) { + $list[] = $entity; + } + + return $list; + } +} diff --git a/application/Espo/Tools/WorkingTime/TeamCalendar.php b/application/Espo/Tools/WorkingTime/TeamCalendar.php new file mode 100644 index 0000000000..8aaaa52ad8 --- /dev/null +++ b/application/Espo/Tools/WorkingTime/TeamCalendar.php @@ -0,0 +1,247 @@ +team = $team; + $this->entityManager = $entityManager; + $this->config = $config; + + $this->timezone = new DateTimeZone($config->get('timeZone')); + + $this->init(); + + if (!$this->workingTimeCalendar) { + $this->initDefault(); + } + + if ($this->workingTimeCalendar) { + $this->util = new CalendarUtil($this->workingTimeCalendar); + + $this->timezone = $this->workingTimeCalendar->getTimeZone() ?? $this->timezone; + } + } + + private function init(): void + { + $workingTimeCalendarLink = $this->team->getWorkingTimeCalendar(); + + if (!$workingTimeCalendarLink) { + return; + } + + $this->workingTimeCalendar = $this->entityManager + ->getRepositoryByClass(WorkingTimeCalendar::class) + ->getById($workingTimeCalendarLink->getId()); + } + + private function initDefault(): void + { + $id = $this->config->get('workingTimeCalendarId'); + + if (!$id) { + return; + } + + $this->workingTimeCalendar = $this->entityManager->getEntityById(WorkingTimeCalendar::ENTITY_TYPE, $id); + } + + public function isAvailable(): bool + { + return $this->workingTimeCalendar !== null; + } + + public function getTimezone(): DateTimeZone + { + return $this->timezone; + } + + /** + * @return WorkingWeekday[] + */ + public function getWorkingWeekdays(): array + { + if ($this->workingTimeCalendar === null) { + return []; + } + + return $this->workingTimeCalendar->getWorkingWeekdays(); + } + + /** + * @return WorkingDate[] + */ + public function getNonWorkingDates(Date $from, Date $to): array + { + if ($this->workingTimeCalendar === null) { + return []; + } + + return $this->getDates($from, $to)[0]; + } + + /** + * @return WorkingDate[] + */ + public function getWorkingDates(Date $from, Date $to): array + { + if ($this->workingTimeCalendar === null) { + return []; + } + + return $this->getDates($from, $to)[1]; + } + + /** + * @return array{WorkingDate[], WorkingDate[]} + */ + private function getDates(Date $from, Date $to): array + { + $cacheKey = $from->getString() . '-' . $to->getString(); + + if ($this->cacheKey === $cacheKey) { + assert($this->cache !== null); + + return $this->cache; + } + + $notWorkingList = []; + $workingList = []; + + $list = $this->fetchRanges($from, $to); + + foreach ($list as $range) { + $dates = $this->rangeToDates($range); + + if ($range->getType() === WorkingTimeRange::TYPE_NON_WORKING) { + $notWorkingList = array_merge($notWorkingList, $dates); + + continue; + } + + $workingList = array_merge($workingList, $dates); + } + + $this->cacheKey = $cacheKey; + $this->cache = [$notWorkingList, $workingList]; + + return $this->cache; + } + + /** + * @param WorkingTimeRange $range + * @return WorkingDate[] + */ + private function rangeToDates(WorkingTimeRange $range): array + { + if (!$this->util) { + return []; + } + + return $this->util->rangeToDates($range); + } + + /** + * @return WorkingTimeRange[] + */ + private function fetchRanges(Date $from, Date $to): array + { + if ($this->workingTimeCalendar === null) { + return []; + } + + $list = []; + + $collection = $this->entityManager + ->getRDBRepositoryByClass(WorkingTimeRange::class) + ->leftJoin('calendars') + ->where( + Condition::equal( + Expression::column('calendars.id'), + $this->workingTimeCalendar->getId() + ) + ) + ->where( + OrGroup::create( + Condition::greaterOrEqual( + Expression::column('dateEnd'), + $from->getString() + ), + Condition::lessOrEqual( + Expression::column('dateStart'), + $to->getString() + ), + ) + ) + ->find(); + + foreach ($collection as $entity) { + $list[] = $entity; + } + + return $list; + } +} diff --git a/application/Espo/Tools/WorkingTime/UserCalendar.php b/application/Espo/Tools/WorkingTime/UserCalendar.php new file mode 100644 index 0000000000..874e004cf7 --- /dev/null +++ b/application/Espo/Tools/WorkingTime/UserCalendar.php @@ -0,0 +1,331 @@ +user = $user; + $this->entityManager = $entityManager; + $this->config = $config; + + $this->timezone = new DateTimeZone($config->get('timeZone')); + + $this->init(); + + if (!$this->workingTimeCalendar) { + $this->initDefault(); + } + + if ($this->workingTimeCalendar) { + $this->util = new CalendarUtil($this->workingTimeCalendar); + + $this->timezone = $this->workingTimeCalendar->getTimeZone() ?? $this->timezone; + } + } + + private function init(): void + { + $workingTimeCalendarLink = $this->user->getWorkingTimeCalendar(); + + if ($workingTimeCalendarLink) { + $this->workingTimeCalendar = $this->entityManager + ->getRepositoryByClass(WorkingTimeCalendar::class) + ->getById($workingTimeCalendarLink->getId()); + + if ($this->workingTimeCalendar) { + return; + } + } + + $defaultTeamLink = $this->user->getDefaultTeam(); + + if (!$defaultTeamLink) { + return; + } + + $team = $this->entityManager + ->getRepositoryByClass(Team::class) + ->getById($defaultTeamLink->getId()); + + if (!$team) { + return; + } + + $workingTimeCalendarLink = $team->getWorkingTimeCalendar(); + + if (!$workingTimeCalendarLink) { + return; + } + + $this->workingTimeCalendar = $this->entityManager + ->getRepositoryByClass(WorkingTimeCalendar::class) + ->getById($workingTimeCalendarLink->getId()); + } + + private function initDefault(): void + { + $id = $this->config->get('workingTimeCalendarId'); + + if (!$id) { + return; + } + + $this->workingTimeCalendar = $this->entityManager->getEntityById(WorkingTimeCalendar::ENTITY_TYPE, $id); + } + + public function isAvailable(): bool + { + return $this->workingTimeCalendar !== null; + } + + public function getTimezone(): DateTimeZone + { + return $this->timezone; + } + + /** + * @return WorkingWeekday[] + */ + public function getWorkingWeekdays(): array + { + if ($this->workingTimeCalendar === null) { + return []; + } + + return $this->workingTimeCalendar->getWorkingWeekdays(); + } + + /** + * @return WorkingDate[] + */ + public function getNonWorkingDates(Date $from, Date $to): array + { + if ($this->workingTimeCalendar === null) { + return []; + } + + return $this->getDates($from, $to)[0]; + } + + /** + * @return WorkingDate[] + */ + public function getWorkingDates(Date $from, Date $to): array + { + if ($this->workingTimeCalendar === null) { + return []; + } + + return $this->getDates($from, $to)[1]; + } + + /** + * @return array{WorkingDate[], WorkingDate[]} + */ + private function getDates(Date $from, Date $to): array + { + $cacheKey = $from->getString() . '-' . $to->getString(); + + if ($this->cacheKey === $cacheKey) { + assert($this->cache !== null); + + return $this->cache; + } + + $notWorkingList = []; + $workingList = []; + + $listForUser = $this->fetchUserRanges($from, $to); + $list = $this->fetchRanges($from, $to); + + foreach ($listForUser as $range) { + $dates = $this->rangeToDates($range); + + if ($range->getType() === WorkingTimeRange::TYPE_NON_WORKING) { + $notWorkingList = array_merge($notWorkingList, $dates); + + continue; + } + + $workingList = array_merge($workingList, $dates); + } + + $metMap = []; + + foreach (array_merge($notWorkingList, $workingList) as $date) { + $metMap[$date->getDate()->getString()] = true; + } + + foreach ($list as $range) { + $dates = array_filter( + $this->rangeToDates($range), + function (WorkingDate $date) use ($metMap) { + return !array_key_exists($date->getDate()->getString(), $metMap); + } + ); + + if ($range->getType() === WorkingTimeRange::TYPE_NON_WORKING) { + $notWorkingList = array_merge($notWorkingList, $dates); + + continue; + } + + $workingList = array_merge($workingList, $dates); + } + + $this->cacheKey = $cacheKey; + $this->cache = [$notWorkingList, $workingList]; + + return $this->cache; + } + + /** + * @param WorkingTimeRange $range + * @return WorkingDate[] + */ + private function rangeToDates(WorkingTimeRange $range): array + { + if (!$this->util) { + return []; + } + + return $this->util->rangeToDates($range); + } + + /** + * @return WorkingTimeRange[] + */ + private function fetchUserRanges(Date $from, Date $to): array + { + $list = []; + + $collection = $this->entityManager + ->getRDBRepositoryByClass(WorkingTimeRange::class) + ->leftJoin('users') + ->where( + Condition::equal( + Expression::column('users.id'), + $this->user->getId() + ) + ) + ->where( + OrGroup::create( + Condition::greaterOrEqual( + Expression::column('dateEnd'), + $from->getString() + ), + Condition::lessOrEqual( + Expression::column('dateStart'), + $to->getString() + ), + ) + ) + ->find(); + + foreach ($collection as $entity) { + $list[] = $entity; + } + + return $list; + } + + /** + * @return WorkingTimeRange[] + */ + private function fetchRanges(Date $from, Date $to): array + { + if ($this->workingTimeCalendar === null) { + return []; + } + + $list = []; + + $collection = $this->entityManager + ->getRDBRepositoryByClass(WorkingTimeRange::class) + ->leftJoin('calendars') + ->where( + Condition::equal( + Expression::column('calendars.id'), + $this->workingTimeCalendar->getId() + ) + ) + ->where( + OrGroup::create( + Condition::greaterOrEqual( + Expression::column('dateEnd'), + $from->getString() + ), + Condition::lessOrEqual( + Expression::column('dateStart'), + $to->getString() + ), + ) + ) + ->find(); + + foreach ($collection as $entity) { + $list[] = $entity; + } + + return $list; + } +} diff --git a/application/Espo/Tools/WorkingTime/Util/CalendarUtil.php b/application/Espo/Tools/WorkingTime/Util/CalendarUtil.php new file mode 100644 index 0000000000..7a0ab034f0 --- /dev/null +++ b/application/Espo/Tools/WorkingTime/Util/CalendarUtil.php @@ -0,0 +1,74 @@ +workingTimeCalendar = $workingTimeCalendar; + } + + /** + * @param WorkingTimeRange $range + * @return WorkingDate[] + */ + public function rangeToDates(WorkingTimeRange $range): array + { + $isWorking = $range->getType() === WorkingTimeRange::TYPE_WORKING; + + $list = []; + + $pointer = $range->getDateStart(); + $endPlusOne = $range->getDateEnd()->modify('+1 day'); + + $defaultTimeRanges = $this->workingTimeCalendar->getTimeRanges(); + + while ($pointer->isLessThan($endPlusOne)) { + $timeRanges = $isWorking ? $range->getTimeRanges() : []; + + if ($isWorking && $timeRanges === null) { + $timeRanges = $defaultTimeRanges; + } + + $list[] = new WorkingDate($pointer, $timeRanges ?? []); + + $pointer = $pointer->modify('+1 day'); + } + + return $list; + } +} diff --git a/client/modules/crm/res/templates/calendar/mode-buttons.tpl b/client/modules/crm/res/templates/calendar/mode-buttons.tpl index 0b4b8028f9..f5ce9a2d14 100644 --- a/client/modules/crm/res/templates/calendar/mode-buttons.tpl +++ b/client/modules/crm/res/templates/calendar/mode-buttons.tpl @@ -31,8 +31,10 @@ {{/each}} - {{#if isCustomViewAvailable}} + {{#if hasMoreItems}}
  • + {{/if}} + {{#if isCustomViewAvailable}}
  • {{translate 'Create Shared View' scope='Calendar'}}
  • {{/if}} + {{#if hasWorkingTimeCalendarLink}} +
  • + {{translate 'WorkingTimeCalendar' category='scopeNamesPlural'}} +
  • + {{/if}} diff --git a/client/modules/crm/src/views/calendar/calendar.js b/client/modules/crm/src/views/calendar/calendar.js index f1a6040b14..ca5aae6fba 100644 --- a/client/modules/crm/src/views/calendar/calendar.js +++ b/client/modules/crm/src/views/calendar/calendar.js @@ -67,6 +67,11 @@ define('crm:views/calendar/calendar', ['view', 'lib!full-calendar'], function (D day: 'dddd, MMMM D, YYYY', }, + /** + * @private + */ + fetching: false, + data: function () { return { mode: this.mode, @@ -215,13 +220,16 @@ define('crm:views/calendar/calendar', ['view', 'lib!full-calendar'], function (D } }, + isAgendaMode: function () { + return this.mode.indexOf('agenda') === 0; + }, + selectMode: function (mode) { if (~this.fullCalendarModeList.indexOf(mode) || mode.indexOf('view-') === 0) { var previousMode = this.mode; if ( - mode.indexOf('view-') === 0 - || + mode.indexOf('view-') === 0 || mode.indexOf('view-') !== 0 && previousMode.indexOf('view-') === 0 ) { this.trigger('change:mode', mode, true); @@ -244,6 +252,16 @@ define('crm:views/calendar/calendar', ['view', 'lib!full-calendar'], function (D this.$calendar.fullCalendar('changeView', this.viewMode); + let toAgenda = previousMode.indexOf('agenda') !== 0 && mode.indexOf('agenda') === 0; + let fromAgenda = previousMode.indexOf('agenda') === 0 && mode.indexOf('agenda') !== 0; + + if ( + toAgenda && !this.fetching || + fromAgenda && !this.fetching + ) { + this.$calendar.fullCalendar('refetchEvents') + } + this.updateDate(); if (this.hasView('modeButtons')) { @@ -347,6 +365,13 @@ define('crm:views/calendar/calendar', ['view', 'lib!full-calendar'], function (D originalColor: o.color, }; + if (o.isWorkingRange) { + event.rendering = 'inverse-background'; + //event.display = 'inverse-background'; + + event.color = this.colors['bg']; + } + if (this.teamIdList && o.userIdList) { event.userIdList = o.userIdList; event.userNameMap = o.userNameMap || {}; @@ -386,9 +411,15 @@ define('crm:views/calendar/calendar', ['view', 'lib!full-calendar'], function (D event.allDay = false; - this.handleAllDay(event); - this.fillColor(event); - this.handleStatus(event); + if (!o.isWorkingRange) { + this.handleAllDay(event); + this.fillColor(event); + this.handleStatus(event); + } + + if (o.isWorkingRange && !this.isAgendaMode()) { + event.allDay = true; + } return event; }, @@ -590,6 +621,7 @@ define('crm:views/calendar/calendar', ['view', 'lib!full-calendar'], function (D snapDuration: this.slotDuration * 60 * 1000, timezone: this.getDateTime().timeZone, longPressDelay: 300, + //eventBackgroundColor: '#333', eventColor: this.colors[''], nowIndicator: true, windowResize: () => { @@ -903,6 +935,10 @@ define('crm:views/calendar/calendar', ['view', 'lib!full-calendar'], function (D url += '&teamIdList=' + encodeURIComponent(this.teamIdList.join(',')); } + let agenda = this.mode === 'agendaWeek' || this.mode === 'agendaDay'; + + url += '&agenda=' + encodeURIComponent(agenda); + Espo.Ajax.getRequest(url).then(data => { let events = this.convertToFcEvents(data); @@ -910,6 +946,10 @@ define('crm:views/calendar/calendar', ['view', 'lib!full-calendar'], function (D Espo.Ui.notify(false); }); + + this.fetching = true; + + setTimeout(() => this.fetching = false, 50) }, addModel: function (model) { diff --git a/client/modules/crm/src/views/calendar/mode-buttons.js b/client/modules/crm/src/views/calendar/mode-buttons.js index 5d8e7e3fc6..d4f211d043 100644 --- a/client/modules/crm/src/views/calendar/mode-buttons.js +++ b/client/modules/crm/src/views/calendar/mode-buttons.js @@ -56,6 +56,8 @@ define('crm:views/calendar/mode-buttons', 'view', function (Dep) { hiddenModeDataList: this.getHiddenModeDataList(), scopeFilterDataList: scopeFilterDataList, isCustomViewAvailable: this.isCustomViewAvailable, + hasMoreItems: this.isCustomViewAvailable, + hasWorkingTimeCalendarLink: this.getAcl().checkScope('WorkingTimeCalendar'), }; }, diff --git a/client/modules/crm/src/views/calendar/timeline.js b/client/modules/crm/src/views/calendar/timeline.js index ca61574036..dc73e6f1c1 100644 --- a/client/modules/crm/src/views/calendar/timeline.js +++ b/client/modules/crm/src/views/calendar/timeline.js @@ -424,7 +424,24 @@ define('crm:views/calendar/timeline', ['view', 'lib!vis'], function (Dep, Vis) { 'date-end': o.dateEnd, type: 'background' }; - } else { + } else if (o.isWorkingRange) { + event = { + className: 'working', + group: userId, + 'date-start': o.dateStart, + 'date-end': o.dateEnd, + type: 'background', + }; + } else if (o.isNonWorkingRange) { + event = { + className: 'non-working', + group: userId, + 'date-start': o.dateStart, + 'date-end': o.dateEnd, + type: 'background', + }; + } + else { event = { content: this.getHelper().escapeString(o.name), title: this.getHelper().escapeString(o.name), @@ -484,13 +501,22 @@ define('crm:views/calendar/timeline', ['view', 'lib!vis'], function (Dep, Vis) { } this.fillColor(event); - this.handleStatus(event); + + if (!o.isNonWorkingRange) { + this.handleStatus(event); + } return event; }, fillColor: function (event) { - var color = this.colors[event.scope]; + let key = event.scope; + + if (event.className === 'non-working') { + key = 'bg'; + } + + let color = this.colors[key]; if (event.color) { color = event.color; @@ -500,11 +526,13 @@ define('crm:views/calendar/timeline', ['view', 'lib!vis'], function (Dep, Vis) { color = this.getColorFromScopeName(event.scope); } - /*if (color) { - color = this.shadeColor(color, 0.15); - }*/ - - if (~this.completedStatusList.indexOf(event.status) || ~this.canceledStatusList.indexOf(event.status)) { + if ( + event.status && + ( + ~this.completedStatusList.indexOf(event.status) || + ~this.canceledStatusList.indexOf(event.status) + ) + ) { color = this.shadeColor(color, 0.4); } @@ -758,7 +786,7 @@ define('crm:views/calendar/timeline', ['view', 'lib!vis'], function (Dep, Vis) { }, runFetch: function () { - this.fetchEvents(this.start, this.end, (eventList) => { + this.fetchEvents(this.start, this.end, eventList => { let itemsDataSet = new Vis.DataSet(eventList); this.timeline.setItems(itemsDataSet); @@ -1029,30 +1057,23 @@ define('crm:views/calendar/timeline', ['view', 'lib!vis'], function (Dep, Vis) { url += '&scopeList=' + encodeURIComponent(this.enabledScopeList.join(',')); - this.ajaxGetRequest(url).then((data) => { + this.ajaxGetRequest(url).then(data => { this.fetchedStart = from.clone(); this.fetchedEnd = to.clone(); - var eventList = []; + let eventList = []; for (let userId in data) { - var userEventList = data[userId].eventList; + let userEventList = data[userId]; - userEventList.forEach((item) => { + userEventList.forEach(item => { item.userId = userId; - eventList.push(item); - }); - var userBusyRangeList = data[userId].busyRangeList; - - userBusyRangeList.forEach(item =>{ - item.userId = userId; - item.isBusyRange = true; eventList.push(item); }); } - var convertedEventList = this.convertEventList(eventList); + let convertedEventList = this.convertEventList(eventList); callback(convertedEventList); @@ -1069,17 +1090,17 @@ define('crm:views/calendar/timeline', ['view', 'lib!vis'], function (Dep, Vis) { actionShowSharedCalendarOptions: function () { this.createView('dialog', 'crm:views/calendar/modals/shared-options', { userList: this.userList - }, function (view) { + }, view => { view.render(); - this.listenToOnce(view, 'save', function (data) { + this.listenToOnce(view, 'save', data => { this.userList = data.userList; this.storeUserList(); this.initGroupsDataSet(); this.timeline.setGroups(this.groupsDataSet); this.runFetch(); - }, this); - }, this); + }); + }); }, actionAddUser: function () { @@ -1098,12 +1119,12 @@ define('crm:views/calendar/timeline', ['view', 'lib!vis'], function (Dep, Vis) { scope: 'User', createButton: false, boolFilterList: boolFilterList, - multiple: true - }, (view) => { + multiple: true, + }, view => { view.render(); this.notify(false); - this.listenToOnce(view, 'select', (modelList) => { + this.listenToOnce(view, 'select', modelList => { modelList.forEach(model => { this.addSharedCalenderUser(model.id, model.get('name')); }); @@ -1135,7 +1156,7 @@ define('crm:views/calendar/timeline', ['view', 'lib!vis'], function (Dep, Vis) { var index = 0; var j = 0; - for (var i = 0; i < scopeList.length; i++) { + for (let i = 0; i < scopeList.length; i++) { if (scopeList[i] in colors) { continue; } diff --git a/client/modules/crm/src/views/scheduler/scheduler.js b/client/modules/crm/src/views/scheduler/scheduler.js index 189bb6f075..7f1834b2d5 100644 --- a/client/modules/crm/src/views/scheduler/scheduler.js +++ b/client/modules/crm/src/views/scheduler/scheduler.js @@ -48,6 +48,14 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) this.endField = this.options.endField || 'dateEnd'; this.assignedUserField = this.options.assignedUserField || 'assignedUser'; + this.colors = Espo.Utils + .clone(this.getMetadata().get('clientDefs.Calendar.colors') || {}); + + this.colors = _.extend( + this.colors, + Espo.Utils.clone(this.getHelper().themeManager.getParam('calendarColors') || {}), + ); + var usersFieldDefault = 'users'; if (!this.model.hasLink('users') && this.model.hasLink('assignedUsers')) { @@ -134,7 +142,9 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) this.$timeline.empty(); this.$timeline.append( - '
    '+this.translate('No Data')+'
    ' + $('
    ') + .addClass('revert-margin') + .text(this.translate('No Data')) ); }, @@ -168,14 +178,14 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) $timeline.css('min-height', this.lastHeight + 'px'); } - this.fetch(this.start, this.end, function (eventList) { + this.fetch(this.start, this.end, eventList => { var itemsDataSet = new Vis.DataSet(eventList); var timeline = this.timeline =new Vis.Timeline($timeline.get(0), itemsDataSet, this.groupsDataSet, { dataAttributes: 'all', start: this.start.toDate(), end: this.end.toDate(), - moment: function (date) { + moment: date => { var m = moment(date); if (date && date.noTimeZone) { @@ -183,7 +193,7 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) } return m.tz(this.getDateTime().getTimeZone()); - }.bind(this), + }, format: this.getFormatObject(), zoomable: false, moveable: true, @@ -213,7 +223,7 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) $timeline.css('min-height', ''); - timeline.on('rangechanged', function (e) { + timeline.on('rangechanged', (e) => { e.skipClick = true; this.blockClick = true; @@ -224,13 +234,12 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) this.end = moment(e.end); this.updateRange(); - }.bind(this)); + }); - setTimeout(function () { + setTimeout(() => { this.lastHeight = $timeline.height(); - }.bind(this), 500); - - }.bind(this)); + }, 500); + }); }, updateEvent: function () { @@ -246,8 +255,7 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) updateRange: function () { if ( - (this.start.unix() < this.fetchedStart.unix() + this.rangeMarginThreshold) - || + (this.start.unix() < this.fetchedStart.unix() + this.rangeMarginThreshold) || (this.end.unix() > this.fetchedEnd.unix() - this.rangeMarginThreshold) ) { this.runFetch(); @@ -274,7 +282,7 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) this.eventStart = moment.tz(startS, this.getDateTime().getTimeZone()); this.eventEnd = moment.tz(endS, this.getDateTime().getTimeZone()); this.eventEnd.add(1, 'day'); - }else { + } else { this.eventStart = moment.utc(startS).tz(this.getDateTime().getTimeZone()); this.eventEnd = moment.utc(endS).tz(this.getDateTime().getTimeZone()); } @@ -305,11 +313,11 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) }, runFetch: function () { - this.fetch(this.start, this.end, function (eventList) { + this.fetch(this.start, this.end, eventList => { var itemsDataSet = new this.Vis.DataSet(eventList); this.timeline.setItems(itemsDataSet); - }.bind(this)); + }); }, fetch: function (from, to, callback) { @@ -328,42 +336,46 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) url += '&entityId=' + this.model.id; } - this.ajaxGetRequest(url).then(function (data) { + this.ajaxGetRequest(url).then(data => { this.fetchedStart = from.clone(); this.fetchedEnd = to.clone(); - var eventList = []; + let eventList = []; - for (var userId in data) { - data[userId].forEach(function (item) { + for (let userId in data) { + let itemList = data[userId] + .filter(item => !item.isBusyRange) + .concat( + data[userId].filter(item => item.isBusyRange) + ); + + itemList.forEach(item => { item.userId = userId; - item.isBusyRange = true; + eventList.push(item); - }, this); + }); } this.busyEventList = Espo.Utils.cloneDeep(eventList); - var convertedEventList = this.convertEventList(eventList); + let convertedEventList = this.convertEventList(eventList); this.addEvent(convertedEventList); callback(convertedEventList); - - }.bind(this)); + }); }, addEvent: function (list) { - this.getCurrentItemList().forEach(function (item) { + this.getCurrentItemList().forEach(item => { list.push(item); - }, this); + }); }, getCurrentItemList: function () { var list = []; var o = { - type: 'point', start: this.eventStart.clone(), end: this.eventEnd.clone(), type: 'background', @@ -375,16 +387,19 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) if (color) { o.style += '; border-color: ' + color; + var rgb = this.hexToRgb(color); + o.style += '; background-color: rgba('+rgb.r+', '+rgb.g+', '+rgb.b+', 0.05)'; } - this.userIdList.forEach(function (id) { + this.userIdList.forEach(id => { var c = Espo.Utils.clone(o); c.group = id; c.id = 'event-' + id; + list.push(c); - }, this); + }); return list; }, @@ -392,7 +407,7 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) convertEventList: function (list) { var resultList = []; - list.forEach(function (item) { + list.forEach(item => { var event = this.convertEvent(item); if (!event) { @@ -400,7 +415,7 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) } resultList.push(event); - }, this); + }); return resultList; }, @@ -416,6 +431,27 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) 'date-end': o.dateEnd, type: 'background', }; + } else if (o.isWorkingRange) { + event = { + className: 'working', + group: o.userId, + 'date-start': o.dateStart, + 'date-end': o.dateEnd, + type: 'background', + }; + } else if (o.isNonWorkingRange) { + event = { + className: 'non-working', + group: o.userId, + 'date-start': o.dateStart, + 'date-end': o.dateEnd, + type: 'background', + }; + + let color = this.colors['bg']; + + event.style = 'background-color:' + color + ';'; + event.style += 'border-color:' + color + ';'; } if (o.dateStart) { @@ -425,6 +461,7 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) event.start = moment.tz(o.dateStartDate, this.getDateTime().getTimeZone()); } } + if (o.dateEnd) { if (!o.dateEndDate) { event.end = this.getDateTime().toMoment(o.dateEnd); @@ -433,7 +470,7 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) } } - if (o.isBusyRange) { + if (o.isBusyRange || o.isNonWorkingRange) { return event; } }, @@ -456,23 +493,22 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) this.userIdList = userIdList; - userIdList.forEach(function (id, i) { + userIdList.forEach((id, i) => { list.push({ id: id, content: this.getGroupContent(id, names[id] || id), order: i, }); - }, this); + }); this.groupsDataSet = new this.Vis.DataSet(list); }, getGroupContent: function (id, name) { - if (name) { - name = this.getHelper().escapeString(name); - } if (this.calendarType === 'single') { - return name; + return $('') + .text(name) + .get(0).outerHTML; } var avatarHtml = this.getAvatarHtml(id); @@ -481,9 +517,15 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) avatarHtml += ' '; } - var html = avatarHtml + '' + name + ''; - - return html; + return $('') + .append( + avatarHtml, + $('') + .attr('data-id', id) + .addClass('group-title') + .text(name) + ) + .get(0).innerHTML; }, getAvatarHtml: function (id) { @@ -492,7 +534,6 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) } var t; - var cache = this.getCache(); if (cache) { @@ -501,12 +542,15 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) t = Date.now(); } - return ''; + return $('') + .addClass('avatar avatar-link') + .attr('width', '14') + .attr('src', this.getBasePath() + '?entryPoint=avatar&size=small&id=' + id + '&t=' + t) + .get(0).outerHTML; }, getFormatObject: function () { - var format = { + return { minorLabels: { millisecond: 'SSS', second: 's', @@ -528,8 +572,6 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) year: '' } }; - - return format; }, getColorFromScopeName: function (scope) { @@ -548,6 +590,5 @@ define('crm:views/scheduler/scheduler', ['view', 'lib!vis'], function (Dep, Vis) b: parseInt(result[3], 16), } : null; }, - }); }); diff --git a/client/src/controllers/admin.js b/client/src/controllers/admin.js index a3a6cd6c82..d084feebb2 100644 --- a/client/src/controllers/admin.js +++ b/client/src/controllers/admin.js @@ -185,6 +185,10 @@ function (Dep, /** typeof module:search-manager.Class */SearchManager, _) { this.getRouter().dispatch('LayoutSet', 'list', {fromAdmin: true}); }, + actionWorkingTimeCalendar: function () { + this.getRouter().dispatch('WorkingTimeCalendar', 'list', {fromAdmin: true}); + }, + actionAttachments: function () { this.getRouter().dispatch('Attachment', 'list', {fromAdmin: true}); }, diff --git a/client/src/handlers/working-time-range.js b/client/src/handlers/working-time-range.js new file mode 100644 index 0000000000..6e7c219bc2 --- /dev/null +++ b/client/src/handlers/working-time-range.js @@ -0,0 +1,52 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2022 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * EspoCRM is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * EspoCRM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with EspoCRM. If not, see http://www.gnu.org/licenses/. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU General Public License version 3. + * + * In accordance with Section 7(b) of the GNU General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +define('handlers/working-time-range', [], () => { + + let Class = class Class { + + constructor(view) { + /** @type {module:views/record/edit.Class} */ + this.view = view; + } + + process() { + this.listenTo(this.view.model, 'change:dateStart', (model, value, o) => { + if (!o.ui || model.get('dateEnd')) { + return; + } + + setTimeout(() => model.set('dateEnd', value), 50); + }); + } + } + + _.extend(Class.prototype, Backbone.Events); + + return Class; +}); diff --git a/client/src/views/fields/base.js b/client/src/views/fields/base.js index fbf929a523..536a9e4346 100644 --- a/client/src/views/fields/base.js +++ b/client/src/views/fields/base.js @@ -594,6 +594,16 @@ define('views/fields/base', ['view'], function (Dep) { return Promise.resolve(); }, + /** + * Called on mode change and on value change before re-rendering. + * To be used for additional initialization that depends on field + * values or mode. + * + * @protected + * @returns {Promise|undefined} + */ + prepare: function () {}, + /** * @private * @returns {Promise} @@ -618,30 +628,30 @@ define('views/fields/base', ['view'], function (Dep) { * Additional initialization for the detail mode. * * @protected - * @returns {Promise} + * @returns {Promise|undefined} */ onDetailModeSet: function () { - return Promise.resolve(); + return this.prepare(); }, /** * Additional initialization for the edit mode. * * @protected - * @returns {Promise} + * @returns {Promise|undefined} */ onEditModeSet: function () { - return Promise.resolve(); + return this.prepare(); }, /** * Additional initialization for the list mode. * * @protected - * @returns {Promise} + * @returns {Promise|undefined} */ onListModeSet: function () { - return Promise.resolve(); + return this.prepare(); }, /** @@ -786,20 +796,26 @@ define('views/fields/base', ['view'], function (Dep) { this.attributeList = this.getAttributeList(); // for backward compatibility, to be removed this.listenTo(this.model, 'change', (model, options) => { - if (this.isRendered() || this.isBeingRendered()) { - if (options.ui) { - return; + if (options.ui) { + return; + } + + let changed = false; + + for (let attribute of this.getAttributeList()) { + if (model.hasChanged(attribute)) { + changed = true; + + break; } + } - let changed = false; + if (!changed) { + return; + } - this.getAttributeList().forEach(attribute => { - if (model.hasChanged(attribute)) { - changed = true; - } - }); - - if (!changed) { + let reRender = () => { + if (!this.isRendered() && !this.isBeingRendered()) { return; } @@ -810,7 +826,17 @@ define('views/fields/base', ['view'], function (Dep) { if (options.highlight) { this.trigger('highlight'); } + }; + + let promise = this.prepare(); + + if (promise) { + promise.then(() => reRender()); + + return; } + + reRender(); }); this.listenTo(this, 'change', () => { diff --git a/client/src/views/record/base.js b/client/src/views/record/base.js index 1c1b8d3266..f4d08823ca 100644 --- a/client/src/views/record/base.js +++ b/client/src/views/record/base.js @@ -1285,7 +1285,11 @@ function (Dep, ViewRecordHelper, DynamicLogic, _) { var defaultTeamId = this.getUser().get('defaultTeamId'); if (defaultTeamId) { - if (this.model.hasField('teams') && !this.model.getFieldParam('teams', 'default')) { + if ( + this.model.hasField('teams') && + !this.model.getFieldParam('teams', 'default') && + this.model.getLinkParam('teams', 'relationName') === 'entityTeam' + ) { defaultHash['teamsIds'] = [defaultTeamId]; defaultHash['teamsNames'] = {}; defaultHash['teamsNames'][defaultTeamId] = this.getUser().get('defaultTeamName'); diff --git a/client/src/views/user/record/detail.js b/client/src/views/user/record/detail.js index d22871a8aa..d3141bafbc 100644 --- a/client/src/views/user/record/detail.js +++ b/client/src/views/user/record/detail.js @@ -326,6 +326,7 @@ define('views/user/record/detail', 'views/record/detail', function (Dep) { [{"name":"portalRoles"}, {"name":"accounts"}] ] }); + if (this.getUser().isAdmin()) { layout.push({ "label": "Misc", @@ -336,6 +337,16 @@ define('views/user/record/detail', 'views/record/detail', function (Dep) { }); } } + + if (this.model.isAdmin() || this.model.isRegular()) { + layout.push({ + "label": "Misc", + "name": "misc", + "rows": [ + [{"name": "workingTimeCalendar"}, false] + ] + }); + } } if (this.getUser().isAdmin() && this.model.isApi()) { diff --git a/client/src/views/working-time-calendar/fields/time-ranges.js b/client/src/views/working-time-calendar/fields/time-ranges.js new file mode 100644 index 0000000000..b5de4fadf0 --- /dev/null +++ b/client/src/views/working-time-calendar/fields/time-ranges.js @@ -0,0 +1,344 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2022 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * EspoCRM is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * EspoCRM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with EspoCRM. If not, see http://www.gnu.org/licenses/. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU General Public License version 3. + * + * In accordance with Section 7(b) of the GNU General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +define('views/working-time-calendar/fields/time-ranges', ['views/fields/base'], function (Dep) { + + /** + * @class + * @name Class + * @memberOf module:views/working-time-calendar/fields/time-ranges + * @extends module:views/fields/base.Class + */ + return Dep.extend(/** @lends module:views/working-time-calendar/fields/time-ranges.Class# */{ + + listTemplateContent: ` +
    + {{#each itemDataList}} + {{{var viewKey ../this}}}{{#unless isLast}}  ·  {{/unless}} + {{/each}} +
    + {{#unless itemDataList.length}} + {{translate 'None'}} + {{/unless}} + `, + + detailTemplateContent: ` +
    + {{#each itemDataList}} +
    + {{{var viewKey ../this}}} +
    + {{/each}} +
    + {{#unless itemDataList.length}} + {{translate 'None'}} + {{/unless}} + `, + + editTemplateContent: ` +
    + {{#each itemDataList}} +
    + {{{var viewKey ../this}}} +
    + {{/each}} +
    +
    + +
    + `, + + fetchEmptyAsNull: false, + + validations: ['required', 'valid'], + + events: { + 'click .add-item': function () { + this.addItem(); + }, + 'click .remove-item': function (e) { + let key = parseInt($(e.currentTarget).attr('data-key')); + + this.removeItem(key); + }, + }, + + data: function () { + let data = Dep.prototype.data.call(this); + + data.itemDataList = this.itemKeyList.map((key, i) => { + return { + key: key.toString(), + viewKey: this.composeViewKey(key), + isLast: i === this.itemKeyList.length - 1, + }; + }); + + return data; + }, + + prepare: function () { + this.initItems(); + + return this.createItemViews(); + }, + + initItems: function () { + this.itemKeyList = []; + + this.getItemListFromModel().forEach((item, i) => { + this.itemKeyList.push(i); + }); + }, + + /** + * @returns {Promise} + */ + createItemView: function (item, key) { + let viewName = this.isEditMode() ? + 'views/working-time-calendar/fields/time-ranges/item-edit' : + 'views/working-time-calendar/fields/time-ranges/item-detail'; + + return this.createView( + this.composeViewKey(key), + viewName, + { + value: item, + el: this.getSelector() + ' .item[data-key="' + key + '"]', + key: key, + } + ) + .then(view => { + this.listenTo(view, 'change', () => { + this.trigger('change'); + }); + + return view; + }); + }, + + /** + * @returns {Promise} + */ + createItemViews: function () { + this.itemKeyList.forEach(key => { + this.clearView(this.composeViewKey(key)); + }); + + if (!this.model.has(this.name)) { + return Promise.resolve(); + } + + let itemList = this.getItemListFromModel(); + + let promiseList = []; + + this.itemKeyList.forEach((key, i) => { + let item = itemList[i]; + + let promise = this.createItemView(item, key); + + promiseList.push(promise); + }); + + return Promise.all(promiseList); + }, + + getItemView: function (key) { + return this.getView(this.composeViewKey(key)); + }, + + composeViewKey: function (key) { + return 'item-' + key; + }, + + /** + * @return {[string|null, string|null][]} + */ + getItemListFromModel: function () { + return Espo.Utils.cloneDeep(this.model.get(this.name) || []); + }, + + addItem: function () { + let itemList = this.getItemListFromModel(); + + let value = null; + + if (itemList.length) { + value = itemList[itemList.length - 1][1]; + } + + let item = [value, null]; + + itemList.push(item); + + let key = this.itemKeyList[this.itemKeyList.length - 1]; + + if (typeof key === 'undefined') { + key = 0; + } + + key++; + + this.itemKeyList.push(key); + + this.$el.find('.item-list').append( + $('
    ') + .addClass('item') + .attr('data-key', key) + ); + + this.createItemView(item, key) + .then(view => view.render()) + .then(() => { + this.trigger('change'); + }); + }, + + removeItem: function (key) { + let index = this.itemKeyList.indexOf(key); + + if (key === -1) { + return; + } + + let itemList = this.getItemListFromModel(); + + this.itemKeyList.splice(index, 1); + itemList.splice(index, 1); + + this.model.set(this.name, itemList, {ui: true}); + + this.clearView(this.composeViewKey(key)); + + this.$el.find(`.item[data-key="${key}"`).remove(); + + this.trigger('change'); + }, + + fetch: function () { + let itemList = []; + + this.itemKeyList.forEach(key => { + itemList.push( + this.getItemView(key).fetch() + ); + }); + + let data = {}; + + data[this.name] = Espo.Utils.cloneDeep(itemList); + + if (data[this.name].length === 0) { + data[this.name] = null; + } + + return data; + }, + + validateRequired: function () { + if (!this.isRequired()) { + return false; + } + + if (this.getItemListFromModel().length) { + return false; + } + + let msg = this.translate('fieldIsRequired', 'messages') + .replace('{field}', this.getLabelText()); + + this.showValidationMessage(msg, '.add-item-container'); + + return true; + }, + + validateValid: function () { + if (!this.isRangesInvalid()) { + return false; + } + + let msg = this.translate('fieldInvalid', 'messages') + .replace('{field}', this.getLabelText()); + + this.showValidationMessage(msg, '.add-item-container'); + + return true; + }, + + isRangesInvalid: function () { + let itemList = this.getItemListFromModel(); + + for (let i = 0; i < itemList.length; i++) { + let item = itemList[i]; + + if (this.isRangeInvalid(item[0], item[1], true)) { + return true; + } + + if (i === 0) { + continue; + } + + let prevItem = item[i - 1]; + + if (this.isRangeInvalid(prevItem[1], item[0])) { + return true; + } + } + + return false; + }, + + /** + * @param {string|null} from + * @param {string|null} to + * @param {boolean} [noEmpty] + */ + isRangeInvalid: function (from, to, noEmpty) { + if (from === null || to === null) { + return true; + } + + let fromNumber = parseFloat(from.replace(':', '.')); + let toNumber = parseFloat(to.replace(':', '.')); + + if (noEmpty && fromNumber === toNumber) { + return true; + } + + return fromNumber > toNumber; + }, + }); +}); diff --git a/client/src/views/working-time-calendar/fields/time-ranges/item-detail.js b/client/src/views/working-time-calendar/fields/time-ranges/item-detail.js new file mode 100644 index 0000000000..06e38dc76e --- /dev/null +++ b/client/src/views/working-time-calendar/fields/time-ranges/item-detail.js @@ -0,0 +1,70 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2022 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * EspoCRM is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * EspoCRM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with EspoCRM. If not, see http://www.gnu.org/licenses/. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU General Public License version 3. + * + * In accordance with Section 7(b) of the GNU General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +define('views/working-time-calendar/fields/time-ranges/item-detail', ['view', 'lib!moment'], +(Dep, /** @param {moment} */moment) => { + + /** + * @extends module:view.Class + */ + class Class extends Dep + { + templateContent = ` + {{start}} +  –  + {{end}} + ` + + data() { + return { + start: this.convertTimeToDisplay(this.value[0]), + end: this.convertTimeToDisplay(this.value[1]), + }; + } + + setup() { + this.value = this.options.value; + } + + convertTimeToDisplay(value) { + if (!value) { + return ''; + } + + let m = moment(value, 'HH:mm'); + + if (!m.isValid()) { + return ''; + } + + return m.format(this.getDateTime().timeFormat); + } + } + + return Class; +}); diff --git a/client/src/views/working-time-calendar/fields/time-ranges/item-edit.js b/client/src/views/working-time-calendar/fields/time-ranges/item-edit.js new file mode 100644 index 0000000000..da370a18ca --- /dev/null +++ b/client/src/views/working-time-calendar/fields/time-ranges/item-edit.js @@ -0,0 +1,168 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2022 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * EspoCRM is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * EspoCRM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with EspoCRM. If not, see http://www.gnu.org/licenses/. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU General Public License version 3. + * + * In accordance with Section 7(b) of the GNU General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +define('views/working-time-calendar/fields/time-ranges/item-edit', ['view', 'lib!moment'], function (Dep, moment) { + + return Dep.extend({ + + templateContent: ` +
    +
    + +
    +
    +  –  +
    +
    + +
    +
    + +
    +
    + `, + + timeFormatMap: { + 'HH:mm': 'H:i', + 'hh:mm A': 'h:i A', + 'hh:mm a': 'h:i a', + 'hh:mmA': 'h:iA', + 'hh:mma': 'h:ia', + }, + + minuteStep: 30, + + data: function () { + let data = {}; + + data.start = this.convertTimeToDisplay(this.value[0]); + data.end = this.convertTimeToDisplay(this.value[1]); + + data.key = this.key; + + return data; + }, + + setup: function () { + this.value = this.options.value || [null, null]; + this.key = this.options.key; + }, + + convertTimeToDisplay: function (value) { + if (!value) { + return ''; + } + + let m = moment(value, 'HH:mm'); + + if (!m.isValid()) { + return ''; + } + + return m.format(this.getDateTime().timeFormat); + }, + + convertTimeFromDisplay: function (value) { + if (!value) { + return null; + } + + let m = moment(value, this.getDateTime().timeFormat); + + if (!m.isValid()) { + return null; + } + + return m.format('HH:mm'); + }, + + afterRender: function () { + this.$start = this.$el.find('[data-name="start"]'); + this.$end = this.$el.find('[data-name="end"]'); + + this.initTimepicker(this.$start); + this.initTimepicker(this.$end); + + this.setMinTime(); + + this.$start.on('change', () => this.setMinTime()); + }, + + setMinTime: function () { + let value = this.$start.val(); + + this.$end.timepicker('option', 'maxTime', this.convertTimeToDisplay('23:59')); + + if (!value) { + this.$end.timepicker('option', 'minTime', null); + + return; + } + + this.$end.timepicker('option', 'minTime', value); + }, + + initTimepicker: function ($el) { + $el.timepicker({ + step: this.minuteStep, + timeFormat: this.timeFormatMap[this.getDateTime().timeFormat], + }); + + $el.on('change', () => this.trigger('change')); + + $el.attr('autocomplete', 'espo-time-range-item'); + }, + + fetch: function () { + return [ + this.convertTimeFromDisplay(this.$start.val()), + this.convertTimeFromDisplay(this.$end.val()), + ]; + }, + }); +}); diff --git a/client/src/views/working-time-range/fields/date-end.js b/client/src/views/working-time-range/fields/date-end.js new file mode 100644 index 0000000000..26230cc6fc --- /dev/null +++ b/client/src/views/working-time-range/fields/date-end.js @@ -0,0 +1,61 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2022 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * EspoCRM is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * EspoCRM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with EspoCRM. If not, see http://www.gnu.org/licenses/. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU General Public License version 3. + * + * In accordance with Section 7(b) of the GNU General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +define('views/working-time-range/fields/date-end', ['views/fields/date'], function (Dep) { + + + return Dep.extend({ + + setup: function () { + Dep.prototype.setup.call(this); + + this.validations.push('afterOrSame'); + }, + + validateAfterOrSame: function () { + let field = 'dateStart'; + + let value = this.model.get(this.name); + let otherValue = this.model.get(field); + + if (value && otherValue) { + if (moment(value).unix() < moment(otherValue).unix()) { + let msg = this.translate('fieldShouldAfter', 'messages') + .replace('{field}', this.getLabelText()) + .replace('{otherField}', this.translate(field, 'fields', this.model.entityType)); + + this.showValidationMessage(msg); + + return true; + } + } + + return false; + }, + }); +}); diff --git a/client/src/views/working-time-range/fields/users.js b/client/src/views/working-time-range/fields/users.js new file mode 100644 index 0000000000..a53f2c120c --- /dev/null +++ b/client/src/views/working-time-range/fields/users.js @@ -0,0 +1,37 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2022 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * EspoCRM is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * EspoCRM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with EspoCRM. If not, see http://www.gnu.org/licenses/. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU General Public License version 3. + * + * In accordance with Section 7(b) of the GNU General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +define('views/working-time-range/fields/users', ['views/fields/link-multiple'], function (Dep) { + + return Dep.extend({ + + getSelectPrimaryFilterName: function () { + return 'active'; + }, + }); +}); diff --git a/frontend/less/espo/custom.less b/frontend/less/espo/custom.less index 3fd9cbab3f..766cac1de0 100644 --- a/frontend/less/espo/custom.less +++ b/frontend/less/espo/custom.less @@ -2028,6 +2028,11 @@ label.attach-file-label { margin-right: 4px; } +.field-row-text-item { + margin-top: 7px; + display: inline-block; +} + .post-container > textarea.note, textarea.auto-height { overflow-x: hidden; diff --git a/frontend/less/espo/elements/type.less b/frontend/less/espo/elements/type.less index e8cebc868e..df6a34556a 100644 --- a/frontend/less/espo/elements/type.less +++ b/frontend/less/espo/elements/type.less @@ -22,6 +22,10 @@ margin-bottom: @table-cell-padding; } +.margin-bottom-sm { + margin-bottom: calc(@table-cell-padding / 2); +} + .margin-bottom-2x { margin-bottom: @table-cell-padding * 2; } @@ -38,6 +42,10 @@ margin-top: 0; } +.center-align { + text-align: center; +} + h1 { font-size: 30px; margin-top: 20px; diff --git a/frontend/less/espo/misc/fullcalendar/fullcalendar.less b/frontend/less/espo/misc/fullcalendar/fullcalendar.less index 9b2acc4903..d31c8488ee 100644 --- a/frontend/less/espo/misc/fullcalendar/fullcalendar.less +++ b/frontend/less/espo/misc/fullcalendar/fullcalendar.less @@ -65,8 +65,8 @@ } } -.fc-unthemed td.fc-today { - background: var(--calendar-today-bg) !important; +.fc-unthemed .fc-view-container td.fc-today { + background: var(--calendar-today-bg); } .fc-unthemed th, @@ -95,6 +95,10 @@ } } +.fc-view-container .fc-bgevent { + opacity: 0.5; +} + .fc.fc-unthemed { overflow: hidden; } diff --git a/frontend/less/espo/misc/timeline/timeline.less b/frontend/less/espo/misc/timeline/timeline.less index 50c331bb3c..7d3491c363 100644 --- a/frontend/less/espo/misc/timeline/timeline.less +++ b/frontend/less/espo/misc/timeline/timeline.less @@ -86,6 +86,10 @@ background-color: var(--calendar-busy-bg); } +.vis-item.vis-background.non-working { + opacity: 0.7; +} + .vis-item.vis-background.event-range { border-color: var(--brand-primary-lighten-10); border-left-width: 4px ; diff --git a/frontend/less/espo/value-variables.less b/frontend/less/espo/value-variables.less index e8f4be4e16..1e6607b1b4 100644 --- a/frontend/less/espo/value-variables.less +++ b/frontend/less/espo/value-variables.less @@ -196,7 +196,7 @@ @calendar-today-bg-value: #fcf8e3; @calendar-border-value: #d1d5d6; -@calendar-busy-bg-value: @gray-lighter-value; +@calendar-busy-bg-value: #d0beeb; @select-item-text-color-value: @text-color-value; @select-item-bg-value: #efefef; diff --git a/frontend/less/glass/variables.less b/frontend/less/glass/variables.less index 7edbd198e1..25cd24ad21 100644 --- a/frontend/less/glass/variables.less +++ b/frontend/less/glass/variables.less @@ -114,7 +114,7 @@ @calendar-today-bg-value: @table-bg-accent-value; @calendar-border-value: #666363; -@calendar-busy-bg-value: #2e25255c; +@calendar-busy-bg-value: #440c0c5c; @label-color-value: #dfdfdf; @label-default-bg-value: #534958; diff --git a/tests/unit/Espo/Tools/WorkingTime/CalendarUtilityTest.php b/tests/unit/Espo/Tools/WorkingTime/CalendarUtilityTest.php new file mode 100644 index 0000000000..5050c3bec8 --- /dev/null +++ b/tests/unit/Espo/Tools/WorkingTime/CalendarUtilityTest.php @@ -0,0 +1,267 @@ +calendar = $this->createMock(Calendar::class); + $this->extractor = $this->createMock(Extractor::class); + $this->calendarUtility = new CalendarUtility($this->calendar, $this->extractor); + } + + public function testHasWorkingDay1(): void + { + $time = DateTime::fromString('2023-01-01 01:01:01'); + + $from = DateTime::fromString('2023-01-01 00:00:00'); + $to = DateTime::fromString('2023-01-02 00:00:00'); + + $this->extractor + ->expects($this->any()) + ->method('extractAllDay') + ->with($this->calendar, $from, $to) + ->willReturn([ + [ + $from, + $to + ] + ]); + + $this->assertTrue($this->calendarUtility->isWorkingDay($time)); + } + + public function testHasWorkingTime1(): void + { + $from = DateTime::fromString('2023-01-01 00:00:00'); + $to = DateTime::fromString('2023-01-02 00:00:00'); + + $this->extractor + ->expects($this->any()) + ->method('extract') + ->with($this->calendar, $from, $to) + ->willReturn([ + [ + DateTime::fromString('2023-01-01 05:00:00'), + DateTime::fromString('2023-01-01 06:00:00') + ] + ]); + + $this->assertTrue($this->calendarUtility->hasWorkingTime($from, $to)); + } + + public function testHasWorkingTime2(): void + { + $from = DateTime::fromString('2023-01-01 00:00:00'); + $to = DateTime::fromString('2023-01-02 00:00:00'); + + $this->extractor + ->expects($this->any()) + ->method('extract') + ->with($this->calendar, $from, $to) + ->willReturn([]); + + $this->assertFalse($this->calendarUtility->hasWorkingTime($from, $to)); + } + + public function testGetSummedWorkingHours1(): void + { + $from = DateTime::fromString('2023-01-01 00:00:00'); + $to = DateTime::fromString('2023-01-02 00:00:00'); + + $this->extractor + ->expects($this->any()) + ->method('extract') + ->with($this->calendar, $from, $to) + ->willReturn([ + [ + DateTime::fromString('2023-01-01 05:00:00'), + DateTime::fromString('2023-01-01 06:00:00') + ], + [ + DateTime::fromString('2023-01-01 07:00:00'), + DateTime::fromString('2023-01-01 08:00:00') + ] + ]); + + $this->assertEquals(2.0, $this->calendarUtility->getSummedWorkingHours($from, $to)); + } + + public function testGetWorkingDays1(): void + { + $from = DateTime::fromString('2023-01-01 00:00:00'); + $to = DateTime::fromString('2023-01-07 00:00:00'); + + $this->extractor + ->expects($this->any()) + ->method('extractAllDay') + ->with($this->calendar, $from, $to) + ->willReturn([ + [ + DateTime::fromString('2023-01-01 00:00:00'), + DateTime::fromString('2023-01-02 00:00:00') + ], + [ + DateTime::fromString('2023-01-04 00:00:00'), + DateTime::fromString('2023-01-05 00:00:00') + ] + ]); + + $this->assertEquals(2, $this->calendarUtility->getWorkingDays($from, $to)); + } + + public function testFindClosestWorkingTime1(): void + { + $time = DateTime::fromString('2023-01-01 00:00:00'); + + $this->extractor + ->expects($this->exactly(2)) + ->method('extract') + ->withConsecutive( + [ + $this->calendar, + $time, + $time->modify('+10 days') + ], + [ + $this->calendar, + $time->modify('+10 days'), + $time->modify('+20 days'), + ] + ) + ->willReturnOnConsecutiveCalls( + [], + [ + [ + DateTime::fromString('2023-01-11 01:00:00'), + DateTime::fromString('2023-01-11 02:00:00') + ], + ] + ); + + $found = $this->calendarUtility->findClosestWorkingTime($time); + + $this->assertEquals(DateTime::fromString('2023-01-11 01:00:00'), $found); + } + + public function testFindClosestWorkingTime2(): void + { + $time = DateTime::fromString('2023-01-01 00:00:00'); + + $this->extractor + ->expects($this->exactly(20)) + ->method('extract') + ->willReturn([]); + + $found = $this->calendarUtility->findClosestWorkingTime($time); + + $this->assertEquals(null, $found); + } + + public function testAddWorkingDays1(): void + { + $time = DateTime::fromString('2023-01-01 01:00:00'); + + $point = $time->withTime(0, 0, 0)->modify('+1 day'); + + $this->extractor + ->expects($this->exactly(2)) + ->method('extractAllDay') + ->withConsecutive( + [ + $this->calendar, + $point, + $point->modify('+30 days') + ], + [ + $this->calendar, + $point->modify('+30 days'), + $point->modify('+60 days'), + ] + ) + ->willReturnOnConsecutiveCalls( + [], + [ + [ + DateTime::fromString('2023-04-01 00:00:00'), + DateTime::fromString('2023-04-01 00:00:00') + ], + ] + ); + + $found = $this->calendarUtility->addWorkingDays($time, 1); + + $this->assertEquals(DateTime::fromString('2023-04-01 00:00:00'), $found); + } + + public function testAddWorkingDays2(): void + { + $time = DateTime::fromString('2023-01-01 01:00:00'); + + $point = $time->withTime(0, 0, 0)->modify('+1 day'); + + $this->extractor + ->expects($this->exactly(1)) + ->method('extractAllDay') + ->withConsecutive( + [ + $this->calendar, + $point, + $point->modify('+30 days') + ], + ) + ->willReturnOnConsecutiveCalls( + [ + [ + DateTime::fromString('2023-01-02 00:00:00'), + DateTime::fromString('2023-01-03 00:00:00') + ], + [ + DateTime::fromString('2023-01-03 00:00:00'), + DateTime::fromString('2023-01-04 00:00:00') + ], + ] + ); + + $found = $this->calendarUtility->addWorkingDays($time, 2); + + $this->assertEquals(DateTime::fromString('2023-01-03 00:00:00'), $found); + } +} diff --git a/tests/unit/Espo/Tools/WorkingTime/ExtractorTest.php b/tests/unit/Espo/Tools/WorkingTime/ExtractorTest.php new file mode 100644 index 0000000000..3c4772586f --- /dev/null +++ b/tests/unit/Espo/Tools/WorkingTime/ExtractorTest.php @@ -0,0 +1,267 @@ +calendar = $this->createMock(Calendar::class); + } + + private function initCalendar1(): void + { + $this->calendar + ->expects($this->any()) + ->method('getTimeZone') + ->willReturn(new DateTimeZone('UTC')); + + $ranges = [ + new TimeRange(new Time(9, 0), new Time(13, 0)), + new TimeRange(new Time(14, 0), new Time(17, 0)), + ]; + + $this->calendar + ->expects($this->any()) + ->method('getWorkingDates') + ->willReturn([ + new WorkingDate( + new Date('2022-01-05'), + [ + new TimeRange(new Time(10, 30), new Time(15, 30)), + ] + ), + new WorkingDate( + new Date('2022-01-09'), + [ + new TimeRange(new Time(10, 30), new Time(15, 30)), + ] + ) + ]); + + $this->calendar + ->expects($this->any()) + ->method('getNonWorkingDates') + ->willReturn([ + new WorkingDate(new Date('2022-01-06')), + new WorkingDate(new Date('2022-01-10')), + ]); + + $this->calendar + ->expects($this->any()) + ->method('getWorkingWeekdays') + ->willReturn([ + new WorkingWeekday(1, $ranges), + new WorkingWeekday(2, $ranges), + new WorkingWeekday(3, $ranges), + new WorkingWeekday(4, $ranges), + new WorkingWeekday(5, $ranges), + ]); + } + + public function testExtract1(): void + { + $this->initCalendar1(); + $extractor = new Extractor(); + + $list = $extractor->extract( + $this->calendar, + DateTime::fromString('2022-01-02 00:00:00'), // sun + DateTime::fromString('2022-01-17 00:00:00') // sun + ); + + // mon + $this->assertEquals('2022-01-03 09:00:00', $list[0][0]->getString()); + $this->assertEquals('2022-01-03 13:00:00', $list[0][1]->getString()); + + $this->assertEquals('2022-01-03 14:00:00', $list[1][0]->getString()); + $this->assertEquals('2022-01-03 17:00:00', $list[1][1]->getString()); + + // tue + $this->assertEquals('2022-01-04 09:00:00', $list[2][0]->getString()); + $this->assertEquals('2022-01-04 13:00:00', $list[2][1]->getString()); + + $this->assertEquals('2022-01-04 14:00:00', $list[3][0]->getString()); + $this->assertEquals('2022-01-04 17:00:00', $list[3][1]->getString()); + + // wed working-date + $this->assertEquals('2022-01-05 10:30:00', $list[4][0]->getString()); + $this->assertEquals('2022-01-05 15:30:00', $list[4][1]->getString()); + + // thu non-working-date + + // fri + $this->assertEquals('2022-01-07 09:00:00', $list[5][0]->getString()); + $this->assertEquals('2022-01-07 13:00:00', $list[5][1]->getString()); + + $this->assertEquals('2022-01-07 14:00:00', $list[6][0]->getString()); + $this->assertEquals('2022-01-07 17:00:00', $list[6][1]->getString()); + + // sat + + // sun working-day + $this->assertEquals('2022-01-09 10:30:00', $list[7][0]->getString()); + $this->assertEquals('2022-01-09 15:30:00', $list[7][1]->getString()); + + // mon non-working-day + + // tue + $this->assertEquals('2022-01-11 09:00:00', $list[8][0]->getString()); + $this->assertEquals('2022-01-11 13:00:00', $list[8][1]->getString()); + + $this->assertEquals('2022-01-11 14:00:00', $list[9][0]->getString()); + $this->assertEquals('2022-01-11 17:00:00', $list[9][1]->getString()); + } + + public function testExtractInversion1(): void + { + $this->initCalendar1(); + $extractor = new Extractor(); + + $listInversion = $extractor->extractInversion( + $this->calendar, + DateTime::fromString('2022-01-02 00:00:00'), // sun + DateTime::fromString('2022-01-17 00:00:00') // sun + ); + + $this->assertEquals('2022-01-02 00:00:00', $listInversion[0][0]->getString()); + $this->assertEquals('2022-01-03 09:00:00', $listInversion[0][1]->getString()); + + $this->assertEquals('2022-01-14 13:00:00', $listInversion[15][0]->getString()); + $this->assertEquals('2022-01-14 14:00:00', $listInversion[15][1]->getString()); + + $this->assertEquals('2022-01-14 17:00:00', $listInversion[16][0]->getString()); + $this->assertEquals('2022-01-17 00:00:00', $listInversion[16][1]->getString()); + + + $listAllDayInversion = $extractor->extractAllDayInversion( + $this->calendar, + DateTime::fromString('2022-01-02 00:00:00'), // sun + DateTime::fromString('2022-01-17 00:00:00') // sun + ); + + $this->assertEquals('2022-01-02 00:00:00', $listAllDayInversion[0][0]->getString()); + $this->assertEquals('2022-01-03 00:00:00', $listAllDayInversion[0][1]->getString()); + + $this->assertEquals('2022-01-14 00:00:00', $listAllDayInversion[8][0]->getString()); + $this->assertEquals('2022-01-14 00:00:00', $listAllDayInversion[8][1]->getString()); + + $this->assertEquals('2022-01-15 00:00:00', $listAllDayInversion[9][0]->getString()); + $this->assertEquals('2022-01-17 00:00:00', $listAllDayInversion[9][1]->getString()); + } + + public function testExtractAllDay1(): void + { + $this->initCalendar1(); + $extractor = new Extractor(); + + $listAllDay = $extractor->extractAllDay( + $this->calendar, + DateTime::fromString('2022-01-02 00:00:00'), // sun + DateTime::fromString('2022-01-17 00:00:00') // sun + ); + + // mon + $this->assertEquals('2022-01-03 00:00:00', $listAllDay[0][0]->getString()); + $this->assertEquals('2022-01-04 00:00:00', $listAllDay[0][1]->getString()); + + // tue + $this->assertEquals('2022-01-04 00:00:00', $listAllDay[1][0]->getString()); + $this->assertEquals('2022-01-05 00:00:00', $listAllDay[1][1]->getString()); + + // wed + $this->assertEquals('2022-01-05 00:00:00', $listAllDay[2][0]->getString()); + $this->assertEquals('2022-01-06 00:00:00', $listAllDay[2][1]->getString()); + + // thu non-working-date + + // fri + $this->assertEquals('2022-01-07 00:00:00', $listAllDay[3][0]->getString()); + $this->assertEquals('2022-01-08 00:00:00', $listAllDay[3][1]->getString()); + + // sat + + // sun working-day + $this->assertEquals('2022-01-09 00:00:00', $listAllDay[4][0]->getString()); + $this->assertEquals('2022-01-10 00:00:00', $listAllDay[4][1]->getString()); + + // mon non-working-day + + // tue + $this->assertEquals('2022-01-11 00:00:00', $listAllDay[5][0]->getString()); + $this->assertEquals('2022-01-12 00:00:00', $listAllDay[5][1]->getString()); + } + + public function testExtract2(): void + { + $this->initCalendar1(); + $extractor = new Extractor(); + + $list = $extractor->extract( + $this->calendar, + DateTime::fromString('2022-01-03 10:00:00'), + DateTime::fromString('2022-01-14 15:00:00') + ); + + $this->assertEquals('2022-01-03 10:00:00', $list[0][0]->getString()); + $this->assertEquals('2022-01-14 15:00:00', $list[count($list) - 1][1]->getString()); + } + + public function testExtract3(): void + { + $this->initCalendar1(); + $extractor = new Extractor(); + + $list = $extractor->extract( + $this->calendar, + DateTime::fromString('2022-01-03 16:00:00'), + DateTime::fromString('2022-01-14 15:00:00') + ); + + $this->assertEquals('2022-01-03 16:00:00', $list[0][0]->getString()); + $this->assertEquals('2022-01-03 17:00:00', $list[0][1]->getString()); + + $this->assertEquals('2022-01-14 15:00:00', $list[count($list) - 1][1]->getString()); + } +}