mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-15 11:42:15 +01:00
add break time entries and simplified time tracker ui
This commit is contained in:
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\TimeEntryType;
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
@@ -154,6 +155,7 @@ class DashboardService
|
||||
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
|
||||
->where('user_id', '=', $user->getKey())
|
||||
->where('organization_id', '=', $organization->getKey())
|
||||
->workTime()
|
||||
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
|
||||
->orderBy('date');
|
||||
|
||||
@@ -195,6 +197,7 @@ class DashboardService
|
||||
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
|
||||
->where('user_id', '=', $user->getKey())
|
||||
->where('organization_id', '=', $organization->getKey())
|
||||
->workTime()
|
||||
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
|
||||
->orderBy('date');
|
||||
|
||||
@@ -222,7 +225,8 @@ class DashboardService
|
||||
$query = TimeEntry::query()
|
||||
->select(DB::raw('round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
|
||||
->where('user_id', '=', $user->getKey())
|
||||
->where('organization_id', '=', $organization->getKey());
|
||||
->where('organization_id', '=', $organization->getKey())
|
||||
->workTime();
|
||||
|
||||
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
|
||||
/** @var Collection<int, object{aggregate: int}> $resultDb */
|
||||
@@ -290,6 +294,7 @@ class DashboardService
|
||||
->select(DB::raw('project_id, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
|
||||
->where('user_id', '=', $user->getKey())
|
||||
->where('organization_id', '=', $organization->getKey())
|
||||
->workTime()
|
||||
->groupBy('project_id');
|
||||
|
||||
$query = $this->constrainDateByCurrentWeek($query, $timezone, $user->week_start);
|
||||
@@ -433,7 +438,8 @@ class DashboardService
|
||||
JOIN time_entries ON time_entries.start < time_ranges."end"
|
||||
AND coalesce(time_entries."end", :now::timestamp) > time_ranges.start
|
||||
WHERE time_entries.user_id = :user_id and
|
||||
time_entries.organization_id = :organization_id
|
||||
time_entries.organization_id = :organization_id and
|
||||
time_entries.type = :work_type
|
||||
GROUP BY time_ranges.start
|
||||
ORDER BY time_ranges.start
|
||||
', [
|
||||
@@ -442,6 +448,7 @@ class DashboardService
|
||||
'user_id' => $user->getKey(),
|
||||
'organization_id' => $organization->getKey(),
|
||||
'now' => Carbon::now()->toDateTimeString(),
|
||||
'work_type' => TimeEntryType::Work->value,
|
||||
]))->pluck('aggregate', 'start');
|
||||
|
||||
$response = [];
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Enums\TagMatchType;
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Enums\TimeEntryType;
|
||||
use App\Enums\Weekday;
|
||||
use App\Service\TimeEntryFilter;
|
||||
use Illuminate\Contracts\Database\Eloquent\Castable;
|
||||
@@ -68,6 +69,8 @@ class ReportPropertiesDto implements Castable
|
||||
|
||||
public ?int $roundingMinutes = null;
|
||||
|
||||
public ?TimeEntryType $timeEntryType = null;
|
||||
|
||||
/**
|
||||
* Get the caster class to use when casting from / to this cast target.
|
||||
*
|
||||
@@ -129,6 +132,8 @@ class ReportPropertiesDto implements Castable
|
||||
$dto->roundingType = isset($data->roundingType) ? TimeEntryRoundingType::from($data->roundingType) : null;
|
||||
// Note: roundingMinutes was added later so it is possible that the value is missing in persisted reports in the DB
|
||||
$dto->roundingMinutes = isset($data->roundingMinutes) ? (int) $data->roundingMinutes : null;
|
||||
// Note: timeEntryType was added later so it is possible that the value is missing in persisted reports in the DB
|
||||
$dto->timeEntryType = isset($data->timeEntryType) ? TimeEntryType::from($data->timeEntryType) : null;
|
||||
|
||||
return $dto;
|
||||
}
|
||||
@@ -157,6 +162,7 @@ class ReportPropertiesDto implements Castable
|
||||
'timezone' => $value->timezone,
|
||||
'roundingType' => $value->roundingType?->value,
|
||||
'roundingMinutes' => $value->roundingMinutes,
|
||||
'timeEntryType' => $value->timeEntryType?->value,
|
||||
];
|
||||
|
||||
$jsonString = json_encode($data);
|
||||
|
||||
@@ -107,6 +107,7 @@ class ExportService
|
||||
'end',
|
||||
'billable_rate',
|
||||
'billable',
|
||||
'type',
|
||||
'member_id',
|
||||
'user_id',
|
||||
'organization_id',
|
||||
@@ -131,6 +132,7 @@ class ExportService
|
||||
$timeEntry->end?->toIso8601ZuluString() ?? '',
|
||||
$timeEntry->billable_rate ?? '',
|
||||
$timeEntry->billable ? 'true' : 'false',
|
||||
$timeEntry->type->value,
|
||||
$timeEntry->member_id,
|
||||
$timeEntry->user_id,
|
||||
$timeEntry->organization_id,
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Enums\TimeEntryType;
|
||||
use App\Jobs\RecalculateSpentTimeForProject;
|
||||
use App\Jobs\RecalculateSpentTimeForTask;
|
||||
use App\Models\TimeEntry;
|
||||
@@ -71,8 +72,12 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
'role' => Role::Placeholder->value,
|
||||
]);
|
||||
$member = $this->memberImportHelper->getModelById($memberId);
|
||||
// Clockify allows a project/task/client/tags/billable on breaks, but those are
|
||||
// meaningless for non-work time. Detect breaks up front and skip creating any of
|
||||
// that so a break can't spawn an orphan project/tag or inflate the import counts.
|
||||
$isBreak = isset($record['Type']) && strtolower($record['Type']) === 'break';
|
||||
$clientId = null;
|
||||
if (($record['Client'] ?? '') !== '') {
|
||||
if (! $isBreak && ($record['Client'] ?? '') !== '') {
|
||||
$clientId = $this->clientImportHelper->getKey([
|
||||
'name' => $record['Client'],
|
||||
'organization_id' => $this->organization->id,
|
||||
@@ -81,7 +86,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
$projectId = null;
|
||||
$project = null;
|
||||
$projectMember = null;
|
||||
if ($record['Project'] !== '') {
|
||||
if (! $isBreak && $record['Project'] !== '') {
|
||||
$projectId = $this->projectImportHelper->getKey([
|
||||
'name' => $record['Project'],
|
||||
'client_id' => $clientId,
|
||||
@@ -97,7 +102,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
]);
|
||||
}
|
||||
$taskId = null;
|
||||
if ($taskKey !== null && $record[$taskKey] !== '') {
|
||||
if (! $isBreak && $taskKey !== null && $record[$taskKey] !== '') {
|
||||
$taskId = $this->taskImportHelper->getKey([
|
||||
'name' => $record[$taskKey],
|
||||
'project_id' => $projectId,
|
||||
@@ -123,7 +128,12 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
}
|
||||
$timeEntry->billable = $record['Billable'] === 'Yes';
|
||||
}
|
||||
$timeEntry->tags = $this->getTags($record['Tags']);
|
||||
if ($isBreak) {
|
||||
// Breaks can not be billable or belong to a project/task (already skipped above)
|
||||
$timeEntry->type = TimeEntryType::Break;
|
||||
$timeEntry->billable = false;
|
||||
}
|
||||
$timeEntry->tags = $isBreak ? [] : $this->getTags($record['Tags']);
|
||||
$timeEntry->is_imported = true;
|
||||
|
||||
// Start
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Enums\TimeEntryType;
|
||||
use App\Jobs\RecalculateSpentTimeForProject;
|
||||
use App\Jobs\RecalculateSpentTimeForTask;
|
||||
use App\Models\TimeEntry;
|
||||
@@ -255,6 +256,14 @@ class SolidtimeImporter extends DefaultImporter
|
||||
throw new ImportException('Invalid billable value');
|
||||
}
|
||||
$timeEntry->billable = $timeEntryRow['billable'] === 'true';
|
||||
// The type column does not exist in old exports
|
||||
if (($timeEntryRow['type'] ?? '') !== '') {
|
||||
$type = TimeEntryType::tryFrom($timeEntryRow['type']);
|
||||
if ($type === null) {
|
||||
throw new ImportException('Invalid type value');
|
||||
}
|
||||
$timeEntry->type = $type;
|
||||
}
|
||||
$timeEntry->tags = $this->getTags($timeEntryRow['tags']);
|
||||
$timeEntry->is_imported = true;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service\ReportExport;
|
||||
|
||||
use App\Enums\TimeEntryType;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\IntervalService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -25,6 +26,7 @@ class TimeEntriesDetailedCsvExport extends CsvExport
|
||||
'Duration',
|
||||
'Duration (decimal)',
|
||||
'Billable',
|
||||
'Break',
|
||||
'Tags',
|
||||
];
|
||||
|
||||
@@ -58,6 +60,7 @@ class TimeEntriesDetailedCsvExport extends CsvExport
|
||||
'Duration' => $duration !== null ? $interval->format($model->getDuration()) : null,
|
||||
'Duration (decimal)' => $duration?->totalHours,
|
||||
'Billable' => $model->billable ? 'Yes' : 'No',
|
||||
'Break' => $model->type === TimeEntryType::Break ? 'Yes' : 'No',
|
||||
'Tags' => $model->tagsRelation->pluck('name')->implode(', '),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Service\ReportExport;
|
||||
|
||||
use App\Enums\ExportFormat;
|
||||
use App\Enums\TimeEntryType;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\LocalizationService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -106,6 +107,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
|
||||
'Duration',
|
||||
'Duration (decimal)',
|
||||
'Billable',
|
||||
'Break',
|
||||
'Tags',
|
||||
];
|
||||
}
|
||||
@@ -130,6 +132,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
|
||||
$duration !== null ? $this->localizationService->formatInterval($duration) : null,
|
||||
$duration?->totalHours,
|
||||
$model->billable ? 'Yes' : 'No',
|
||||
$model->type === TimeEntryType::Break ? 'Yes' : 'No',
|
||||
$model->tagsRelation->pluck('name')->implode(', '),
|
||||
];
|
||||
} elseif ($this->exportFormat === ExportFormat::ODS) {
|
||||
@@ -144,6 +147,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
|
||||
$duration !== null ? $this->localizationService->formatInterval($duration) : null,
|
||||
$duration?->totalHours,
|
||||
$model->billable ? 'Yes' : 'No',
|
||||
$model->type === TimeEntryType::Break ? 'Yes' : 'No',
|
||||
$model->tagsRelation->pluck('name')->implode(', '),
|
||||
];
|
||||
} else {
|
||||
|
||||
@@ -353,6 +353,13 @@ class TimeEntryAggregationService
|
||||
'color' => null,
|
||||
];
|
||||
}
|
||||
} elseif ($type === TimeEntryAggregationType::Type) {
|
||||
foreach ($keys as $key) {
|
||||
$descriptorMap[$key] = [
|
||||
'description' => $key === 'break' ? 'Break' : 'Work time',
|
||||
'color' => null,
|
||||
];
|
||||
}
|
||||
} elseif ($type === TimeEntryAggregationType::Tag) {
|
||||
$tags = Tag::query()
|
||||
->whereIn('id', $keys)
|
||||
@@ -504,6 +511,8 @@ class TimeEntryAggregationService
|
||||
return 'client_id';
|
||||
} elseif ($group === TimeEntryAggregationType::Billable) {
|
||||
return 'billable';
|
||||
} elseif ($group === TimeEntryAggregationType::Type) {
|
||||
return 'type';
|
||||
} elseif ($group === TimeEntryAggregationType::Description) {
|
||||
return 'description';
|
||||
} elseif ($group === TimeEntryAggregationType::Tag) {
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\TagMatchType;
|
||||
use App\Enums\TimeEntryType;
|
||||
use App\Models\Member;
|
||||
use App\Models\TimeEntry;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -144,6 +145,32 @@ class TimeEntryFilter
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addTypeFilter(?string $type): self
|
||||
{
|
||||
if ($type === null) {
|
||||
return $this;
|
||||
}
|
||||
$typeEnum = TimeEntryType::tryFrom($type);
|
||||
if ($typeEnum === null) {
|
||||
Log::warning('Invalid type filter value', ['value' => $type]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
$this->addType($typeEnum);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addType(?TimeEntryType $type): self
|
||||
{
|
||||
if ($type === null) {
|
||||
return $this;
|
||||
}
|
||||
$this->builder->where('type', '=', $type->value);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string>|null $clientIds
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user