'label.general_billable', 'GENERAL_NOT_BILLABLE' => 'label.general_not_billable', 'PROJECTMANAGEMENT' => 'label.projectmanagement', 'DEVELOPMENT' => 'label.development', 'BUGFIXING_NOT_BILLABLE' => 'label.bugfixing_not_billable', 'TESTING' => 'label.testing', ]; public function __construct( TimesheetRepository $timesheetsRepo, Users $userRepo, TicketRepository $ticketRepo ) { $this->timesheetsRepo = $timesheetsRepo; $this->userRepo = $userRepo; $this->ticketRepo = $ticketRepo; } /** * Non-throwing READ check for a timesheet belonging to $ownerUserId: own → timesheets.view, * else → timesheets.manage. For soft-deny reads (no cross-user existence oracle). */ private function canViewTimeForUser(int $ownerUserId): bool { return $ownerUserId === $this->currentUserId() ? $this->can(TimesheetsPermissions::VIEW) : $this->can(TimesheetsPermissions::MANAGE); } /** * isClocked - Checks to see whether a user is clocked in * * * * @api */ #[RequiresPermission(TimesheetsPermissions::VIEW, global: true)] public function isClocked(int $sessionId): false|array { return $this->timesheetsRepo->isClocked($sessionId); } /** * @api */ #[RequiresPermission(TimesheetsPermissions::CREATE, global: true)] public function punchIn(int $ticketId): mixed { return $this->timesheetsRepo->punchIn($ticketId); } /** * @api */ #[RequiresPermission(TimesheetsPermissions::CREATE, global: true)] public function punchOut(int $ticketId): float|false|int { return $this->timesheetsRepo->punchOut($ticketId); } /** * Stop the active timer for the current user (no ticket ID required) * * @api */ #[RequiresPermission(TimesheetsPermissions::CREATE, global: true)] public function stopActiveTimer(): float|false|int { $userId = session('userdata.id'); if (! $userId) { return false; } $clockedStatus = $this->timesheetsRepo->isClocked($userId); if ($clockedStatus === false || ! isset($clockedStatus['id'])) { return false; } return $this->timesheetsRepo->punchOut($clockedStatus['id']); } /** * logTime, will always add hours or increment existing values * * * * @throws BindingResolutionException * @throws MissingParameterException * * @api */ #[RequiresPermission(TimesheetsPermissions::CREATE, global: true, entityScoped: true)] public function logTime(int $ticketId, array $params): array|bool { // Editor+ to log any time; non-managers are pinned to their own account (cannot log for // another user — that needs timesheets.manage). $this->authorize(TimesheetsPermissions::CREATE); if (! $this->can(TimesheetsPermissions::MANAGE)) { $params['userId'] = $this->currentUserId(); } // @TODO: Change to use value objects for more type safeness. $values = [ 'userId' => $params['userId'] ?? session('userdata.id'), 'ticket' => $ticketId, 'date' => '', 'kind' => $params['kind'] ?? '', 'hours' => '', 'rate' => '', 'description' => '', 'invoicedEmpl' => '', 'invoicedComp' => '', 'invoicedEmplDate' => '', 'invoicedCompDate' => '', 'paid' => '', ]; if (! empty($params['dateString'])) { $values['date'] = dtHelper()->parseUserDateTime($params['dateString'], 'start')->formatDateTimeForDb(); } elseif (! empty($params['timestamp'])) { $values['date'] = CarbonImmutable::createFromTimestamp($params['timestamp'], 'UTC')->format('Y-m-d H:i:s'); } elseif (! empty($params['date']) && empty($params['time'])) { $values['date'] = dtHelper()->parseUserDateTime($params['date'], 'start')->formatDateTimeForDb(); } elseif (! empty($params['date'])) { $values['date'] = dtHelper()->parseUserDateTime($params['date'], $params['time'])->formatDateTimeForDb(); } else { throw new MissingParameterException('Date or timestamp is a required field'); } if (! isset($params['hours'])) { throw new MissingParameterException('Hours is a required field'); } if (! isset($params['kind'])) { throw new MissingParameterException('Timesheet type is a required field'); } $values['hours'] = $params['hours']; $values['description'] = $params['description'] ?? ''; $loggingUser = $this->userRepo->getUser($values['userId']); $values['rate'] = $loggingUser['wage']; $this->timesheetsRepo->addTime($values); return true; } /** * Upserts a time entry for a ticket. Will update hours based on the values provided, not touching descriptions * * @param int $ticketId The ID of the ticket. * @param array $params An associative array of parameters for the time entry. * - userId: The ID of the user creating the time entry. Defaults to the ID of the logged-in user. * - kind: The type of timesheet entry. Required. * - date: The date of the time entry. Required. * - hours: The number of hours for the time entry. Required. * @return array|bool Returns true if the time entry was successfully upserted. * * @throws MissingParameterException If any of the required parameters are missing. * @throws BindingResolutionException * * @api */ #[RequiresPermission(TimesheetsPermissions::CREATE, global: true, entityScoped: true)] public function upsertTime(int $ticketId, array $params): array|bool { // Editor+ to log any time; non-managers are pinned to their own account. $this->authorize(TimesheetsPermissions::CREATE); if (! $this->can(TimesheetsPermissions::MANAGE)) { $params['userId'] = $this->currentUserId(); } // @TODO: Change to use value objects for more type safeness. $values = [ 'userId' => $params['userId'] ?? session('userdata.id'), 'ticket' => $ticketId, 'date' => '', 'kind' => $params['kind'] ?? '', 'hours' => '', 'rate' => '', 'invoicedEmpl' => '', 'invoicedComp' => '', 'invoicedEmplDate' => '', 'invoicedCompDate' => '', 'paid' => '', ]; // Either date, dateString or timestamp is required if (! isset($params['date']) && empty($params['timestamp']) && empty($params['dateString'])) { throw new MissingParameterException('Date or timestamp is a required field'); } if (! isset($params['hours'])) { throw new MissingParameterException('Hours is a required field'); } if (! isset($params['kind'])) { throw new MissingParameterException('Timesheet type is a required field'); } if (! empty($params['dateString'])) { $values['date'] = dtHelper()->parseUserDateTime($params['dateString'], 'start')->formatDateTimeForDb(); } elseif (! empty($params['timestamp'])) { $values['date'] = CarbonImmutable::createFromTimestamp($params['timestamp'], 'UTC')->format('Y-m-d H:i:s'); } else { $values['date'] = dtHelper()->parseUserDateTime($params['date'], 'start')->formatDateTimeForDb(); } $values['hours'] = $params['hours']; $loggingUser = $this->userRepo->getUser($values['userId']); $values['rate'] = $loggingUser['wage']; $this->timesheetsRepo->upsertTimesheetEntry($values); return true; } /** * Delete a timesheet entry. * The caller must be the entry owner or have at least manager role. * * @param int $id The ID of the timesheet entry to delete * @return bool True if deleted, false if unauthorized or not found * * @api */ #[RequiresPermission(TimesheetsPermissions::DELETE, global: true, entityScoped: true)] public function deleteTime(int $id): bool { $timesheet = $this->timesheetsRepo->getTimesheet($id); if (! $timesheet) { return false; } $ownerId = (int) ($timesheet['userId'] ?? 0); // Own entry → timesheets.delete (editor+); another user's entry → timesheets.manage. $allowed = $ownerId === $this->currentUserId() ? $this->can(TimesheetsPermissions::DELETE) : $this->can(TimesheetsPermissions::MANAGE); if (! $allowed) { return false; } $this->timesheetsRepo->deleteTime($id); return true; } /** * Retrieve a single timesheet entry by ID. * * @param int $id The timesheet entry ID * @return mixed The timesheet entry or false if not found * * @api */ #[RequiresPermission(TimesheetsPermissions::VIEW, global: true, entityScoped: true)] public function getTimesheet(int $id): mixed { $timesheet = $this->timesheetsRepo->getTimesheet($id); if (! $timesheet) { return false; } // Soft-deny: own → timesheets.view, another user's → timesheets.manage. An unauthorized // entry returns the same false as a missing one (no cross-user existence oracle). if (! $this->canViewTimeForUser((int) ($timesheet['userId'] ?? 0))) { return false; } return $timesheet; } /** * Add a new time entry directly from a prepared values array. * * When called via API, the userId is forced to the authenticated user * unless the caller is a manager or above. * * @param array $values The time entry values * * @api */ #[RequiresPermission(TimesheetsPermissions::CREATE, global: true, entityScoped: true)] public function addTime(array $values): void { $this->authorize(TimesheetsPermissions::CREATE); // Non-managers can only add time entries for themselves. if (! $this->can(TimesheetsPermissions::MANAGE)) { $values['userId'] = $this->currentUserId(); } $this->timesheetsRepo->addTime($values); } /** * Update an existing time entry. * * When called via API, only the entry owner or a manager+ can update. * Non-managers cannot reassign entries to other users. * * @param array $values The updated time entry values * * @api */ #[RequiresPermission(TimesheetsPermissions::EDIT, global: true, entityScoped: true)] public function updateTime(array $values): void { $this->authorize(TimesheetsPermissions::EDIT); $currentUserId = $this->currentUserId(); // Editing an existing entry that belongs to ANOTHER user requires timesheets.manage. if (isset($values['id'])) { $existing = $this->timesheetsRepo->getTimesheet($values['id']); if ($existing && (int) $existing['userId'] !== $currentUserId && ! $this->can(TimesheetsPermissions::MANAGE)) { return; } } // Non-managers cannot reassign an entry to someone else. if (! $this->can(TimesheetsPermissions::MANAGE)) { $values['userId'] = $currentUserId; } $this->timesheetsRepo->updateTime($values); } /** * Process and save weekly timesheet entries from the weekly view form. * * Parses pipe-delimited form keys (ticketId|kind|date|timestamp) and upserts * each time entry. Returns an array of notification messages. * * @param array $postData The raw POST data from the weekly timesheet form * @return array{type: string, message: string}[] Notification messages * * @throws BindingResolutionException */ public function saveWeeklyTimesheetEntries(array $postData): array { $notifications = []; foreach ($postData as $key => $dateEntry) { $tempData = explode('|', $key); if (count($tempData) !== 4) { continue; } $ticketId = $tempData[0]; $kind = $tempData[1]; $date = $tempData[2]; $timestamp = $tempData[3]; $hours = $dateEntry; // $ticketId comes from explode('|', ...) so it's always a string; compare // as strings. The placeholder row is keyed "new|...", but treat a "0"/empty // id as a placeholder too rather than a real ticket. (#3210 review) $isNewEntryRow = in_array($ticketId, ['new', '0', ''], true); // The weekly grid includes an "add new task" placeholder row whose // day cells are submitted empty. There's nothing to log for that row // when no hours were entered, so skip it instead of pushing empty // values through upsertTime. Existing-ticket cells are intentionally // left alone here so clearing a previously logged value still works // (an emptied cell falls through and is cleaned up downstream). (#3210) if ($isNewEntryRow && ! is_numeric($hours)) { continue; } if ($isNewEntryRow) { $ticketId = (int) $postData['ticketId']; $kind = $postData['kindId']; if ($ticketId == 0 && $hours > 0) { $notifications[] = ['type' => 'error', 'message' => 'Task ID is required for new entries']; return $notifications; } } $values = [ 'userId' => session('userdata.id'), 'ticket' => $ticketId, 'date' => $date, 'timestamp' => $timestamp, 'hours' => $hours, 'kind' => $kind, ]; if ($timestamp === 'false' || $timestamp == false) { continue; } try { $this->upsertTime($ticketId, $values); $notifications[] = ['type' => 'success', 'message' => 'Timesheet saved successfully']; } catch (\Exception $e) { $notifications[] = ['type' => 'error', 'message' => 'Error logging time: '.$e->getMessage()]; report($e); } } return $notifications; } /** * @api */ public function getLoggedHoursForTicketByDate(int $ticketId): array { return $this->timesheetsRepo->getLoggedHoursForTicket($ticketId); } /** * @return int|mixed * * @api */ public function getSumLoggedHoursForTicket(int $ticketId): mixed { $result = $this->getLoggedHoursForTicketByDate($ticketId); $allHours = 0; foreach ($result as $row) { if ($row['summe']) { $allHours += $row['summe']; } } return $allHours; } /** * Get remaining hours for a ticket (planned hours minus logged hours) * * @param int|Tickets $ticketOrId Ticket ID or Tickets object * @return int|mixed * * @api */ public function getRemainingHours(int|Tickets $ticketOrId): mixed { // Support both ticket ID (for API calls) and Tickets object (for internal use) if ($ticketOrId instanceof Tickets) { $ticketId = $ticketOrId->id; $planHours = $ticketOrId->planHours; } else { $ticketId = $ticketOrId; // Fetch plan hours from repository $planHours = $this->timesheetsRepo->getTicketPlanHours($ticketId); } $totalHoursLogged = $this->getSumLoggedHoursForTicket($ticketId); $remaining = $planHours - $totalHoursLogged; if ($remaining < 0) { $remaining = 0; } return $remaining; } /** * @return int|mixed * * @api */ #[RequiresPermission(TimesheetsPermissions::VIEW, global: true, entityScoped: true)] public function getUsersTicketHours(int $ticketId, int $userId): mixed { // Own hours render on the ticket page for any role with ticket access; ANOTHER user's // hours require timesheets.manage (soft-deny to 0, no cross-user leak). if ($userId !== $this->currentUserId() && ! $this->can(TimesheetsPermissions::MANAGE)) { return 0; } return $this->timesheetsRepo->getUsersTicketHours($ticketId, $userId); } /** * @return array|string[] * * @api */ public function getLoggableHourTypes(): array { return $this->timesheetsRepo->kind; } /** * @api */ #[RequiresPermission(TimesheetsPermissions::VIEW, global: true, entityScoped: true)] public function getAll(CarbonInterface $dateFrom, CarbonInterface $dateTo, int $projectId = -1, string $kind = 'all', ?int $userId = null, string $invEmpl = '-1', string $invComp = '-1', string $ticketFilter = '-1', string $paid = '-1', string $clientId = '-1'): array|false { // Scoped to your OWN entries (an explicit current-user filter) → timesheets.view (editor+). // Anything broader — another user, or the all-users company report (userId null) — needs // timesheets.manage (manager+). Callers wanting their own data must pass their own userId. if ($userId !== null && $userId === $this->currentUserId()) { $this->authorize(TimesheetsPermissions::VIEW); } else { $this->authorize(TimesheetsPermissions::MANAGE); } return $this->timesheetsRepo->getAll( id: $projectId, kind: $kind, dateFrom: $dateFrom, dateTo: $dateTo, userId: $userId, invEmpl: $invEmpl, invComp: $invComp, paid: $paid, clientId: (int) $clientId, ticketFilter: (int) $ticketFilter ); } /** * @throws BindingResolutionException * * @api */ #[RequiresPermission(TimesheetsPermissions::VIEW, global: true, entityScoped: true)] public function getWeeklyTimesheets(int $projectId, CarbonInterface $fromDate, int $userId = 0): array { // Own week (default 0, or an explicit current-user filter) → timesheets.view; another // user's week → timesheets.manage. if ($userId === 0 || $userId === $this->currentUserId()) { $this->authorize(TimesheetsPermissions::VIEW); } else { $this->authorize(TimesheetsPermissions::MANAGE); } // Get timesheet entries and group by day $allTimesheets = $this->timesheetsRepo->getWeeklyTimesheets( projectId: $projectId, fromDate: $fromDate, userId: $userId ); // The week's day columns follow the user's LOCAL calendar days. Flat UTC +Nd math // drifts an hour across a DST transition, which put Monday entries into the previous // week's Sunday column (#3310). $weekStartLocal = CarbonImmutable::instance($fromDate)->setToUserTimezone()->startOfDay(); // Timesheets are grouped by ticketId + type $timesheetGroups = []; foreach ($allTimesheets as $timesheet) { try { $currentWorkDate = dtHelper()->parseDbDateTime($timesheet['workDate']); } catch (\Exception $e) { Log::warning($e); continue; } // Detect timezone offset $workdateOffsetStart = (int) ($currentWorkDate->setToUserTimezone()->secondsSinceMidnight() / 60 / 60); // Various Entries can be in different timezones and thus would not be caught by upsert or grouping by // default Creating new rows for each timezone adjustment // to avoid timezone collisions we disable adding new times to rows that were created in an different timezone $timezonedTime = $currentWorkDate->format('H:i:s'); $groupKey = $timesheet['ticketId'].'-'.$timesheet['kind'].'-'.$timezonedTime; if (! isset($timesheetGroups[$groupKey])) { $timesheetGroups[$groupKey] = [ 'kind' => $timesheet['kind'], 'clientName' => $timesheet['clientName'], 'name' => $timesheet['name'], 'headline' => $timesheet['headline'], 'ticketId' => $timesheet['ticketId'], 'hasTimesheetOffset' => $workdateOffsetStart !== 0, 'rowSum' => 0, ]; // Build the 7 day columns from the user's local calendar days and convert the // boundaries back to UTC. addDays() in the local timezone keeps each column // anchored at local midnight even when the week contains a DST transition. for ($i = 1; $i < 8; $i++) { $dayStartLocal = $weekStartLocal->addDays($i - 1); $dayStartUtc = $dayStartLocal->setToDbTimezone(); $timesheetGroups[$groupKey]['day'.$i] = [ 'start' => $dayStartUtc, 'end' => $dayStartLocal->addDay()->setToDbTimezone(), 'localDay' => $dayStartLocal->toDateString(), // Empty cells seed the workDate a new entry would get: that day's exact // local midnight (in UTC). Not derived from the first entry's time-of-day, // which would drift by the DST hour across a transition and store the new // entry an hour off local midnight (it feeds upsertTime via the form key). 'actualWorkDate' => $workdateOffsetStart === 0 ? $dayStartUtc : '', 'hours' => 0, 'description' => '', ]; } } // The stored workDate is the user's local midnight converted to UTC — but possibly // under a different UTC offset than today (entry logged before a DST switch, or the // user changed timezones). Converted to the current user timezone such an entry sits // shortly before or after midnight; rounding to the nearest midnight recovers the // calendar day the user actually logged, so a Monday entry can never render in the // previous week's Sunday column (#3310). $entryLocalTime = $currentWorkDate->setToUserTimezone(); $intendedDay = $entryLocalTime->hour >= 12 ? $entryLocalTime->addDay()->toDateString() : $entryLocalTime->toDateString(); for ($i = 1; $i < 8; $i++) { if ($intendedDay === $timesheetGroups[$groupKey]['day'.$i]['localDay']) { $timesheetGroups[$groupKey]['day'.$i]['hours'] += $timesheet['hours']; $timesheetGroups[$groupKey]['day'.$i]['actualWorkDate'] = $currentWorkDate; $timesheetGroups[$groupKey]['day'.$i]['description'] = $timesheet['description']; $timesheetGroups[$groupKey]['rowSum'] += $timesheet['hours']; break; } } } // The repository over-fetches by 12h on both sides of the week; drop groups whose // entries all belong to an adjacent week so they don't render as empty rows. return array_filter($timesheetGroups, fn (array $group) => $group['rowSum'] > 0); } /** * @api */ #[RequiresPermission(TimesheetsPermissions::MANAGE, global: true)] public function updateInvoices(array $invEmpl, array $invComp = [], array $paid = []): bool { // Marking entries invoiced/paid is a company-wide management action (manager+). This // closes a critical IDOR: previously any caller could flip invoice/paid flags on // arbitrary timesheet ids. $this->authorize(TimesheetsPermissions::MANAGE); return $this->timesheetsRepo->updateInvoices($invEmpl, $invComp, $paid); } /** * @return array|string[] * * @api */ public function getBookedHourTypes(): array { return $this->timesheetsRepo->kind; } /** * @return array * * @api */ #[RequiresPermission(TimesheetsPermissions::VIEW, global: true)] public function pollForNewTimesheets(?int $projectId = null): array|false { $timesheets = $this->timesheetsRepo->getAllAccountTimesheets($projectId); foreach ($timesheets as $key => $timesheet) { $timesheets[$key] = $this->prepareDatesForApiResponse($timesheet); } return $timesheets; } /** * @return array * * @api */ #[RequiresPermission(TimesheetsPermissions::VIEW, global: true)] public function pollForUpdatedTimesheets(?int $projectId = null): array|false { $timesheets = $this->timesheetsRepo->getAllAccountTimesheets($projectId); foreach ($timesheets as $key => $timesheet) { $timesheets[$key] = $this->prepareDatesForApiResponse($timesheet); $timesheets[$key]['id'] = $timesheet['id'].'-'.$timesheet['modified']; } return $timesheets; } private function prepareDatesForApiResponse($timesheet) { if (dtHelper()->isValidDateString($timesheet['workDate'])) { $timesheet['workDate'] = dtHelper()->parseDbDateTime($timesheet['workDate'])->toIso8601ZuluString(); } else { $timesheet['workDate'] = null; } if (dtHelper()->isValidDateString($timesheet['invoicedEmplDate'])) { $timesheet['invoicedEmplDate'] = dtHelper()->parseDbDateTime($timesheet['invoicedEmplDate'])->toIso8601ZuluString(); } else { $timesheet['invoicedEmplDate'] = null; } if (dtHelper()->isValidDateString($timesheet['invoicedCompDate'])) { $timesheet['invoicedCompDate'] = dtHelper()->parseDbDateTime($timesheet['invoicedCompDate'])->toIso8601ZuluString(); } else { $timesheet['invoicedCompDate'] = null; } if (dtHelper()->isValidDateString($timesheet['paidDate'])) { $timesheet['paidDate'] = dtHelper()->parseDbDateTime($timesheet['paidDate'])->toIso8601ZuluString(); } else { $timesheet['paidDate'] = null; } if (dtHelper()->isValidDateString($timesheet['modified'])) { $timesheet['modified'] = dtHelper()->parseDbDateTime($timesheet['modified'])->toIso8601ZuluString(); } else { $timesheet['modified'] = null; } return $timesheet; } /** * Returns the tickets that belong to a user, used to populate the * add/edit/weekly timesheet ticket pickers. * * @param int $userId The user whose tickets to fetch * @param int $limit Maximum number of tickets to return (-1 for no limit) * @return array The user's tickets (empty array when none found) * * @throws BindingResolutionException * * @api */ #[RequiresPermission(TimesheetsPermissions::VIEW, global: true, entityScoped: true)] public function getUsersTickets(int $userId, int $limit = -1): array { // Your own ticket picker → timesheets.view; another user's tickets → timesheets.manage. if (! $this->canViewTimeForUser($userId)) { return []; } $tickets = $this->ticketRepo->getUsersTickets($userId, $limit); return $tickets === false ? [] : $tickets; } /** * Returns the default empty values array for a new time entry form. * * @return array The default form values */ public function getDefaultTimeValues(): array { return [ 'userId' => session('userdata.id'), 'ticket' => '', 'project' => '', 'date' => '', 'kind' => '', 'hours' => '', 'description' => '', 'invoicedEmpl' => '', 'invoicedComp' => '', 'invoicedEmplDate' => '', 'invoicedCompDate' => '', 'paid' => '', 'paidDate' => '', ]; } /** * Parses raw POST data from the add-time form into a values array. * * Splits the pipe-delimited ticket/project field, converts the user date to * UTC, and applies the manager-gated invoice/paid field assembly. * * @param array $post The raw POST data * @param array|null $values Starting values (defaults applied when null) * @return array The parsed values array */ public function parseAddTimePostValues(array $post, ?array $values = null): array { $values ??= $this->getDefaultTimeValues(); if (isset($post['tickets']) && $post['tickets'] != '') { $tempArr = explode('|', $post['tickets']); $values['project'] = $tempArr[0]; $values['ticket'] = $tempArr[1]; } if (! empty($post['kind'])) { $values['kind'] = $post['kind']; } if (! empty($post['date'])) { $values['date'] = (new Carbon($post['date'], session('usersettings.timezone')))->setTimezone('UTC'); } if (! empty($post['hours'])) { $values['hours'] = $post['hours']; } if (! empty($post['invoicedEmpl']) && $post['invoicedEmpl'] == 'on') { $values['invoicedEmpl'] = 1; if (! empty($post['invoicedEmplDate'])) { $values['invoicedEmplDate'] = Carbon::now(session('usersettings.timezone'))->setTimezone('UTC'); } } if (! empty($post['invoicedComp']) && $this->can(TimesheetsPermissions::MANAGE)) { if ($post['invoicedComp'] == 'on') { $values['invoicedComp'] = 1; } if (! empty($post['invoicedCompDate'])) { $values['invoicedCompDate'] = Carbon::now(session('usersettings.timezone'))->setTimezone('UTC'); } } if (! empty($post['paid']) && $this->can(TimesheetsPermissions::MANAGE)) { if ($post['paid'] == 'on') { $values['paid'] = 1; } if (! empty($post['paidDate'])) { $values['paidDate'] = Carbon::now(session('usersettings.timezone'))->setTimezone('UTC'); } } if (! empty($post['description'])) { $values['description'] = $post['description']; } return $values; } /** * Validates the add-time values and, when valid, saves the entry. * * Returns a status string mirroring the controller's previous info codes * (NO_TICKET / NO_KIND / NO_DATE / NO_HOURS / TIME_SAVED) so external * behavior (notification keys) stays identical. * * @param array $values The parsed time entry values * @return string The status code describing the outcome */ public function validateAndSaveTime(array $values): string { if ($values['ticket'] == '' || $values['project'] == '') { return 'NO_TICKET'; } if ($values['kind'] == '') { return 'NO_KIND'; } if ($values['date'] == '') { return 'NO_DATE'; } if ($values['hours'] == '' || $values['hours'] <= 0) { return 'NO_HOURS'; } $this->addTime($values); return 'TIME_SAVED'; } /** * Builds a values array from a stored timesheet entry, normalizing the * EMPTY_DATE sentinel to 'now' and hydrating dates into Carbon instances. * * @param int $id The timesheet entry ID * @return array|null The values array, or null if the timesheet is not found */ public function getTimesheetForEdit(int $id): ?array { $timesheet = $this->getTimesheet($id); if (! $timesheet) { return null; } $timesheet['invoicedEmplDate'] = $timesheet['invoicedEmplDate'] == self::EMPTY_DATE ? 'now' : $timesheet['invoicedEmplDate']; $timesheet['invoicedCompDate'] = $timesheet['invoicedCompDate'] == self::EMPTY_DATE ? 'now' : $timesheet['invoicedCompDate']; $timesheet['paidDate'] = $timesheet['paidDate'] == self::EMPTY_DATE ? 'now' : $timesheet['paidDate']; return [ 'id' => $id, 'userId' => $timesheet['userId'], 'ticket' => $timesheet['ticketId'], 'project' => $timesheet['projectId'], 'date' => new Carbon($timesheet['workDate'], 'UTC'), 'kind' => $timesheet['kind'], 'hours' => $timesheet['hours'], 'description' => $timesheet['description'], 'invoicedEmpl' => $timesheet['invoicedEmpl'], 'invoicedComp' => $timesheet['invoicedComp'], 'invoicedEmplDate' => new Carbon($timesheet['invoicedEmplDate'], 'UTC'), 'invoicedCompDate' => new Carbon($timesheet['invoicedCompDate'], 'UTC'), 'paid' => $timesheet['paid'], 'paidDate' => new Carbon($timesheet['paidDate'], 'UTC'), ]; } /** * Applies basic POST field updates from the edit-time form to a values array. * * @param array $post The raw POST data * @param array $values The current values array * @return array The updated values array */ public function applyEditTimePostUpdates(array $post, array $values): array { if (! empty($post['tickets'])) { $values['project'] = (int) $post['projects']; $values['ticket'] = (int) $post['tickets']; } if (! empty($post['kind'])) { $values['kind'] = $post['kind']; } if (! empty($post['date'])) { $values['date'] = dtHelper()->parseUserDateTime($post['date'], 'start')->formatDateTimeForDb(); } if (! empty($post['hours'])) { $values['hours'] = (float) $post['hours']; } if (! empty($post['description'])) { $values['description'] = $post['description']; } return $values; } /** * Processes the manager-gated invoice and payment fields from the edit-time * form, applying the on/off toggles and default-now date fallbacks. * * @param array $post The raw POST data * @param array $values The current values array * @return array The updated values array */ public function processEditTimeInvoiceFields(array $post, array $values): array { if (! $this->can(TimesheetsPermissions::MANAGE)) { return $values; } if (! empty($post['invoicedEmpl'])) { if ($post['invoicedEmpl'] == 'on') { $values['invoicedEmpl'] = 1; } $values['invoicedEmplDate'] = ! empty($post['invoicedEmplDate']) ? dtHelper()->parseUserDateTime($post['invoicedEmplDate'], 'start')->formatDateTimeForDb() : dtHelper()->userNow()->formatDateTimeForDb(); } else { $values['invoicedEmpl'] = 0; $values['invoicedEmplDate'] = ''; } if (! empty($post['invoicedComp'])) { if ($post['invoicedComp'] == 'on') { $values['invoicedComp'] = 1; } $values['invoicedCompDate'] = ! empty($post['invoicedCompDate']) ? dtHelper()->parseUserDateTime($post['invoicedCompDate'], 'start')->formatDateTimeForDb() : dtHelper()->userNow()->formatDateTimeForDb(); } else { $values['invoicedComp'] = 0; $values['invoicedCompDate'] = ''; } if (! empty($post['paid'])) { if ($post['paid'] == 'on') { $values['paid'] = 1; } if (! empty($post['paidDate'])) { $date = dtHelper()->parseUserDateTime($post['paidDate'], 'start'); $date->setTimezone('UTC'); $values['paidDate'] = $date->formatDateTimeForDb(); } else { $values['paidDate'] = dtHelper()->userNow()->formatDateTimeForDb(); } } else { $values['paid'] = 0; $values['paidDate'] = ''; } return $values; } /** * Validates the edit-time values and, when valid, updates the entry. * * Returns a result describing the outcome: a notification message key and * type, plus the refreshed values reloaded from the database on success. * This preserves the controller's previous behavior exactly. * * @param int $id The timesheet entry ID * @param array $values The parsed time entry values * @return array{notification: array{message: string, type: string}|null, values: array} */ public function validateAndUpdateTime(int $id, array $values): array { if ($values['ticket'] == '' || $values['project'] == '') { return ['notification' => ['message' => 'notifications.time_logged_error_no_ticket', 'type' => 'error'], 'values' => $values]; } if ($values['kind'] == '') { return ['notification' => ['message' => 'notifications.time_logged_error_no_kind', 'type' => 'error'], 'values' => $values]; } if ($values['date'] == '') { return ['notification' => ['message' => 'notifications.time_logged_error_no_date', 'type' => 'error'], 'values' => $values]; } if ($values['hours'] == '' || $values['hours'] <= 0) { return ['notification' => ['message' => 'notifications.time_logged_error_no_hours', 'type' => 'error'], 'values' => $values]; } $notification = ['message' => 'notifications.time_logged_success', 'type' => 'success']; try { $this->updateTime($values); } catch (\Exception $e) { Log::error($e); $notification = ['message' => 'notifications.could_not_store_time', 'type' => 'error']; } $refreshed = $this->getTimesheetForEdit($id); if ($refreshed !== null) { $values = $refreshed; } return ['notification' => $notification, 'values' => $values]; } /** * Normalizes the raw showAll filter POST data into the canonical filter * shape consumed by the timesheet list view, owning the strip_tags / on-off * coercion and -1 defaulting. * * @param array $post The raw POST data * @return array{dateFrom: CarbonInterface, dateTo: CarbonInterface, kind: string, userId: ?int, invEmplCheck: string, invCompCheck: string, paidCheck: string, projectFilter: int|string, ticketFilter: int|string, clientId: int|string} */ public function buildShowAllFilters(array $post): array { $kind = ! empty($post['kind']) ? strip_tags($post['kind']) : 'all'; $userId = ! empty($post['userId']) ? (int) strip_tags($post['userId']) : null; $dateFrom = dtHelper()->userNow()->startOfWeek(CarbonInterface::MONDAY)->setToDbTimezone(); if (! empty($post['dateFrom'])) { $dateFrom = dtHelper()->parseUserDateTime($post['dateFrom'])->setToDbTimezone(); } $dateTo = dtHelper()->userNow()->endOfMonth()->setToDbTimezone(); if (! empty($post['dateTo'])) { $dateTo = dtHelper()->parseUserDateTime($post['dateTo'])->setToDbTimezone(); } $invEmplCheck = isset($post['invEmpl']) ? ($post['invEmpl'] == 'all' ? '-1' : $post['invEmpl']) : '-1'; $invCompCheck = '0'; if (isset($post['invComp'])) { $invCompCheck = $post['invComp'] == 'on' ? '1' : '0'; } $paidCheck = '0'; if (isset($post['paid'])) { $paidCheck = $post['paid'] == 'on' ? '1' : '0'; } $projectFilter = ! empty($post['project']) ? strip_tags($post['project']) : -1; $ticketFilter = ! empty($post['ticket']) ? strip_tags($post['ticket']) : -1; $clientId = ! empty($post['clientId']) ? strip_tags($post['clientId']) : -1; return [ 'dateFrom' => $dateFrom, 'dateTo' => $dateTo, 'kind' => $kind, 'userId' => $userId, 'invEmplCheck' => $invEmplCheck, 'invCompCheck' => $invCompCheck, 'paidCheck' => $paidCheck, 'projectFilter' => $projectFilter, 'ticketFilter' => $ticketFilter, 'clientId' => $clientId, ]; } /** * Resolves the effective ticket filter for the showAll list. * * When a ticket filter is set, the selected ticket's project is compared to * the selected project filter; a mismatch (or no project selected) collapses * the ticket filter to '-1' so getAll() receives an already-resolved value. * * The selected ticket's project ID is passed in (null when no accessible * ticket was found) to keep the permission-checked ticket lookup in the * caller and avoid a circular Tickets/Timesheets service dependency. * * @param int|string $projectFilter The selected project filter * @param int|string $ticketFilter The selected ticket filter * @param int|string|null $selectedTicketProjectId The selected ticket's project ID, or null when none * @return string The resolved ticket filter */ public function resolveShowAllTicketFilter(int|string $projectFilter, int|string $ticketFilter, int|string|null $selectedTicketProjectId): string { $projectMismatch = false; if ($ticketFilter != '' && $ticketFilter != -1) { if ($selectedTicketProjectId !== null && $selectedTicketProjectId != $projectFilter) { $projectMismatch = true; } } return $projectMismatch ? '-1' : ($projectFilter == -1 ? '-1' : ($ticketFilter ?: '-1')); } /** * Returns the weekly timesheets for a user along with the list of ticket IDs * already present in those timesheets (used to pre-select rows in the view). * * @param int $projectId Project filter (-1 for all) * @param CarbonInterface $fromDate The start-of-week date * @param int $userId The user whose timesheets to load * @return array{timesheets: array, existingTicketIds: array} * * @throws BindingResolutionException */ public function getWeeklyTimesheetsWithTicketIds(int $projectId, CarbonInterface $fromDate, int $userId = 0): array { $timesheets = $this->getWeeklyTimesheets($projectId, $fromDate, $userId); $existingTicketIds = array_map(fn ($item) => $item['ticketId'], $timesheets); return [ 'timesheets' => $timesheets, 'existingTicketIds' => $existingTicketIds, ]; } /** * Parses a user-supplied weekly-view start date into a DB-timezone date. * * On parse failure the original fallback date is returned and the failure is * logged with the user's timezone/date-format diagnostics, mirroring the * controller's previous behavior. The boolean flag indicates whether parsing * failed so the caller can surface a notification. * * @param string $startDate The raw user-supplied start date * @param CarbonInterface $fallback The date to use when parsing fails * @return array{date: CarbonInterface, failed: bool} */ public function parseWeeklyStartDate(string $startDate, CarbonInterface $fallback): array { try { return ['date' => dtHelper()->parseUserDateTime($startDate)->setToDbTimezone(), 'failed' => false]; } catch (\Exception $e) { Log::warning($e); Log::warning('User timezone: '.session('usersettings.timezone')); Log::warning('User dateTime format: '.session('usersettings.date_format')); return ['date' => $fallback, 'failed' => true]; } } }