Compare commits

...

35 Commits

Author SHA1 Message Date
Constantin Graf
ccc07c4235 Enhanced description for Clockify imports 2024-12-20 19:46:41 -05:00
Constantin Graf
453dbaac9e Fixed timezones in unit tests 2024-12-20 19:39:38 -05:00
Constantin Graf
62270382dc Fixed import lock 2024-12-18 11:26:49 -05:00
Constantin Graf
29929467f6 Fixed overlapping labels in PDF report 2024-12-18 11:20:32 -05:00
Gregor Vostrak
02fe89dfdf Update README.md 2024-12-17 17:38:34 +01:00
Gregor Vostrak
03550a0ca6 add request free trial text to upgrade modal 2024-12-17 17:03:54 +01:00
Gregor Vostrak
2f1056dddb change report default to public 2024-12-17 15:21:23 +01:00
Gregor Vostrak
6e226cd743 hide report table for users that do not already have reports and cannot report new ones 2024-12-17 13:03:59 +01:00
Gregor Vostrak
19ed966504 fix icons alignment in billing upgrade buttons 2024-12-17 12:55:29 +01:00
Gregor Vostrak
33818f10b3 improve detailed report so that the table header has a border on the new page 2024-12-09 17:29:44 +01:00
Gregor Vostrak
ee9d818d75 add name of shared report to title attribute 2024-12-09 17:24:04 +01:00
Gregor Vostrak
e3d8457523 add week_start default for unauthenticated shared reports view 2024-12-09 17:11:56 +01:00
Gregor Vostrak
67e42a0a54 improve pdf index export to prevent overflows 2024-12-09 16:58:06 +01:00
Gregor Vostrak
fdbf88a9a6 fix selects inside of focus trap not working on click select 2024-12-09 16:33:57 +01:00
Gregor Vostrak
c4daca32c5 add modal focus trap & fix design bug in project billable section 2024-12-09 15:45:28 +01:00
Gregor Vostrak
4e10f9538f add export modal to prevent firefox popup blocking behaviour 2024-12-09 15:29:44 +01:00
Gregor Vostrak
959cad8f74 fix main chart label not cutting off for big numbers on the top 2024-12-09 12:57:25 +01:00
Gregor Vostrak
e308ca78b1 improve design for time entries index export 2024-12-09 12:57:25 +01:00
Gregor Vostrak
4281736a6d automatically set the project billable default in time entry create modal 2024-12-09 12:57:25 +01:00
Gregor Vostrak
9b0cf37bc7 improve aggregated pdf design 2024-12-09 12:57:25 +01:00
Constantin Graf
a4f3e014d9 Add debug flag to pdf export 2024-12-09 12:57:25 +01:00
Gregor Vostrak
32bce2f749 fix reporting descriptions for nested group 2024-12-09 12:57:25 +01:00
Gregor Vostrak
ae7f5a98e7 add Today option to Date Range Picker 2024-12-09 12:57:25 +01:00
Gregor Vostrak
e3f981aac2 add missing data to public shared reports, add premium restrictions, add pdf download 2024-12-09 12:57:25 +01:00
Constantin Graf
bcb298bd6d Updated dedoc/scramble composer dependency 2024-12-09 12:57:25 +01:00
Constantin Graf
620c4c97dc Updated PDF footer and added pie chart to aggregate report 2024-12-09 12:57:25 +01:00
Constantin Graf
05da595470 Add wait for report with chart 2024-12-09 12:57:25 +01:00
Constantin Graf
a4d8a02b80 Updated PDF reports 2024-12-09 12:57:25 +01:00
Constantin Graf
0860aa9d24 Added shareable reports 2024-12-09 12:57:25 +01:00
Gregor Vostrak
9c82efdf07 add reporting submenus to navbar 2024-12-09 12:57:25 +01:00
Gregor Vostrak
2560619c15 add shared reports section in the frontend 2024-12-09 12:57:25 +01:00
Constantin Graf
c03aad1abd Added shareable reports 2024-12-09 12:57:25 +01:00
Constantin Graf
0ee0175f04 Prevent stray requests in unit tests 2024-12-02 17:40:01 +01:00
Constantin Graf
0c1f06face Change default generate key env to single line 2024-12-02 15:00:29 +01:00
Gregor Vostrak
86d625b18a add discount banner 2024-11-25 13:21:35 +01:00
113 changed files with 5947 additions and 732 deletions

View File

@@ -13,7 +13,7 @@ solidtime is a modern open-source time tracking application for Freelancers and
- Time tracking: Track your time with a modern and easy-to-use interface
- Projects: Create and manage projects and assign project members
- Tasks: Create and manage tasks and assign tasks to project members
- Tasks: Create and manage tasks and assign tasks to projects
- Clients: Create and manage clients and assign clients to projects
- Billable rates: Set billable rates for projects, project members, organization members and organizations
- Multiple organizations: Create and manage multiple organizations with one account

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Report;
use App\Models\Report;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use LogicException;
class ReportSetExpiredToPrivateCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'report:set-expired-to-private '.
' { --dry-run : Do not actually save anything to the database, just output what would happen }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Makes public reports private if the public_until date has passed.';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->comment('Makes public reports private if the public_until date has passed...');
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->comment('Running in dry-run mode. Nothing will be saved to the database.');
}
$resetReports = 0;
Report::query()
->where('public_until', '<', Carbon::now())
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $reports) use ($dryRun, &$resetReports): void {
/** @var Collection<int, Report> $reports */
foreach ($reports as $report) {
$publicUntil = $report->public_until;
if ($publicUntil === null) {
throw new LogicException('public_until should not be null');
}
$this->info('Make report "'.$report->name.'" ('.$report->getKey().') private, expired: '.
$publicUntil->toIso8601ZuluString().' ('.$publicUntil->diffForHumans().')');
$resetReports++;
if (! $dryRun) {
$report->is_public = false;
$report->share_secret = null;
$report->save();
}
}
});
$this->comment('Finished setting '.$resetReports.' expired reports to private...');
return self::SUCCESS;
}
}

View File

@@ -18,6 +18,7 @@ class SelfHostGenerateKeysCommand extends Command
*/
protected $signature = 'self-host:generate-keys
{ --length=4096 : The length of the passport private key }
{ --multi-line : Whether to output the keys in multiple lines }
{ --format=env : The format of the output (env, yaml) }';
/**
@@ -34,6 +35,7 @@ class SelfHostGenerateKeysCommand extends Command
{
$format = $this->option('format');
$key = RSA::createKey((int) $this->option('length'));
$multiLine = (bool) $this->option('multi-line');
$publicKey = (string) $key->getPublicKey();
$privateKey = (string) $key;
@@ -41,12 +43,17 @@ class SelfHostGenerateKeysCommand extends Command
if ($format === 'env') {
$this->line('APP_KEY="'.$appKey.'"');
$this->line('PASSPORT_PRIVATE_KEY="'.$privateKey.'"');
$this->line('PASSPORT_PUBLIC_KEY="'.$publicKey.'"');
if ($multiLine) {
$this->line('PASSPORT_PRIVATE_KEY="'.Str::replace("\r\n", "\n", $privateKey).'"');
$this->line('PASSPORT_PUBLIC_KEY="'.Str::replace("\r\n", "\n", $publicKey).'"');
} else {
$this->line('PASSPORT_PRIVATE_KEY="'.Str::replace("\r\n", '\n', $privateKey).'"');
$this->line('PASSPORT_PUBLIC_KEY="'.Str::replace("\r\n", '\n', $publicKey).'"');
}
} elseif ($format === 'yaml') {
$this->line('APP_KEY: "'.$appKey.'"');
$this->line("PASSPORT_PRIVATE_KEY: |\n ".Str::replace("\n", "\n ", $privateKey));
$this->line("PASSPORT_PUBLIC_KEY: |\n ".Str::replace("\n", "\n ", $publicKey));
$this->line("PASSPORT_PRIVATE_KEY: |\n ".Str::replace("\r\n", "\n ", $privateKey));
$this->line("PASSPORT_PUBLIC_KEY: |\n ".Str::replace("\r\n", "\n ", $publicKey));
} else {
$this->error('Invalid format');

View File

@@ -4,10 +4,13 @@ declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
use Illuminate\Support\Carbon;
enum Weekday: string
{
use LaravelEnumHelper;
case Monday = 'monday';
case Tuesday = 'tuesday';
case Wednesday = 'wednesday';

View File

@@ -131,6 +131,8 @@ class MemberController extends Controller
}
/**
* Make a member a placeholder member
*
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization
*/
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse

View File

@@ -102,6 +102,7 @@ class ProjectController extends Controller
$project->is_billable = (bool) $request->input('is_billable');
$project->billable_rate = $request->getBillableRate();
$project->client_id = $request->input('client_id');
$project->is_public = $request->getIsPublic();
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$project->estimated_time = $request->getEstimatedTime();
}
@@ -127,6 +128,9 @@ class ProjectController extends Controller
if ($request->has('is_archived')) {
$project->archived_at = $request->getIsArchived() ? Carbon::now() : null;
}
if ($request->has('is_public')) {
$project->is_public = $request->boolean('is_public');
}
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$project->estimated_time = $request->getEstimatedTime();
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Public;
use App\Enums\TimeEntryAggregationType;
use App\Http\Controllers\Api\V1\Controller;
use App\Http\Resources\V1\Report\DetailedWithDataReportResource;
use App\Models\Report;
use App\Models\TimeEntry;
use App\Service\Dto\ReportPropertiesDto;
use App\Service\TimeEntryAggregationService;
use App\Service\TimeEntryFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
class ReportController extends Controller
{
/**
* Get report by a share secret
*
* This endpoint is public and does not require authentication. The report must be public and not expired.
* The report is considered expired if the `public_until` field is set and the date is in the past.
* The report is considered public if the `is_public` field is set to `true`.
*
* @operationId getPublicReport
*/
public function show(Request $request, TimeEntryAggregationService $timeEntryAggregationService): DetailedWithDataReportResource
{
$shareSecret = $request->header('X-Api-Key');
if (! is_string($shareSecret)) {
throw new ModelNotFoundException;
}
$report = Report::query()
->with([
'organization',
])
->where('share_secret', '=', $shareSecret)
->where('is_public', '=', true)
->where(function (Builder $builder): void {
/** @var Builder<Report> $builder */
$builder->whereNull('public_until')
->orWhere('public_until', '>', now());
})
->firstOrFail();
/** @var ReportPropertiesDto $properties */
$properties = $report->properties;
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($report->organization, 'organization');
$filter = new TimeEntryFilter($timeEntriesQuery);
$filter->addStart($properties->start);
$filter->addEnd($properties->end);
$filter->addActive($properties->active);
$filter->addBillable($properties->billable);
$filter->addMemberIdsFilter($properties->memberIds?->toArray());
$filter->addProjectIdsFilter($properties->projectIds?->toArray());
$filter->addTagIdsFilter($properties->tagIds?->toArray());
$filter->addTaskIdsFilter($properties->taskIds?->toArray());
$filter->addClientIdsFilter($properties->clientIds?->toArray());
$timeEntriesQuery = $filter->get();
$data = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesQuery->clone(),
$report->properties->group,
$report->properties->subGroup,
$report->properties->timezone,
$report->properties->weekStart,
false,
$report->properties->start,
$report->properties->end,
);
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesQuery->clone(),
TimeEntryAggregationType::fromInterval($report->properties->historyGroup),
null,
$report->properties->timezone,
$report->properties->weekStart,
true,
$report->properties->start,
$report->properties->end,
);
return new DetailedWithDataReportResource($report, $data, $historyData);
}
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Weekday;
use App\Http\Requests\V1\Report\ReportStoreRequest;
use App\Http\Requests\V1\Report\ReportUpdateRequest;
use App\Http\Resources\V1\Report\DetailedReportResource;
use App\Http\Resources\V1\Report\ReportCollection;
use App\Http\Resources\V1\Report\ReportResource;
use App\Models\Organization;
use App\Models\Report;
use App\Service\Dto\ReportPropertiesDto;
use App\Service\ReportService;
use App\Service\TimezoneService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
class ReportController extends Controller
{
/**
* @throws AuthorizationException
*/
protected function checkPermission(Organization $organization, string $permission, ?Report $report = null): void
{
parent::checkPermission($organization, $permission);
if ($report !== null && $report->organization_id !== $organization->id) {
throw new AuthorizationException('Report does not belong to organization');
}
}
/**
* Get reports
*
* @return ReportCollection<ReportResource>
*
* @throws AuthorizationException
*
* @operationId getReports
*/
public function index(Organization $organization): ReportCollection
{
$this->checkPermission($organization, 'reports:view');
$reports = Report::query()
->orderBy('created_at', 'desc')
->whereBelongsTo($organization, 'organization')
->paginate(config('app.pagination_per_page_default'));
return new ReportCollection($reports);
}
/**
* Get report
*
* @throws AuthorizationException
*
* @operationId getReport
*/
public function show(Organization $organization, Report $report): DetailedReportResource
{
$this->checkPermission($organization, 'reports:view', $report);
return new DetailedReportResource($report);
}
/**
* Create report
*
* @throws AuthorizationException
*
* @operationId createReport
*/
public function store(Organization $organization, ReportStoreRequest $request, TimezoneService $timezoneService, ReportService $reportService): DetailedReportResource
{
$this->checkPermission($organization, 'reports:create');
$user = $this->user();
$report = new Report;
$report->name = $request->getName();
$report->description = $request->getDescription();
$isPublic = $request->getIsPublic();
$report->is_public = $isPublic;
$properties = new ReportPropertiesDto;
$properties->group = $request->getPropertyGroup();
$properties->subGroup = $request->getPropertySubGroup();
$properties->historyGroup = $request->getPropertyHistoryGroup();
$properties->start = $request->getPropertyStart();
$properties->end = $request->getPropertyEnd();
$properties->active = $request->getPropertyActive();
$properties->setMemberIds($request->input('properties.member_ids', null));
$properties->billable = $request->getPropertyBillable();
$properties->setClientIds($request->input('properties.client_ids', null));
$properties->setProjectIds($request->input('properties.project_ids', null));
$properties->setTagIds($request->input('properties.tag_ids', null));
$properties->setTaskIds($request->input('properties.task_ids', null));
$properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start;
$timezone = $user->timezone;
if ($request->has('properties.timezone')) {
if ($timezoneService->isValid($request->input('properties.timezone'))) {
$timezone = $request->input('properties.timezone');
}
if ($timezoneService->mapLegacyTimezone($request->input('properties.timezone')) !== null) {
$timezone = $timezoneService->mapLegacyTimezone($request->input('properties.timezone'));
}
}
$properties->timezone = $timezone;
$report->properties = $properties;
if ($isPublic) {
$report->share_secret = $reportService->generateSecret();
$report->public_until = $request->getPublicUntil();
} else {
$report->share_secret = null;
$report->public_until = null;
}
$report->organization()->associate($organization);
$report->save();
return new DetailedReportResource($report);
}
/**
* Update report
*
* @throws AuthorizationException
*
* @operationId updateReport
*/
public function update(Organization $organization, Report $report, ReportUpdateRequest $request, ReportService $reportService): DetailedReportResource
{
$this->checkPermission($organization, 'reports:update', $report);
if ($request->has('name')) {
$report->name = $request->getName();
}
if ($request->has('description')) {
$report->description = $request->getDescription();
}
if ($request->has('is_public') && $request->getIsPublic() !== $report->is_public) {
$isPublic = $request->getIsPublic();
$report->is_public = $isPublic;
if ($isPublic) {
$report->share_secret = $reportService->generateSecret();
$report->public_until = $request->getPublicUntil();
} else {
$report->share_secret = null;
$report->public_until = null;
}
}
$report->save();
return new DetailedReportResource($report);
}
/**
* Delete report
*
* @throws AuthorizationException
*
* @operationId deleteReport
*/
public function destroy(Organization $organization, Report $report): JsonResponse
{
$this->checkPermission($organization, 'reports:delete', $report);
$report->delete();
return response()->json(null, 204);
}
}

View File

@@ -164,7 +164,7 @@ class TimeEntryController extends Controller
*
* @operationId exportTimeEntries
*/
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request): JsonResponse
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
{
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
@@ -173,6 +173,7 @@ class TimeEntryController extends Controller
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$debug = $request->getDebug();
$format = $request->getFormatValue();
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
throw new FeatureIsNotAvailableInFreePlanApiException;
@@ -195,19 +196,43 @@ class TimeEntryController extends Controller
$export = new TimeEntriesDetailedCsvExport(config('filesystems.private'), $folderPath, $filename, $timeEntriesQuery, 1000, $timezone);
$export->export();
} elseif ($format === ExportFormat::PDF) {
if (config('services.gotenberg.url') === null) {
if (config('services.gotenberg.url') === null && ! $debug) {
throw new PdfRendererIsNotConfiguredException;
}
$viewFile = file_get_contents(resource_path('views/reports/time-entry-index.blade.php'));
$viewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf.blade.php'));
if ($viewFile === false) {
throw new \LogicException('View file not found');
}
$html = Blade::render($viewFile, ['timeEntries' => $timeEntriesQuery->get()]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index-footer.blade.php'));
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesQuery->clone()->reorder()->withOnly([]),
null,
null,
$user->timezone,
$user->week_start,
false,
null,
null
);
$html = Blade::render($viewFile, [
'timeEntries' => $timeEntriesQuery->get(),
'aggregatedData' => $aggregatedData,
'timezone' => $timezone,
'currency' => $organization->currency,
'start' => $request->getStart()->timezone($timezone),
'end' => $request->getEnd()->timezone($timezone),
]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf-footer.blade.php'));
if ($footerViewFile === false) {
throw new \LogicException('View file not found');
}
$footerHtml = Blade::render($footerViewFile);
if ($debug) {
return response()->json([
'html' => $html,
'footer_html' => $footerHtml,
]);
}
$client = new Client([
'auth' => config('services.gotenberg.basic_auth_username') !== null && config('services.gotenberg.basic_auth_password') !== null ? [
config('services.gotenberg.basic_auth_username'),
@@ -216,7 +241,10 @@ class TimeEntryController extends Controller
]);
$request = Gotenberg::chromium(config('services.gotenberg.url'))
->pdf()
->pdfa('PDF/A-3b')
->assets(
Stream::path(resource_path('pdf/Outfit-VariableFont_wght.ttf'), 'outfit.ttf'),
)
->margins(0.39, 0.78, 0.39, 0.39)
->paperSize('8.27', '11.7') // A4
->footer(Stream::string('footer', $footerHtml))
->html(Stream::string('body', $html));
@@ -329,6 +357,7 @@ class TimeEntryController extends Controller
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
throw new FeatureIsNotAvailableInFreePlanApiException;
}
$debug = $request->getDebug();
$user = $this->user();
$group = $request->getGroup();
@@ -363,7 +392,7 @@ class TimeEntryController extends Controller
$path = $folderPath.'/'.$filename;
if ($format === ExportFormat::PDF) {
if (config('services.gotenberg.url') === null) {
if (config('services.gotenberg.url') === null && ! $debug) {
throw new PdfRendererIsNotConfiguredException;
}
$client = new Client([
@@ -372,7 +401,7 @@ class TimeEntryController extends Controller
config('services.gotenberg.basic_auth_password'),
] : null,
]);
$viewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate-index.blade.php'));
$viewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf.blade.php'));
if ($viewFile === false) {
throw new \LogicException('View file not found');
}
@@ -384,17 +413,28 @@ class TimeEntryController extends Controller
'subGroup' => $subGroup,
'start' => $request->getStart()->timezone($timezone),
'end' => $request->getEnd()->timezone($timezone),
'debug' => $debug,
]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index-footer.blade.php'));
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php'));
if ($footerViewFile === false) {
throw new \LogicException('View file not found');
}
$footerHtml = Blade::render($footerViewFile);
if ($debug) {
return response()->json([
'html' => $html,
'footer_html' => $footerHtml,
]);
}
$request = Gotenberg::chromium(config('services.gotenberg.url'))
->pdf()
->pdfa('PDF/A-3b')
->waitForExpression("window.status === 'ready'")
->margins(0.39, 0.78, 0.39, 0.39)
->paperSize('8.27', '11.7') // A4
->footer(Stream::string('footer', $footerHtml))
->assets(Stream::path(resource_path('pdf/echarts.min.js'), 'echarts.min.js'),
Stream::path(resource_path('pdf/Outfit-VariableFont_wght.ttf'), 'outfit.ttf'),
)
->html(Stream::string('body', $html));
$tempFolder = TemporaryDirectory::make();
$filenameTemp = Gotenberg::save($request, $tempFolder->path(), $client);

View File

@@ -68,9 +68,18 @@ class ProjectStoreRequest extends FormRequest
'min:0',
'max:2147483647',
],
// Whether the project is public
'is_public' => [
'boolean',
],
];
}
public function getIsPublic(): bool
{
return $this->has('is_public') && $this->boolean('is_public');
}
public function getBillableRate(): ?int
{
$input = $this->input('billable_rate');

View File

@@ -50,6 +50,9 @@ class ProjectUpdateRequest extends FormRequest
'is_archived' => [
'boolean',
],
'is_public' => [
'boolean',
],
'client_id' => [
'nullable',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Report;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\Weekday;
use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
/**
* @property Organization $organization Organization from model binding
*/
class ReportStoreRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|LegacyValidationRule>>
*/
public function rules(): array
{
return [
'name' => [
'required',
'string',
'max:255',
],
'description' => [
'nullable',
'string',
],
'is_public' => [
'required',
'boolean',
],
// After this date the report will be automatically set to private (is_public=false) (ISO 8601 format, UTC timezone)
'public_until' => [
'nullable',
'date_format:Y-m-d\TH:i:s\Z',
'after:now',
],
'properties' => [
'required',
'array',
],
'properties.start' => [
'required',
'date_format:Y-m-d\TH:i:s\Z',
],
'properties.end' => [
'required',
'date_format:Y-m-d\TH:i:s\Z',
],
'properties.active' => [
'nullable',
'boolean',
],
'properties.member_ids' => [
'nullable',
'array',
],
'properties.member_ids.*' => [
'string',
'uuid',
],
'properties.billable' => [
'nullable',
'boolean',
],
'properties.client_ids' => [
'nullable',
'array',
],
'properties.client_ids.*' => [
'string',
'uuid',
],
// Filter by project IDs, project IDs are OR combined
'properties.project_ids' => [
'nullable',
'array',
],
'properties.project_ids.*' => [
'string',
'uuid',
],
// Filter by tag IDs, tag IDs are OR combined
'properties.tag_ids' => [
'nullable',
'array',
],
'properties.tag_ids.*' => [
'string',
'uuid',
],
'properties.task_ids' => [
'nullable',
'array',
],
'properties.task_ids.*' => [
'string',
'uuid',
],
'properties.group' => [
'required',
Rule::enum(TimeEntryAggregationType::class),
],
'properties.sub_group' => [
'required',
Rule::enum(TimeEntryAggregationType::class),
],
'properties.history_group' => [
'required',
Rule::enum(TimeEntryAggregationTypeInterval::class),
],
'properties.week_start' => [
'nullable',
Rule::enum(Weekday::class),
],
'properties.timezone' => [
'nullable',
'timezone:all',
],
];
}
public function getName(): string
{
return (string) $this->input('name');
}
public function getDescription(): ?string
{
return $this->input('description');
}
public function getIsPublic(): bool
{
return (bool) $this->input('is_public');
}
public function getPublicUntil(): ?Carbon
{
$publicUntil = $this->input('public_until');
return $publicUntil === null ? null : Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $publicUntil);
}
public function getPropertyStart(): Carbon
{
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('properties.start'));
if ($start === null) {
throw new \LogicException('Start date validation is not working');
}
return $start;
}
public function getPropertyEnd(): Carbon
{
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('properties.end'));
if ($end === null) {
throw new \LogicException('End date validation is not working');
}
return $end;
}
public function getPropertyActive(): ?bool
{
if ($this->has('properties.active') && $this->input('properties.active') !== null) {
return (bool) $this->input('properties.active');
}
return null;
}
public function getPropertyBillable(): ?bool
{
if ($this->has('properties.billable') && $this->input('properties.billable') !== null) {
return (bool) $this->input('properties.billable');
}
return null;
}
public function getPropertyGroup(): TimeEntryAggregationType
{
return TimeEntryAggregationType::from($this->input('properties.group'));
}
public function getPropertySubGroup(): TimeEntryAggregationType
{
return TimeEntryAggregationType::from($this->input('properties.sub_group'));
}
public function getPropertyHistoryGroup(): TimeEntryAggregationTypeInterval
{
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Report;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
/**
* @property Organization $organization Organization from model binding
*/
class ReportUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'name' => [
'string',
'max:255',
],
'description' => [
'nullable',
'string',
],
'is_public' => [
'boolean',
],
'public_until' => [
'nullable',
'date_format:Y-m-d\TH:i:s\Z',
'after:now',
],
];
}
public function getName(): string
{
return (string) $this->input('name');
}
public function getDescription(): ?string
{
return $this->input('description');
}
public function getIsPublic(): bool
{
return (bool) $this->input('is_public');
}
public function getPublicUntil(): ?Carbon
{
$publicUntil = $this->input('public_until');
return $publicUntil === null ? null : Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $publicUntil);
}
}

View File

@@ -34,21 +34,23 @@ class TimeEntryAggregateExportRequest extends FormRequest
public function rules(): array
{
return [
// Data format of the export
'format' => [
'required',
'string',
Rule::enum(ExportFormat::class),
],
// Type of first grouping
'group' => [
'required',
Rule::enum(TimeEntryAggregationType::class),
],
// Type of second grouping
'sub_group' => [
'required',
Rule::enum(TimeEntryAggregationType::class),
],
// Type of grouping of the historic aggregation (time chart)
'history_group' => [
'required',
'nullable',
@@ -158,9 +160,18 @@ class TimeEntryAggregateExportRequest extends FormRequest
'string',
'in:true,false',
],
'debug' => [
'string',
'in:true,false',
],
];
}
public function getDebug(): bool
{
return $this->input('debug') === 'true';
}
public function getGroup(): TimeEntryAggregationType
{
return TimeEntryAggregationType::from($this->input('group'));
@@ -178,12 +189,22 @@ class TimeEntryAggregateExportRequest extends FormRequest
public function getStart(): Carbon
{
return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC');
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC');
if ($start === null) {
throw new \LogicException('Start date validation is not working');
}
return $start;
}
public function getEnd(): Carbon
{
return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC');
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC');
if ($end === null) {
throw new \LogicException('End date validation is not working');
}
return $end;
}
public function getFormatValue(): ExportFormat

View File

@@ -32,12 +32,13 @@ class TimeEntryAggregateRequest extends FormRequest
public function rules(): array
{
return [
// Type of first grouping
'group' => [
'nullable',
'required_with:group_2',
'required_with:sub_group',
Rule::enum(TimeEntryAggregationType::class),
],
// Type of second grouping
'sub_group' => [
'nullable',
Rule::enum(TimeEntryAggregationType::class),

View File

@@ -12,6 +12,7 @@ use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
@@ -96,14 +97,14 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
],
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [
'nullable',
'required',
'string',
'date_format:Y-m-d\TH:i:s\Z',
'before:end',
],
// Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'end' => [
'nullable',
'required',
'string',
'date_format:Y-m-d\TH:i:s\Z',
],
@@ -128,9 +129,38 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
'string',
'in:true,false',
],
'debug' => [
'string',
'in:true,false',
],
];
}
public function getDebug(): bool
{
return $this->input('debug', 'false') === 'true';
}
public function getStart(): Carbon
{
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC');
if ($start === null) {
throw new \LogicException('Start date validation is not working');
}
return $start;
}
public function getEnd(): Carbon
{
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC');
if ($end === null) {
throw new \LogicException('End date validation is not working');
}
return $end;
}
public function getOnlyFullDates(): bool
{
return $this->input('only_full_dates', 'false') === 'true';

View File

@@ -28,6 +28,8 @@ class PersonalMembershipResource extends BaseResource
'id' => $this->resource->organization->id,
/** @var string $name Name of organization */
'name' => $this->resource->organization->name,
/** @var string $currency Currency code (ISO 4217) of organization */
'currency' => $this->resource->organization->currency,
],
/** @var string $role Role */
'role' => $this->resource->role,

View File

@@ -45,6 +45,8 @@ class OrganizationResource extends BaseResource
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->currency,
];
}
}

View File

@@ -48,6 +48,8 @@ class ProjectResource extends BaseResource
'estimated_time' => $this->resource->estimated_time,
/** @var int $spent_time Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
'spent_time' => $this->resource->spent_time,
/** @var bool $is_public Whether the project is public */
'is_public' => $this->resource->is_public,
];
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Report;
use App\Http\Resources\V1\BaseResource;
use App\Models\Report;
use Illuminate\Http\Request;
/**
* @property Report $resource
*/
class DetailedReportResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|array<string, string|bool|int|null|array<int, string>>>
*/
public function toArray(Request $request): array
{
return [
/** @var string $id ID of the report */
'id' => $this->resource->id,
/** @var string $name Name */
'name' => $this->resource->name,
/** @var string|null $email Description */
'description' => $this->resource->description,
/** @var bool $is_public Whether the report can be accessed via an external link */
'is_public' => $this->resource->is_public,
/** @var string|null $public_until Date until the report is public */
'public_until' => $this->resource->public_until?->toIso8601ZuluString(),
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
'shareable_link' => $this->resource->getShareableLink(),
'properties' => [
/** @var string $group Type of first grouping */
'group' => $this->resource->properties->group->value,
/** @var string $sub_group Type of second grouping */
'sub_group' => $this->resource->properties->subGroup->value,
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value,
/** @var string $start Start date of the report */
'start' => $this->resource->properties->start->toIso8601ZuluString(),
/** @var string $end End date of the report */
'end' => $this->resource->properties->end->toIso8601ZuluString(),
/** @var bool|null $active Whether the report is active */
'active' => $this->resource->properties->active,
/** @var array<string>|null $member_ids Filter by multiple member IDs, member IDs are OR combined */
'member_ids' => $this->resource->properties->memberIds?->toArray(),
/** @var bool|null $billable Filter by billable status */
'billable' => $this->resource->properties->billable,
/** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */
'client_ids' => $this->resource->properties->clientIds?->toArray(),
/** @var array<string>|null $project_ids Filter by project IDs, project IDs are OR combined */
'project_ids' => $this->resource->properties->projectIds?->toArray(),
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->properties->taskIds?->toArray(),
],
/** @var string $created_at Date when the report was created */
'created_at' => $this->resource->created_at?->toIso8601ZuluString(),
/** @var string $updated_at Date when the report was last updated */
'updated_at' => $this->resource->updated_at?->toIso8601ZuluString(),
];
}
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Report;
use App\Http\Resources\V1\BaseResource;
use App\Models\Report;
use Illuminate\Http\Request;
/**
* @property Report $resource
*
* @phpstan-type Data array{
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* }
*/
class DetailedWithDataReportResource extends BaseResource
{
/**
* @var Data
*/
private array $data;
/**
* @var Data
*/
private array $historyData;
/**
* @param Data $data
* @param Data $historyData
*/
public function __construct(Report $resource, array $data, array $historyData)
{
parent::__construct($resource);
$this->data = $data;
$this->historyData = $historyData;
}
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|Data|array<string, string|bool|int|null|array<int, string>>>
*/
public function toArray(Request $request): array
{
return [
/** @var string $name Name */
'name' => $this->resource->name,
/** @var string|null $email Description */
'description' => $this->resource->description,
/** @var string|null $public_until Date until the report is public */
'public_until' => $this->resource->public_until?->toIso8601ZuluString(),
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->organization->currency,
'properties' => [
/** @var string $group Type of first grouping */
'group' => $this->resource->properties->group->value,
/** @var string $sub_group Type of second grouping */
'sub_group' => $this->resource->properties->subGroup->value,
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value,
/** @var string $start Start date of the report */
'start' => $this->resource->properties->start->toIso8601ZuluString(),
/** @var string $end End date of the report */
'end' => $this->resource->properties->end->toIso8601ZuluString(),
],
/** @var array{
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* } $data Aggregated data
*/
'data' => $this->data,
/** @var array{
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* } $history_data Historic aggregated data
*/
'history_data' => $this->historyData,
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Report;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ReportCollection extends ResourceCollection implements PaginatedResourceCollection
{
/**
* The resource that this resource collects.
*
* @var string
*/
public $collects = ReportResource::class;
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Report;
use App\Http\Resources\V1\BaseResource;
use App\Models\Report;
use Illuminate\Http\Request;
/**
* @property Report $resource
*/
class ReportResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|array<string>>
*/
public function toArray(Request $request): array
{
return [
/** @var string $id ID of the report */
'id' => $this->resource->id,
/** @var string $name Name */
'name' => $this->resource->name,
/** @var string|null $email Description */
'description' => $this->resource->description,
/** @var bool $is_public Whether the report can be accessed via an external link */
'is_public' => $this->resource->is_public,
/** @var string|null $public_until Date until the report is public */
'public_until' => $this->resource->public_until?->toIso8601ZuluString(),
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
'shareable_link' => $this->resource->getShareableLink(),
/** @var string $created_at Date when the report was created */
'created_at' => $this->resource->created_at?->toIso8601ZuluString(),
/** @var string $updated_at Date when the report was last updated */
'updated_at' => $this->resource->updated_at?->toIso8601ZuluString(),
];
}
}

64
app/Models/Report.php Normal file
View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Models\Concerns\HasUuids;
use App\Service\Dto\ReportPropertiesDto;
use Database\Factories\ReportFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* @property string $id
* @property string $name
* @property string|null $description
* @property string $organization_id
* @property bool $is_public
* @property Carbon|null $public_until
* @property string|null $share_secret
* @property ReportPropertiesDto $properties
* @property-read Organization $organization
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*
* @method static ReportFactory factory()
*/
class Report extends Model
{
/** @use HasFactory<ReportFactory> */
use HasFactory;
use HasUuids;
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'is_public' => 'bool',
'public_until' => 'datetime',
'properties' => ReportPropertiesDto::class,
];
public function getShareableLink(): ?string
{
if ($this->is_public && $this->share_secret !== null) {
return route('shared-report').'#'.$this->share_secret;
}
return null;
}
/**
* @return BelongsTo<Organization, Report>
*/
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
}

View File

@@ -104,7 +104,7 @@ class TimeEntry extends Model implements AuditableContract
public function getClientIdComputed(): ?string
{
return $this->project_id === null ? null : $this->project->client_id;
return $this->project_id === null || $this->project === null ? null : $this->project->client_id;
}
/**

View File

@@ -126,6 +126,10 @@ class JetstreamServiceProvider extends ServiceProvider
'members:update',
'members:delete',
'billing',
'reports:view',
'reports:create',
'reports:update',
'reports:delete',
])->description('Owner users can perform any action. There is only one owner per organization.');
Jetstream::role(Role::Admin->value, 'Administrator', [
@@ -170,6 +174,10 @@ class JetstreamServiceProvider extends ServiceProvider
'members:view',
'members:update',
'members:invite-placeholder',
'reports:view',
'reports:create',
'reports:update',
'reports:delete',
])->description('Administrator users can perform any action, except accessing the billing dashboard.');
Jetstream::role(Role::Manager->value, 'Manager', [
@@ -206,6 +214,10 @@ class JetstreamServiceProvider extends ServiceProvider
'organizations:view',
'invitations:view',
'members:view',
'reports:view',
'reports:create',
'reports:update',
'reports:delete',
])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).');
Jetstream::role(Role::Employee->value, 'Employee', [

View File

@@ -33,8 +33,12 @@ class ColorService
private const string VALID_REGEX = '/^#[0-9a-f]{6}$/';
public function getRandomColor(): string
public function getRandomColor(?string $seed = null): string
{
if ($seed !== null) {
srand(crc32($seed));
}
return self::COLORS[array_rand(self::COLORS)];
}

View File

@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace App\Service\Dto;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\Weekday;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class ReportPropertiesDto implements Castable
{
public TimeEntryAggregationType $group;
public TimeEntryAggregationType $subGroup;
public TimeEntryAggregationTypeInterval $historyGroup;
public Weekday $weekStart;
public string $timezone;
public Carbon $start;
public Carbon $end;
public ?bool $active = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $memberIds = null;
public ?bool $billable = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $clientIds = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $projectIds = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $tagIds = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $taskIds = null;
/**
* Get the caster class to use when casting from / to this cast target.
*
* @param array<string, mixed> $arguments
* @return CastsAttributes<ReportPropertiesDto, ReportPropertiesDto>
*/
public static function castUsing(array $arguments): CastsAttributes
{
return new class implements CastsAttributes
{
private const array REQUIRED_PROPERTIES = [
'group',
'subGroup',
'historyGroup',
'weekStart',
'timezone',
'start',
'end',
'active',
'memberIds',
'billable',
'clientIds',
'projectIds',
'tagIds',
'taskIds',
];
public function get(Model $model, string $key, mixed $value, array $attributes): ReportPropertiesDto
{
if (! is_string($value)) {
throw new \InvalidArgumentException('The given value is not a string');
}
$data = json_decode($value, false);
if ($data === null) {
throw new \InvalidArgumentException('The given value is not a JSON string');
}
foreach (self::REQUIRED_PROPERTIES as $property) {
if (! property_exists($data, $property)) {
throw new \InvalidArgumentException('The given JSON string does not contain the required property "'.$property.'"');
}
}
$dto = new ReportPropertiesDto;
$dto->end = $data->end !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->end) : null;
$dto->start = $data->start !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->start) : null;
$dto->active = $data->active;
$dto->memberIds = $data->memberIds !== null ? ReportPropertiesDto::idArrayToCollection($data->memberIds) : null;
$dto->billable = $data->billable;
$dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;
$dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;
$dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;
$dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
$dto->group = TimeEntryAggregationType::from($data->group);
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
$dto->weekStart = Weekday::from($data->weekStart);
$dto->timezone = $data->timezone;
return $dto;
}
/**
* @param ReportPropertiesDto $value
*/
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if (! ($value instanceof ReportPropertiesDto)) {
throw new \InvalidArgumentException('The given value is not an instance of ReportPropertiesDto');
}
$data = (object) [
'end' => $value->end->toIso8601ZuluString(),
'start' => $value->start->toIso8601ZuluString(),
'active' => $value->active,
'memberIds' => $value->memberIds?->toArray(),
'billable' => $value->billable,
'clientIds' => $value->clientIds?->toArray(),
'projectIds' => $value->projectIds?->toArray(),
'tagIds' => $value->tagIds?->toArray(),
'taskIds' => $value->taskIds?->toArray(),
'group' => $value->group->value,
'subGroup' => $value->subGroup->value,
'historyGroup' => $value->historyGroup->value,
'weekStart' => $value->weekStart->value,
'timezone' => $value->timezone,
];
$jsonString = json_encode($data);
if ($jsonString === false) {
throw new \InvalidArgumentException('Could not encode the given data to a JSON string');
}
return $jsonString;
}
};
}
/**
* @param array<mixed> $ids
* @return Collection<int, string>
*/
public static function idArrayToCollection(array $ids): Collection
{
$collection = new Collection;
foreach ($ids as $id) {
if (! is_string($id)) {
throw new \InvalidArgumentException('The given ID is not a string');
}
if (! Str::isUuid($id)) {
throw new \InvalidArgumentException('The given ID is not a valid UUID');
}
$collection->push($id);
}
return $collection;
}
/**
* @param array<mixed>|null $memberIds
*/
public function setMemberIds(?array $memberIds): void
{
$this->memberIds = $memberIds !== null ? ReportPropertiesDto::idArrayToCollection($memberIds) : null;
}
/**
* @param array<mixed>|null $clientIds
*/
public function setClientIds(?array $clientIds): void
{
$this->clientIds = $clientIds !== null ? ReportPropertiesDto::idArrayToCollection($clientIds) : null;
}
/**
* @param array<mixed>|null $projectIds
*/
public function setProjectIds(?array $projectIds): void
{
$this->projectIds = $projectIds !== null ? ReportPropertiesDto::idArrayToCollection($projectIds) : null;
}
/**
* @param array<mixed>|null $tagIds
*/
public function setTagIds(?array $tagIds): void
{
$this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
}
/**
* @param array<mixed>|null $taskIds
*/
public function setTaskIds(?array $taskIds): void
{
$this->taskIds = $taskIds !== null ? ReportPropertiesDto::idArrayToCollection($taskIds) : null;
}
}

View File

@@ -31,10 +31,13 @@ class ImportService
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);
if ($lock->get()) {
DB::transaction(function () use (&$importer, &$data, &$timezone): void {
$importer->importData($data, $timezone);
});
$lock->release();
try {
DB::transaction(function () use (&$importer, &$data, &$timezone): void {
$importer->importData($data, $timezone);
});
} finally {
$lock->release();
}
} else {
throw new ImportException('Import is already in progress');
}

View File

@@ -77,7 +77,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
public function view(): View
{
return view('reports.time-entry-aggregate-index-excel', [
return view('reports.time-entry-aggregate.spreadsheet', [
'data' => $this->data,
'currency' => $this->currency,
'group' => $this->group,

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Illuminate\Support\Str;
class ReportService
{
public function generateSecret(): string
{
return Str::random(40);
}
}

View File

@@ -146,12 +146,14 @@ class TimeEntryAggregationService
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
@@ -180,15 +182,17 @@ class TimeEntryAggregationService
}
}
$descriptionMapGroup1 = $group1Type !== null ? $this->loadDescriptionMap($keysGroup1, $group1Type) : [];
$descriptionMapGroup2 = $group2Type !== null ? $this->loadDescriptionMap($keysGroup2, $group2Type) : [];
$descriptionMapGroup1 = $group1Type !== null ? $this->loadDescriptorsMap($keysGroup1, $group1Type) : [];
$descriptionMapGroup2 = $group2Type !== null ? $this->loadDescriptorsMap($keysGroup2, $group2Type) : [];
if ($aggregatedTimeEntries['grouped_data'] !== null) {
foreach ($aggregatedTimeEntries['grouped_data'] as $keyGroup1 => $group1) {
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['description'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']] ?? null) : null;
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['description'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']]['description'] ?? null) : null;
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['color'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']]['color'] ?? null) : null;
if ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] !== null) {
foreach ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] as $keyGroup2 => $group2) {
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'][$keyGroup2]['description'] = $group2['key'] !== null ? ($descriptionMapGroup2[$group2['key']] ?? null) : null;
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'][$keyGroup2]['description'] = $group2['key'] !== null ? ($descriptionMapGroup2[$group2['key']]['description'] ?? null) : null;
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'][$keyGroup2]['color'] = $group2['key'] !== null ? ($descriptionMapGroup2[$group2['key']]['color'] ?? null) : null;
}
}
}
@@ -200,12 +204,14 @@ class TimeEntryAggregationService
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
@@ -222,33 +228,61 @@ class TimeEntryAggregationService
/**
* @param array<int, string> $keys
* @return array<string, string>
* @return array<string, array{
* description: string,
* color: string|null
* }>
*/
private function loadDescriptionMap(array $keys, TimeEntryAggregationType $type): array
private function loadDescriptorsMap(array $keys, TimeEntryAggregationType $type): array
{
$descriptorMap = [];
if ($type === TimeEntryAggregationType::Client) {
return Client::query()
$clients = Client::query()
->whereIn('id', $keys)
->pluck('name', 'id')
->toArray();
->select('id', 'name')
->get();
foreach ($clients as $client) {
$descriptorMap[$client->id] = [
'description' => $client->name,
'color' => null,
];
}
} elseif ($type === TimeEntryAggregationType::User) {
return User::query()
$users = User::query()
->whereIn('id', $keys)
->pluck('name', 'id')
->toArray();
->select('id', 'name')
->get();
foreach ($users as $user) {
$descriptorMap[$user->id] = [
'description' => $user->name,
'color' => null,
];
}
} elseif ($type === TimeEntryAggregationType::Project) {
return Project::query()
$projects = Project::query()
->whereIn('id', $keys)
->pluck('name', 'id')
->toArray();
->select('id', 'name', 'color')
->get();
foreach ($projects as $project) {
$descriptorMap[$project->id] = [
'description' => $project->name,
'color' => $project->color,
];
}
} elseif ($type === TimeEntryAggregationType::Task) {
return Task::query()
$tasks = Task::query()
->whereIn('id', $keys)
->pluck('name', 'id')
->toArray();
} else {
return [];
->select('id', 'name')
->get();
foreach ($tasks as $task) {
$descriptorMap[$task->id] = [
'description' => $task->name,
'color' => null,
];
}
}
return $descriptorMap;
}
/**

View File

@@ -30,7 +30,17 @@ class TimeEntryFilter
if ($dateTime === null) {
return $this;
}
$this->builder->where('start', '<', Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC'));
$this->addEnd(Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC'));
return $this;
}
public function addEnd(?Carbon $end): self
{
if ($end === null) {
return $this;
}
$this->builder->where('start', '<', $end);
return $this;
}
@@ -40,7 +50,17 @@ class TimeEntryFilter
if ($dateTime === null) {
return $this;
}
$this->builder->where('start', '>', Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC'));
$this->addStart(Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC'));
return $this;
}
public function addStart(?Carbon $start): self
{
if ($start === null) {
return $this;
}
$this->builder->where('start', '>', $start);
return $this;
}
@@ -51,9 +71,21 @@ class TimeEntryFilter
return $this;
}
if ($active === 'true') {
$this->builder->whereNull('end');
$this->addActive(true);
} elseif ($active === 'false') {
$this->addActive(false);
} else {
Log::warning('Invalid active filter value', ['value' => $active]);
}
if ($active === 'false') {
return $this;
}
public function addActive(?bool $active): self
{
if ($active) {
$this->builder->whereNull('end');
} else {
$this->builder->whereNotNull('end');
}
@@ -89,9 +121,9 @@ class TimeEntryFilter
return $this;
}
if ($billable === 'true') {
$this->builder->where('billable', '=', true);
$this->addBillable(true);
} elseif ($billable === 'false') {
$this->builder->where('billable', '=', false);
$this->addBillable(false);
} else {
Log::warning('Invalid billable filter value', ['value' => $billable]);
}
@@ -99,6 +131,16 @@ class TimeEntryFilter
return $this;
}
public function addBillable(?bool $billable): self
{
if ($billable === null) {
return $this;
}
$this->builder->where('billable', '=', $billable);
return $this;
}
/**
* @param array<string>|null $clientIds
*/

View File

@@ -9,7 +9,7 @@
"ext-zip": "*",
"brick/money": "^0.10.0",
"datomatic/laravel-enum-helper": "^2.0.0",
"dedoc/scramble": "dev-main",
"dedoc/scramble": "^0.11.28",
"filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.3.0",
"gotenberg/gotenberg-php": "^2.8",
@@ -121,12 +121,6 @@
]
}
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/korridor/scramble"
}
],
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",

171
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "0db4b322eb7dcfe2796f7520b6fe82ba",
"content-hash": "3e4b0fb7db1fbd785f3079f3788b86eb",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -1519,23 +1519,24 @@
},
{
"name": "dedoc/scramble",
"version": "dev-main",
"version": "v0.11.28",
"source": {
"type": "git",
"url": "https://github.com/korridor/scramble.git",
"reference": "ff692e60e3827ee395007d19ae377fc0d7274fe3"
"url": "https://github.com/dedoc/scramble.git",
"reference": "714036967f6ee5fd139af0a3af0b2f0f374cb720"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/korridor/scramble/zipball/ff692e60e3827ee395007d19ae377fc0d7274fe3",
"reference": "ff692e60e3827ee395007d19ae377fc0d7274fe3",
"url": "https://api.github.com/repos/dedoc/scramble/zipball/714036967f6ee5fd139af0a3af0b2f0f374cb720",
"reference": "714036967f6ee5fd139af0a3af0b2f0f374cb720",
"shasum": ""
},
"require": {
"illuminate/contracts": "^10.0|^11.0",
"myclabs/deep-copy": "^1.12",
"nikic/php-parser": "^5.0",
"php": "^8.1",
"phpstan/phpdoc-parser": "^1.0",
"phpstan/phpdoc-parser": "^1.0|^2.0",
"spatie/laravel-package-tools": "^1.9.2"
},
"require-dev": {
@@ -1547,7 +1548,6 @@
"phpunit/phpunit": "^10.5",
"spatie/pest-plugin-snapshots": "^2.1"
},
"default-branch": true,
"type": "library",
"extra": {
"laravel": {
@@ -1562,25 +1562,7 @@
"Dedoc\\Scramble\\Database\\Factories\\": "database/factories"
}
},
"autoload-dev": {
"psr-4": {
"Dedoc\\Scramble\\Tests\\": "tests"
}
},
"scripts": {
"analyse": [
"vendor/bin/phpstan analyse"
],
"test": [
"vendor/bin/pest"
],
"test-coverage": [
"vendor/bin/pest --coverage"
],
"format": [
"vendor/bin/pint"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
@@ -1599,15 +1581,16 @@
"openapi"
],
"support": {
"source": "https://github.com/korridor/scramble/tree/main"
"issues": "https://github.com/dedoc/scramble/issues",
"source": "https://github.com/dedoc/scramble/tree/v0.11.28"
},
"funding": [
{
"type": "github",
"url": "https://github.com/romalytvynenko"
"url": "https://github.com/romalytvynenko",
"type": "github"
}
],
"time": "2024-07-17T11:33:15+00:00"
"time": "2024-11-25T12:25:37+00:00"
},
{
"name": "defuse/php-encryption",
@@ -6388,6 +6371,66 @@
},
"time": "2024-09-04T18:46:31+00:00"
},
{
"name": "myclabs/deep-copy",
"version": "1.12.1",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
"reference": "123267b2c49fbf30d78a7b2d333f6be754b94845"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/123267b2c49fbf30d78a7b2d333f6be754b94845",
"reference": "123267b2c49fbf30d78a7b2d333f6be754b94845",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"conflict": {
"doctrine/collections": "<1.6.8",
"doctrine/common": "<2.13.3 || >=3 <3.2.2"
},
"require-dev": {
"doctrine/collections": "^1.6.8",
"doctrine/common": "^2.13.3 || ^3.2.2",
"phpspec/prophecy": "^1.10",
"phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
},
"type": "library",
"autoload": {
"files": [
"src/DeepCopy/deep_copy.php"
],
"psr-4": {
"DeepCopy\\": "src/DeepCopy/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Create deep copies (clones) of your objects",
"keywords": [
"clone",
"copy",
"duplicate",
"object",
"object graph"
],
"support": {
"issues": "https://github.com/myclabs/DeepCopy/issues",
"source": "https://github.com/myclabs/DeepCopy/tree/1.12.1"
},
"funding": [
{
"url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
"type": "tidelift"
}
],
"time": "2024-11-08T17:47:46+00:00"
},
{
"name": "nesbot/carbon",
"version": "3.8.2",
@@ -13444,66 +13487,6 @@
},
"time": "2024-05-16T03:13:13+00:00"
},
{
"name": "myclabs/deep-copy",
"version": "1.12.1",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
"reference": "123267b2c49fbf30d78a7b2d333f6be754b94845"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/123267b2c49fbf30d78a7b2d333f6be754b94845",
"reference": "123267b2c49fbf30d78a7b2d333f6be754b94845",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"conflict": {
"doctrine/collections": "<1.6.8",
"doctrine/common": "<2.13.3 || >=3 <3.2.2"
},
"require-dev": {
"doctrine/collections": "^1.6.8",
"doctrine/common": "^2.13.3 || ^3.2.2",
"phpspec/prophecy": "^1.10",
"phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
},
"type": "library",
"autoload": {
"files": [
"src/DeepCopy/deep_copy.php"
],
"psr-4": {
"DeepCopy\\": "src/DeepCopy/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Create deep copies (clones) of your objects",
"keywords": [
"clone",
"copy",
"duplicate",
"object",
"object graph"
],
"support": {
"issues": "https://github.com/myclabs/DeepCopy/issues",
"source": "https://github.com/myclabs/DeepCopy/tree/1.12.1"
},
"funding": [
{
"url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
"type": "tidelift"
}
],
"time": "2024-11-08T17:47:46+00:00"
},
{
"name": "nunomaduro/collision",
"version": "v8.5.0",
@@ -15885,15 +15868,13 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {
"dedoc/scramble": 20
},
"stability-flags": {},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "8.3.*",
"ext-zip": "*"
},
"platform-dev": [],
"platform-dev": {},
"plugin-api-version": "2.6.0"
}

View File

@@ -29,11 +29,9 @@ class ClientFactory extends Factory
public function forOrganization(Organization $organization): self
{
return $this->state(function (array $attributes) use ($organization) {
return [
'organization_id' => $organization->getKey(),
];
});
return $this->state(fn (array $attributes) => [
'organization_id' => $organization->getKey(),
]);
}
public function randomCreatedAt(): self

View File

@@ -41,20 +41,16 @@ class MemberFactory extends Factory
public function forOrganization(Organization $organization): static
{
return $this->state(function (array $attributes) use ($organization): array {
return [
'organization_id' => $organization->getKey(),
];
});
return $this->state(fn (array $attributes): array => [
'organization_id' => $organization->getKey(),
]);
}
public function forUser(User $user): static
{
return $this->state(function (array $attributes) use ($user): array {
return [
'user_id' => $user->getKey(),
];
});
return $this->state(fn (array $attributes): array => [
'user_id' => $user->getKey(),
]);
}
/**

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\Weekday;
use App\Models\Organization;
use App\Models\Report;
use App\Service\Dto\ReportPropertiesDto;
use App\Service\ReportService;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
/**
* @extends Factory<Report>
*/
class ReportFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$reportDto = new ReportPropertiesDto;
$reportDto->start = Carbon::createFromDate($this->faker->dateTimeBetween('-1 year', '-1 month'));
$reportDto->end = Carbon::createFromDate($this->faker->dateTimeBetween('-1 month', 'now'));
$reportDto->group = TimeEntryAggregationType::Project;
$reportDto->subGroup = TimeEntryAggregationType::Task;
$reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
$reportDto->weekStart = Weekday::from($this->faker->randomElement(Weekday::values()));
$reportDto->timezone = $this->faker->timezone();
return [
'name' => $this->faker->company(),
'description' => $this->faker->paragraph(),
'is_public' => $this->faker->boolean(),
'properties' => $reportDto,
'organization_id' => Organization::factory(),
];
}
public function randomCreatedAt(): self
{
return $this->state(fn (array $attributes): array => [
'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
]);
}
public function public(): self
{
return $this->state(fn (array $attributes): array => [
'is_public' => true,
'share_secret' => app(ReportService::class)->generateSecret(),
]);
}
public function private(): self
{
return $this->state(fn (array $attributes): array => [
'is_public' => false,
'share_secret' => null,
]);
}
public function forOrganization(Organization $organization): self
{
return $this->state(fn (array $attributes): array => [
'organization_id' => $organization->getKey(),
]);
}
}

View File

@@ -32,28 +32,22 @@ class TaskFactory extends Factory
public function forProject(Project $project): self
{
return $this->state(function (array $attributes) use ($project) {
return [
'project_id' => $project->getKey(),
];
});
return $this->state(fn (array $attributes) => [
'project_id' => $project->getKey(),
]);
}
public function isDone(): self
{
return $this->state(function (array $attributes) {
return [
'done_at' => $this->faker->dateTime('now', 'UTC'),
];
});
return $this->state(fn (array $attributes) => [
'done_at' => $this->faker->dateTime('now', 'UTC'),
]);
}
public function forOrganization(Organization $organization): self
{
return $this->state(function (array $attributes) use ($organization) {
return [
'organization_id' => $organization->getKey(),
];
});
return $this->state(fn (array $attributes) => [
'organization_id' => $organization->getKey(),
]);
}
}

View File

@@ -173,22 +173,18 @@ class TimeEntryFactory extends Factory
public function forProject(?Project $project): self
{
return $this->state(function (array $attributes) use ($project) {
return [
'project_id' => $project?->getKey(),
'client_id' => $project?->client_id,
];
});
return $this->state(fn (array $attributes) => [
'project_id' => $project?->getKey(),
'client_id' => $project?->client_id,
]);
}
public function forTask(?Task $task): self
{
return $this->state(function (array $attributes) use ($task) {
return [
'task_id' => $task?->getKey(),
'project_id' => $task?->project?->getKey(),
'client_id' => $task?->project?->client?->getKey(),
];
});
return $this->state(fn (array $attributes) => [
'task_id' => $task?->getKey(),
'project_id' => $task?->project?->getKey(),
'client_id' => $task?->project?->client?->getKey(),
]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('reports', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->string('name');
$table->text('description')->nullable();
$table->boolean('is_public')->default(false)->index();
$table->string('share_secret', 40)->nullable()->index()->unique();
$table->jsonb('properties');
$table->dateTime('public_until')->nullable();
$table->uuid('organization_id');
$table->foreign('organization_id')
->references('id')
->on('organizations')
->restrictOnDelete()
->cascadeOnUpdate();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('reports');
}
};

View File

@@ -5,18 +5,20 @@ declare(strict_types=1);
return [
'clockify_time_entries' => [
'name' => 'Clockify Time Entries',
'description' => '1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br> '.
'2. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
'3. Now select the date range that you want to export in the right top. '.
'description' => '1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'.
'2. In the same preferences page change the language of Clockfiy to English.<br>'.
'3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
'4. Now select the date range that you want to export in the right top. '.
'It is currently not possible to select more than one year. You can export each year separately and import them one after another .'.
'<br> 4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '.
'<br><br>Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.',
],
'clockify_projects' => [
'name' => 'Clockify Projects',
'description' => '1. Go to PROJECTS in the navigation on the left.<br> '.
'2. Now click on the three dots on the right of the project that you want to export and select Export.<br> '.
'3. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table in the top right corner.',
'description' => '1. Make sure to set the language of Clockify to English in "Preferences -> General".<br>'.
'2. Go to PROJECTS in the navigation on the left.<br> '.
'3. Now click on the three dots on the right of the project that you want to export and select Export.<br> '.
'4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table in the top right corner.',
],
'toggl_data_importer' => [
'name' => 'Toggl Data Importer',

View File

@@ -4,8 +4,8 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint --ext .js,.vue,.ts --ignore-path .gitignore .",
"lint:fix": "eslint --fix --ext .js,.vue,.ts --ignore-path .gitignore .",
"lint": "eslint --ext .js,.vue,.ts --ignore-path .gitignore resources/js",
"lint:fix": "eslint --fix --ext .js,.vue,.ts --ignore-path .gitignore resources/js",
"type-check": "vue-tsc --noEmit",
"test:e2e": "rm -rf test-results/.auth && npx playwright test",
"zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output resources/js/packages/api/src/openapi.json.client.ts --base-url /api"

View File

@@ -38,5 +38,6 @@
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="AUDITING_ENABLED" value="true"/>
<env name="NEWSLETTER_URL" value="null"/>
</php>
</phpunit>

View File

@@ -30,7 +30,7 @@ export default defineConfig({
trace: process.env.CI ? 'on-first-retry' : 'on',
},
timeout: 10 * 1000,
timeout: 20 * 1000,
/* Configure projects for major browsers */
projects: [

View File

@@ -35,11 +35,90 @@ const hideFreeUpgradeBanner = useSessionStorage(
false
);
const showFreeUpgradeBanner = computed(
() => isFreePlan() && !isBlocked() && !hideFreeUpgradeBanner.value
() =>
isFreePlan() &&
!isBlocked() &&
!hideFreeUpgradeBanner.value &&
!showBlackFridayBanner.value
);
const hideBlackFridayBanner = useSessionStorage(
'hideBlackFridayBanner-' + getCurrentOrganizationId(),
false
);
const showBlackFridayBanner = computed(() => {
if (hideBlackFridayBanner.value) {
return false;
}
const today = new Date();
const blackFriday = new Date(2024, 10, 30);
return today < blackFriday;
});
</script>
<template>
<div
v-if="showBlackFridayBanner"
class="bg-tertiary text-xs lg:text-sm pb-1 pt-2 border-b border-border-secondary">
<MainContainer class="flex items-center justify-between">
<div class="flex items-center space-x-1.5">
<svg
class="w-4 mr-1"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg">
<path
fill="#FF37AD"
d="M22.498 68.97a11.845 11.845 0 1 0 0-23.687c-6.471.098-11.666 5.372-11.666 11.844s5.195 11.746 11.666 11.844m181.393-10.04a11.845 11.845 0 1 0-.003-23.688c-6.471.098-11.665 5.373-11.665 11.845c.001 6.472 5.197 11.745 11.668 11.842" />
<path
fill="#FCC954"
d="M213.503 211.097a11.845 11.845 0 1 0-.003-23.687c-6.471.098-11.665 5.373-11.664 11.845s5.196 11.745 11.667 11.842M70.872 23.689a11.845 11.845 0 1 0 0-23.688C64.4.1 59.206 5.373 59.206 11.845S64.4 23.591 70.872 23.689" />
<path
fill="#2890E9"
d="M140.945 105.94a9.25 9.25 0 0 1-8.974-11.484c.37-1.482.672-2.97.899-4.455a25.4 25.4 0 0 1-8.732 1.904c-5.379.205-10.195-.702-14.3-2.69a22.23 22.23 0 0 1-9.614-8.877c-4.415-7.652-4.034-17.718.964-25.645c4.765-7.568 12.836-11.664 21.586-10.995c6.74.527 12.647 3.051 17.378 7.382q1.293-3.647 2.473-7.803c4.833-17.058 6.429-34.187 6.442-34.36a9.24 9.24 0 0 1 10.041-8.37a9.25 9.25 0 0 1 8.37 10.044c-.067.767-1.768 19.03-7.068 37.735c-2.676 9.445-5.838 17.426-9.42 23.798q.396 2.13.631 4.372c.746 7.211.152 14.974-1.714 22.445a9.256 9.256 0 0 1-8.962 6.998m-20.123-43.827c-.956 0-2.64.28-3.996 2.43c-1.298 2.06-1.552 4.873-.588 6.544c1.282 2.223 5.054 2.417 7.19 2.336c2.424-.092 4.908-1.612 7.338-4.382a16 16 0 0 0-1.43-2.422c-2.007-2.787-4.547-4.212-7.998-4.482c-.13-.008-.305-.024-.516-.024" />
<path
fill="#F0A420"
d="M114.361 131.268c-38.343-30.224-78.42-43.319-89.514-29.246a12.8 12.8 0 0 0-2.257 4.509a4 4 0 0 0-.156.61v.024q-.223.947-.333 1.917L.393 236.18c-3.477 20.412 16.73 36.755 35.967 29.093l117.721-46.908c2.076-.826 7.185-3.982 8.583-5.724q.556-.544 1.037-1.153c11.092-14.075-11-49.988-49.34-80.223z" />
<path
fill="#FCC954"
d="M163.688 211.494c11.1-14.08-10.984-50-49.327-80.226c-38.343-30.227-78.425-43.316-89.524-29.236s10.983 50 49.326 80.226c38.343 30.227 78.425 43.316 89.525 29.236" />
<path
fill="#F0A420"
d="M156.994 203.294c9.108-11.556-10.956-42.563-44.817-69.256c-33.861-26.695-68.697-38.966-77.804-27.413c-9.11 11.556 10.954 42.563 44.815 69.256c33.86 26.695 68.697 38.969 77.806 27.413" />
<path
fill="#2E6AC9"
d="M76.059 249.456c-14.327.07-26.004-7.101-40.158-18.257C19.431 218.21 8.493 202.665 7.63 193.81l-4.668 27.327c2.16 7.798 9.523 17.683 20.202 26.101c8.883 7.004 17.844 11.813 27.135 12.48l25.76-10.266zm-14.332-49.6c-27.443-21.637-45.271-46.467-44.77-60.669l-4.549 26.63c.351 12.685 15.175 33.184 36.262 49.808c18.894 14.896 38.583 25.38 53.66 23.363l25.593-10.2c-20.62 1.425-42.376-10.147-66.196-28.931" />
<path
fill="#2890E9"
d="M118.535 145.052a11.845 11.845 0 1 0 0-23.688c-6.471.098-11.666 5.372-11.666 11.844s5.195 11.746 11.666 11.844" />
<path
fill="#FF37AD"
d="m182.412 122.007l.087-.097c.108-.116.308-.33.596-.621a45 45 0 0 1 2.8-2.56c3.56-2.98 7.45-5.54 11.594-7.63c10.128-5.125 25.208-9.307 44.985-4.747c5.943 1.37 11.87-2.336 13.241-8.278c1.37-5.942-2.336-11.87-8.278-13.24c-25.602-5.903-45.957-.506-59.922 6.566a82.5 82.5 0 0 0-15.857 10.449a66 66 0 0 0-4.215 3.866a45 45 0 0 0-1.53 1.615l-.12.135l-.042.048l-.02.022l-.007.008c-.003.005-.009.01 8.361 7.21l-8.37-7.2c-3.877 4.622-3.328 11.5 1.233 15.448s11.446 3.506 15.464-.994M73.03 43.248a11.75 11.75 0 0 0-16.23-3.664a11.76 11.76 0 0 0-3.665 16.227c.427.683 9.178 14.86 10.976 34.276c1.83 19.727-3.966 37.86-17.253 54.12c4.474 5.686 9.858 11.596 16.008 17.507c8.51-9.834 14.913-20.402 19.12-31.583c5.175-13.756 7.006-28.342 5.445-43.348c-2.487-23.874-12.874-41.11-14.402-43.535" />
<path
fill="#2890E9"
d="M220.242 156.578c6.002 1.553 10.244 3.246 12.077 4.034a11.86 11.86 0 0 0 13.94-1.12a11.87 11.87 0 0 0 4.107-8.765a11.85 11.85 0 0 0-8.06-11.426c-5.618-2.495-26.905-10.92-55.044-9.423c-18.941 1.007-37.155 6.253-54.133 15.608c-16.076 8.86-31.004 21.412-44.556 37.425a199 199 0 0 0 20.17 12.607c22.882-26.08 49.283-40.217 78.7-42.085a105.9 105.9 0 0 1 32.8 3.145" />
</svg>
<div class="flex-1 space-x-1">
<span class="font-medium">
<strong>BLACK FRIDAY SALE!</strong> Use the code
<strong>BLACKFRIDAY</strong> at checkout and get
<strong>30% off</strong> the solidtime yearly plan.
</span>
</div>
</div>
<div class="flex items-center space-x-2">
<Link v-if="canManageBilling()" href="/billing">
<div
class="text-white font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
<span>Upgrade now</span>
</div>
</Link>
<button @click="hideBlackFridayBanner = true" class="p-1">
<XMarkIcon
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button>
</div>
</MainContainer>
</div>
<div
v-if="showTrialBanner"
class="bg-accent-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary">

View File

@@ -0,0 +1,130 @@
<script setup lang="ts">
import TextInput from '../../../packages/ui/src/Input/TextInput.vue';
import SecondaryButton from '../../../packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref } from 'vue';
import PrimaryButton from '../../../packages/ui/src/Buttons/PrimaryButton.vue';
import InputLabel from '../../../packages/ui/src/Input/InputLabel.vue';
import type {
CreateReportBody,
CreateReportBodyProperties,
} from '@/packages/api/src';
import { useMutation } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '@/packages/api/src';
import { Checkbox } from '@/packages/ui/src';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import { useNotificationsStore } from '@/utils/notification';
const show = defineModel('show', { default: false });
const saving = ref(false);
const createReportMutation = useMutation({
mutationFn: async (report: CreateReportBody) => {
const organizationId = getCurrentOrganizationId();
if (organizationId === null) {
throw new Error('No current organization id - create report');
}
return await api.createReport(report, {
params: {
organization: organizationId,
},
});
},
});
const props = defineProps<{
properties: CreateReportBodyProperties;
}>();
const report = ref({
name: '',
description: '',
is_public: true,
public_until: null,
});
const { handleApiRequestNotifications } = useNotificationsStore();
async function submit() {
await handleApiRequestNotifications(
() =>
createReportMutation.mutateAsync({
...report.value,
properties: { ...props.properties },
}),
'Success',
'Error',
() => {
report.value = {
name: '',
description: '',
is_public: false,
public_until: null,
};
show.value = false;
}
);
}
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Create Report </span>
</div>
</template>
<template #content>
<div class="items-center space-y-4 w-full">
<div class="w-full">
<InputLabel for="name" value="Name" />
<TextInput
id="name"
class="mt-1.5 w-full"
v-model="report.name"></TextInput>
</div>
<div>
<InputLabel for="description" value="Description" />
<TextInput
id="description"
class="mt-1.5 w-full"
v-model="report.description"></TextInput>
</div>
<InputLabel value="Visibility" />
<div class="flex items-center space-x-12">
<div class="flex items-center space-x-3 px-2 py-3">
<Checkbox
v-model:checked="report.is_public"
id="is_public"></Checkbox>
<InputLabel for="is_public" value="Public" />
</div>
<div
v-if="report.is_public"
class="flex items-center space-x-4">
<div>
<InputLabel for="public_until" value="Expires at" />
<div class="text-text-tertiary font-medium">
(optional)
</div>
</div>
<DatePicker id="public_until"></DatePicker>
</div>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit">
Create Report
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import TextInput from '../../../packages/ui/src/Input/TextInput.vue';
import SecondaryButton from '../../../packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref, watch } from 'vue';
import PrimaryButton from '../../../packages/ui/src/Buttons/PrimaryButton.vue';
import InputLabel from '../../../packages/ui/src/Input/InputLabel.vue';
import type { UpdateReportBody } from '@/packages/api/src';
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '@/packages/api/src';
import { Checkbox } from '@/packages/ui/src';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import { useNotificationsStore } from '@/utils/notification';
import type { Report } from '@/packages/api/src';
const show = defineModel('show', { default: false });
const saving = ref(false);
const queryClient = useQueryClient();
const updateReportMutation = useMutation({
mutationFn: async (report: UpdateReportBody) => {
const organizationId = getCurrentOrganizationId();
if (organizationId === null) {
throw new Error('No current organization id - update report');
}
return await api.updateReport(report, {
params: {
organization: organizationId,
report: props.originalReport.id,
},
});
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['reports'],
});
},
});
const props = defineProps<{
originalReport: Report;
}>();
const report = ref<UpdateReportBody>({
name: props.originalReport.name,
description: props.originalReport.description,
is_public: props.originalReport.is_public,
public_until: props.originalReport.public_until,
});
watch(
() => props.originalReport,
() => {
report.value = {
name: props.originalReport.name,
description: props.originalReport.description,
is_public: props.originalReport.is_public,
public_until: props.originalReport.public_until,
};
}
);
const { handleApiRequestNotifications } = useNotificationsStore();
async function submit() {
await handleApiRequestNotifications(
() => updateReportMutation.mutateAsync(report.value),
'Success',
'Error',
() => {
report.value = {
name: '',
description: '',
is_public: false,
public_until: null,
properties: {},
};
show.value = false;
}
);
}
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Create Report </span>
</div>
</template>
<template #content>
<div class="items-center space-y-4 w-full">
<div class="w-full">
<InputLabel for="name" value="Name" />
<TextInput
id="name"
class="mt-1.5 w-full"
v-model="report.name"></TextInput>
</div>
<div>
<InputLabel for="description" value="Description" />
<TextInput
id="description"
class="mt-1.5 w-full"
v-model="report.description"></TextInput>
</div>
<InputLabel value="Visibility" />
<div class="flex items-center space-x-12">
<div class="flex items-center space-x-2 px-2 py-3">
<Checkbox
v-model:checked="report.is_public"
id="is_public"></Checkbox>
<InputLabel for="is_public" value="Public" />
</div>
<div
v-if="report.is_public"
class="flex items-center space-x-4">
<InputLabel for="public_until" value="Expires at" />
<DatePicker id="public_until"></DatePicker>
</div>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit">
Update Report
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';
import type { Report } from '@/packages/api/src';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
import { canDeleteReport, canUpdateReport } from '@/utils/permissions';
const emit = defineEmits<{
delete: [];
edit: [];
archive: [];
}>();
const props = defineProps<{
report: Report;
}>();
</script>
<template>
<MoreOptionsDropdown :label="'Actions for Project ' + props.report.name">
<div class="min-w-[150px]">
<button
@click.prevent="emit('edit')"
v-if="canUpdateReport()"
:aria-label="'Edit Report ' + props.report.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span>
</button>
<button
@click.prevent="emit('delete')"
:aria-label="'Delete Report ' + props.report.name"
v-if="canDeleteReport()"
class="border-b border-card-background-separator flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>
</div>
</MoreOptionsDropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { SecondaryButton } from '@/packages/ui/src';
import ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue';
import { h, ref } from 'vue';
import type { CreateReportBodyProperties } from '@/packages/api/src';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
defineProps<{
reportProperties: CreateReportBodyProperties;
}>();
const showCreateReportModal = ref(false);
const showPremiumModal = ref(false);
const SaveIcon = h('div', {
innerHTML:
'<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7M7 3v4a1 1 0 0 0 1 1h7"/></g></svg>',
});
function onSaveReportClick() {
if (isAllowedToPerformPremiumAction()) {
showCreateReportModal.value = true;
} else {
showPremiumModal.value = true;
}
}
</script>
<template>
<ReportCreateModal
:properties="reportProperties"
v-model:show="showCreateReportModal"></ReportCreateModal>
<UpgradeModal v-model:show="showPremiumModal">
<strong>Sharable Reports</strong> is only available in solidtime
Professional.
</UpgradeModal>
<SecondaryButton :icon="SaveIcon" @click="onSaveReportClick"
>Save Report</SecondaryButton
>
</template>
<style scoped></style>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { FolderPlusIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { computed } from 'vue';
import { canCreateProjects } from '@/utils/permissions';
import type { Report } from '@/packages/api/src';
import ReportTableHeading from '@/Components/Common/Report/ReportTableHeading.vue';
import ReportTableRow from '@/Components/Common/Report/ReportTableRow.vue';
import { router } from '@inertiajs/vue3';
defineProps<{
reports: Report[];
}>();
const gridTemplate = computed(() => {
return `grid-template-columns: minmax(150px, auto) minmax(250px, 1fr) minmax(140px, auto) minmax(130px, auto) 80px;`;
});
</script>
<template>
<div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle">
<div
data-testid="report_table"
class="grid min-w-full"
:style="gridTemplate">
<ReportTableHeading></ReportTableHeading>
<div
class="col-span-5 py-24 text-center"
v-if="reports.length === 0">
<FolderPlusIcon
class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-white font-semibold">
No shared reports found
</h3>
<p class="pb-5" v-if="canCreateProjects()">
Create your first project now!
</p>
<SecondaryButton
@click="router.visit(route('reporting'))"
:icon="PlusIcon"
>Go to the overview to create a report
</SecondaryButton>
</div>
<template v-for="report in reports" :key="report.id">
<ReportTableRow :report="report"></ReportTableRow>
</template>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue';
</script>
<template>
<TableHeading>
<div
class="py-1.5 pr-3 text-left font-semibold text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
</div>
<div class="px-3 py-1.5 text-left font-semibold text-white">
Description
</div>
<div class="px-3 py-1.5 text-left font-semibold text-white">
Visibility
</div>
<div class="px-3 py-1.5 text-left font-semibold text-white">
Public URL
</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
</div>
</TableHeading>
</template>
<style scoped></style>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import { ref } from 'vue';
import TableRow from '@/Components/TableRow.vue';
import { api, type Report } from '@/packages/api/src';
import ReportMoreOptionsDropdown from '@/Components/Common/Report/ReportMoreOptionsDropdown.vue';
import ReportEditModal from '@/Components/Common/Report/ReportEditModal.vue';
import { SecondaryButton } from '@/packages/ui/src';
import { useClipboard } from '@vueuse/core';
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
const props = defineProps<{
report: Report;
}>();
const showEditReportModal = ref(false);
const { copy, copied, isSupported } = useClipboard({ legacy: true });
const { handleApiRequestNotifications } = useNotificationsStore();
function openSharableLink() {
const link = props.report.shareable_link;
if (link) {
window.open(link, '_blank')?.focus();
}
}
const queryClient = useQueryClient();
const deleteReportMutation = useMutation({
mutationFn: async (reportId: string) => {
const organizationId = getCurrentOrganizationId();
if (organizationId === null) {
throw new Error('No current organization id - update report');
}
return await api.deleteReport(undefined, {
params: {
organization: organizationId,
report: reportId,
},
});
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['reports'],
});
},
});
async function deleteReport() {
await handleApiRequestNotifications(
() => deleteReportMutation.mutateAsync(props.report.id),
'Success',
'Error'
);
}
</script>
<template>
<ReportEditModal
v-model:show="showEditReportModal"
:original-report="report"></ReportEditModal>
<TableRow>
<div
class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span class="overflow-ellipsis overflow-hidden">
{{ report.name }}
</span>
</div>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-muted">
<span class="overflow-ellipsis overflow-hidden">
{{ report.description }}
</span>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
{{ report.is_public ? 'Public' : 'Private' }}
</div>
<div
class="whitespace-nowrap px-3 flex items-center text-sm text-muted">
<div
v-if="report.shareable_link"
class="space-x-2 flex items-center">
<SecondaryButton
v-if="isSupported"
@click="copy(report.shareable_link)">
<span v-if="!copied">Copy URL</span>
<span v-else>Copied!</span>
</SecondaryButton>
<button
class="outline-0 focus-visible:ring-2 w-6 h-6 flex items-center justify-center rounded focus-visible:ring-white/80"
@click="openSharableLink">
<ArrowTopRightOnSquareIcon
class="w-4 text-text-tertiary hover:text-text-secondary transition"></ArrowTopRightOnSquareIcon>
</button>
</div>
<span v-else> -- </span>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<ReportMoreOptionsDropdown
:report="report"
@edit="showEditReportModal = true"
@delete="deleteReport"></ReportMoreOptionsDropdown>
</div>
</TableRow>
</template>
<style scoped></style>

View File

@@ -1,15 +1,22 @@
<script setup lang="ts">
import { SecondaryButton } from '@/packages/ui/src';
import { ArrowDownTrayIcon } from '@heroicons/vue/20/solid';
import { ArrowDownTrayIcon, LockClosedIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import type { ExportFormat } from '@/types/reporting';
import { ref } from 'vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
const props = defineProps<{
download: (format: ExportFormat) => Promise<void>;
}>();
const loading = ref(false);
const showPremiumModal = ref(false);
function triggerDownload(format: ExportFormat) {
if (format === 'pdf' && !isAllowedToPerformPremiumAction()) {
showPremiumModal.value = true;
return;
}
loading.value = true;
props.download(format).finally(() => {
loading.value = false;
@@ -27,11 +34,15 @@ function triggerDownload(format: ExportFormat) {
<template #content>
<div class="flex flex-col space-y-1 p-1.5">
<SecondaryButton
v-if="false"
class="border-0 px-2"
@click="triggerDownload('pdf')"
>Export as PDF</SecondaryButton
>
@click="triggerDownload('pdf')">
<div class="flex items-center space-x-2">
<span> Export as PDF </span>
<LockClosedIcon
v-if="!isAllowedToPerformPremiumAction()"
class="w-3.5 text-text-tertiary"></LockClosedIcon>
</div>
</SecondaryButton>
<SecondaryButton
class="border-0 px-2"
@click="triggerDownload('xlsx')"
@@ -50,6 +61,10 @@ function triggerDownload(format: ExportFormat) {
</div>
</template>
</Dropdown>
<UpgradeModal v-model:show="showPremiumModal">
<strong>PDF Reports</strong> are only available in solidtime
Professional.
</UpgradeModal>
</template>
<style scoped></style>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import {
ArrowDownTrayIcon,
CheckCircleIcon,
XMarkIcon,
} from '@heroicons/vue/20/solid';
import { Modal, PrimaryButton } from '@/packages/ui/src';
const props = defineProps<{
exportUrl: string | null;
}>();
const showExportModal = defineModel('show', { default: false });
function downloadCurrentExport() {
if (props.exportUrl) {
window.open(props.exportUrl, '_blank')?.focus();
}
}
</script>
<template>
<Modal
closeable
max-width="lg"
@close="showExportModal = false"
:show="showExportModal">
<button
class="text-text-tertiary w-6 mx-auto absolute focus-visible:outline-none focus-visible:ring-2 rounded-full focus-visible:ring-white/80 transition focus-visible:text-text-primary hover:text-text-primary top-2 right-2">
<XMarkIcon @click="showExportModal = false"></XMarkIcon>
</button>
<div class="text-center text-text-primary py-6">
<div
class="flex items-center font-semibold text-lg justify-center space-x-2 pb-2">
<CheckCircleIcon
class="text-text-tertiary w-6"></CheckCircleIcon>
<span> Export Successful! </span>
</div>
<div class="text-center text-sm max-w-64 mx-auto">
<p class="pb-5">
Your export is ready, you can download it with the button
below.
</p>
<PrimaryButton
:icon="ArrowDownTrayIcon"
@click="downloadCurrentExport"
>Download</PrimaryButton
>
</div>
</div>
</Modal>
</template>
<style scoped></style>

View File

@@ -11,11 +11,6 @@ import {
TooltipComponent,
} from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
import type { GroupedDataEntries } from '@/packages/api/src';
import { useReportingStore } from '@/utils/useReporting';
import { useProjectsStore } from '@/utils/useProjects';
import { storeToRefs } from 'pinia';
use([
CanvasRenderer,
@@ -28,36 +23,18 @@ use([
provide(THEME_KEY, 'dark');
const props = defineProps<{
data: GroupedDataEntries | null;
type: string | null;
}>();
const { getNameForReportingRowEntry, emptyPlaceholder } = useReportingStore();
const { projects } = storeToRefs(useProjectsStore());
type ReportingChartDataEntry = {
value: number;
name: string;
color: string;
}[];
const groupChartData = computed(() => {
return (
props?.data?.map((entry) => {
const name = getNameForReportingRowEntry(entry.key, props.type);
let color = getRandomColorWithSeed(entry.key ?? 'none');
if (name && props.type && emptyPlaceholder[props.type] === name) {
color = '#CCCCCC';
} else if (props.type === 'project') {
color =
projects.value?.find((project) => project.id === entry.key)
?.color ?? '#CCCCCC';
}
return {
value: entry.seconds,
name: getNameForReportingRowEntry(entry.key, props.type),
color: color,
};
}) ?? []
);
});
const props = defineProps<{
data: ReportingChartDataEntry | null;
}>();
const seriesData = computed(() => {
return groupChartData.value.map((el) => {
return props.data?.map((el) => {
return {
...el,
...{

View File

@@ -4,30 +4,23 @@ import { formatCents } from '@/packages/ui/src/utils/money';
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
import { ref } from 'vue';
import { twMerge } from 'tailwind-merge';
import { useReportingStore } from '@/utils/useReporting';
import { getOrganizationCurrencyString } from '@/utils/money';
const { getNameForReportingRowEntry } = useReportingStore();
type AggregatedGroupedData = GroupedData & {
grouped_type?: string | null;
grouped_data?: GroupedData[] | null;
};
type GroupedData = {
key: string | null;
seconds: number;
cost: number;
description: string | null | undefined;
};
const props = defineProps<{
entry: AggregatedGroupedData;
indent?: boolean;
type: string | null;
}>();
function getNameForKey(key: string | null) {
return getNameForReportingRowEntry(key, props.type);
}
const expanded = ref(false);
</script>
@@ -48,7 +41,7 @@ const expanded = ref(false);
{{ entry.grouped_data?.length }}
</GroupedItemsCountButton>
<span>
{{ getNameForKey(entry.key) }}
{{ entry.description }}
</span>
</div>
<div class="justify-end flex items-center">
@@ -65,8 +58,7 @@ const expanded = ref(false);
<ReportingRow
indent
v-for="subEntry in entry.grouped_data"
:type="entry?.grouped_type ?? null"
:key="subEntry.key ?? 'none'"
:key="subEntry.description ?? 'none'"
:entry="subEntry"></ReportingRow>
</div>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
defineProps<{
active: 'reporting' | 'detailed' | 'shared';
}>();
</script>
<template>
<TabBar>
<TabBarItem
@click="router.visit(route('reporting'))"
:active="active === 'reporting'"
>Overview</TabBarItem
>
<TabBarItem
@click="router.visit(route('reporting.detailed'))"
:active="active === 'detailed'"
>Detailed</TabBarItem
>
<TabBarItem
@click="router.visit(route('reporting.shared'))"
:active="active === 'shared'"
>Shared</TabBarItem
>
</TabBar>
</template>
<style scoped></style>

View File

@@ -6,7 +6,10 @@ const showUpgradeModal = ref(false);
</script>
<template>
<UpgradeModal v-model:show="showUpgradeModal"></UpgradeModal>
<UpgradeModal v-model:show="showUpgradeModal">
<strong>Project and Task Estimates</strong> is only available in
solidtime Professional.
</UpgradeModal>
<button
@click.prevent="showUpgradeModal = true"
class="inline-flex bg-secondary hover:bg-tertiary px-2 py-1 rounded border border-border-secondary hover:border-border-tertiary items-center space-x-1">

View File

@@ -2,10 +2,7 @@
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { Link } from '@inertiajs/vue3';
import {
isAllowedToPerformPremiumAction,
isBillingActivated,
} from '@/utils/billing';
import { isBillingActivated } from '@/utils/billing';
import { CreditCardIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { canManageBilling, canUpdateOrganization } from '@/utils/permissions';
import { SecondaryButton } from '@/packages/ui/src';
@@ -22,19 +19,31 @@ const show = defineModel('show', { default: false });
</template>
<template #content>
<div v-if="!isAllowedToPerformPremiumAction()">
<div>
<div
class="rounded-full flex items-center justify-center w-20 h-20 mx-auto border border-border-tertiary bg-secondary">
<UserGroupIcon class="w-12"></UserGroupIcon>
</div>
<div class="max-w-sm text-center mx-auto py-4 text-base">
<p class="py-1">
<strong>Project and Task Estimates</strong> is only
available in solidtime Professional.
<slot></slot>
</p>
<p class="py-1">
<p class="py-1 text-sm">
If you want to use this feature,
<strong>please upgrade to a paid plan</strong>.
<strong class="font-semibold text-text-primary"
>please upgrade to a paid plan</strong
>
or
<strong class="font-semibold text-text-primary"
>request a free trial</strong
>
via
<a
class="text-accent-200/80 transition text-accent-300"
href="mailto:hello@solidtime.io"
>hello@solidtime.io</a
>
to try out this feature.
</p>
<Link
@@ -43,10 +52,10 @@ const show = defineModel('show', { default: false });
<PrimaryButton
type="button"
class="mt-6"
:icon="CreditCardIcon"
v-if="
isBillingActivated() && canUpdateOrganization()
">
<CreditCardIcon class="w-5 h-5 me-2" />
Go to Billing
</PrimaryButton>
</Link>

View File

@@ -38,7 +38,7 @@ async function startTaskTimer() {
<div
class="px-3.5 py-2 grid grid-cols-5 border-b border-b-card-background-separator">
<div class="col-span-4">
<p class="font-semibold text-white text-sm pb-1">
<p class="font-semibold text-white text-sm pb-1 overflow-ellipsis">
{{ title }}
</p>
<ProjectBadge

View File

@@ -1,35 +1,109 @@
<script setup lang="ts">
import type { Component } from 'vue';
import { Link } from '@inertiajs/vue3';
import { type Component } from 'vue';
import NavigationSidebarLink from '@/Components/NavigationSidebarLink.vue';
import {
CollapsibleContent,
CollapsibleRoot,
CollapsibleTrigger,
} from 'radix-vue';
import { useSessionStorage } from '@vueuse/core';
import { ChevronRightIcon } from '@heroicons/vue/20/solid';
defineProps<{
const props = defineProps<{
title: string;
icon: Component;
icon?: Component;
current?: boolean;
href: string;
subItems?: { title: string; route: string }[];
}>();
const open = useSessionStorage('nav-collapse-state-' + props.title, true);
</script>
<template>
<li>
<Link
:href="href"
:class="[
current
? 'bg-menu-active text-white'
: 'text-muted hover:text-white hover:bg-menu-active ',
'group flex gap-x-2 rounded-md px-2 py-1.5 transition leading-6 font-semibold text-sm items-center',
]">
<component
:is="icon"
:class="[
current
? 'text-icon-active'
: 'text-icon-default group-hover:text-icon-active',
'transition h-5 w-5 shrink-0',
]"
aria-hidden="true" />
{{ title }}
</Link>
<li class="relative">
<NavigationSidebarLink
v-if="!subItems"
class="py-0.5"
:title
:icon
:current
:href></NavigationSidebarLink>
<CollapsibleRoot v-model:open="open" v-else
><CollapsibleTrigger class="w-full group py-0.5">
<div
class="text-muted group-hover:text-white group-hover:bg-menu-active group flex gap-x-2 rounded-md transition leading-6 py-1 px-2 font-medium text-sm items-center justify-between">
<div class="flex items-center gap-x-2">
<component
v-if="icon"
:is="icon"
:class="[
current
? 'text-icon-active'
: 'text-icon-default group-hover:text-icon-active',
'transition h-5 w-5 shrink-0',
]"
aria-hidden="true" />
<span>
{{ title }}
</span>
</div>
<ChevronRightIcon
:class="[
'w-5 text-text-secondary',
{ 'transform rotate-90': open },
]"></ChevronRightIcon>
</div>
</CollapsibleTrigger>
<CollapsibleContent class="CollapsibleContent">
<div class="px-3.5">
<ul
class="flex min-w-0 flex-col border-l border-border-secondary px-3 w-full my-0.5"
v-if="subItems">
<li
v-for="subItem in subItems"
:key="subItem.title"
class="w-full relative">
<NavigationSidebarLink
:title="subItem.title"
:current="route().current(subItem.route)"
:href="
route(subItem.route)
"></NavigationSidebarLink>
</li>
</ul>
</div>
</CollapsibleContent>
</CollapsibleRoot>
</li>
</template>
<style scoped>
.CollapsibleContent {
overflow: hidden;
}
.CollapsibleContent[data-state='open'] {
animation: slideDown 300ms ease-out;
}
.CollapsibleContent[data-state='closed'] {
animation: slideUp 300ms ease-out;
}
@keyframes slideDown {
from {
height: 0;
}
to {
height: var(--radix-collapsible-content-height);
}
}
@keyframes slideUp {
from {
height: var(--radix-collapsible-content-height);
}
to {
height: 0;
}
}
</style>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import type { Component } from 'vue';
defineProps<{
title: string;
icon?: Component;
current?: boolean;
href: string;
}>();
</script>
<template>
<Link :href="href" class="block group">
<div
:class="[
current
? 'bg-menu-active text-white'
: 'text-muted group-hover:text-white group-hover:bg-menu-active ',
'group flex gap-x-2 rounded-md transition leading-6 py-1 px-2 font-medium text-sm items-center',
]">
<component
v-if="icon"
:is="icon"
:class="[
current
? 'text-icon-active'
: 'text-icon-default group-hover:text-icon-active',
'transition h-5 w-5 shrink-0',
]"
aria-hidden="true" />
{{ title }}
</div>
</Link>
</template>
<style scoped></style>

View File

@@ -41,8 +41,10 @@
<Link
v-if="isBillingActivated() && canManageBilling()"
href="/billing">
<PrimaryButton type="button" class="mt-6">
<CreditCardIcon class="w-5 h-5 me-2" />
<PrimaryButton
:icon="CreditCardIcon"
type="button"
class="mt-6">
Go to Billing
</PrimaryButton>
</Link>

View File

@@ -114,13 +114,27 @@ const page = usePage<{
<NavigationSidebarItem
title="Reporting"
:icon="ChartBarIcon"
:sub-items="[
{
title: 'Overview',
route: 'reporting',
},
{
title: 'Detailed',
route: 'reporting.detailed',
},
{
title: 'Shared',
route: 'reporting.shared',
},
]"
:current="
route().current('reporting') ||
route().current('reporting.detailed')
route().current('reporting.detailed') ||
route().current('reporting.shared')
"
:href="
route('reporting')
"></NavigationSidebarItem>
:href="route('reporting')">
</NavigationSidebarItem>
</ul>
</nav>

View File

@@ -12,8 +12,7 @@ import {
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import {
formatHumanReadableDuration,
getDayJsInstance,
@@ -22,7 +21,11 @@ import {
import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
import { storeToRefs } from 'pinia';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import { type AggregatedTimeEntriesQueryParams, api } from '@/packages/api/src';
import {
type AggregatedTimeEntriesQueryParams,
type CreateReportBodyProperties,
api,
} from '@/packages/api/src';
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultiselectDropdown.vue';
@@ -41,12 +44,12 @@ import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultisel
import { useTagsStore } from '@/utils/useTags';
import { formatCents } from '@/packages/ui/src/utils/money';
import { useSessionStorage, useStorage } from '@vueuse/core';
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import { router } from '@inertiajs/vue3';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
import { useNotificationsStore } from '@/utils/notification';
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
import type { ExportFormat } from '@/types/reporting';
import ReportSaveButton from '@/Components/Common/Report/ReportSaveButton.vue';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
const { handleApiRequestNotifications } = useNotificationsStore();
const startDate = useSessionStorage<string>(
@@ -163,6 +166,15 @@ async function createTag(tag: string) {
return await useTagsStore().createTag(tag);
}
const reportProperties = computed(() => {
return {
...getFilterAttributes(),
group: group.value,
sub_group: subGroup.value,
history_group: getOptimalGroupingOption(startDate.value, endDate.value),
} as CreateReportBodyProperties;
});
async function downloadExport(format: ExportFormat) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
@@ -186,9 +198,80 @@ async function downloadExport(format: ExportFormat) {
'Export successful',
'Export failed'
);
window.open(response.download_url, '_self')?.focus();
if (response?.download_url) {
showExportModal.value = true;
exportUrl.value = response.download_url as string;
}
}
}
const { getNameForReportingRowEntry, emptyPlaceholder } = useReportingStore();
import { useProjectsStore } from '@/utils/useProjects';
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
const projectsStore = useProjectsStore();
const { projects } = storeToRefs(projectsStore);
const showExportModal = ref(false);
const exportUrl = ref<string | null>(null);
const groupedPieChartData = computed(() => {
return (
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
const name = getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type
);
let color = getRandomColorWithSeed(entry.key ?? 'none');
if (
name &&
aggregatedTableTimeEntries.value?.grouped_type &&
emptyPlaceholder[
aggregatedTableTimeEntries.value?.grouped_type
] === name
) {
color = '#CCCCCC';
} else if (
aggregatedTableTimeEntries.value?.grouped_type === 'project'
) {
color =
projects.value?.find((project) => project.id === entry.key)
?.color ?? '#CCCCCC';
}
return {
value: entry.seconds,
name:
getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type
) ?? '',
color: color,
};
}) ?? []
);
});
const tableData = computed(() => {
return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
return {
seconds: entry.seconds,
cost: entry.cost,
description: getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type
),
grouped_data:
entry.grouped_data?.map((el) => {
return {
seconds: el.seconds,
cost: el.cost,
description: getNameForReportingRowEntry(
el.key,
entry.grouped_type
),
};
}) ?? [],
};
});
});
</script>
<template>
@@ -196,22 +279,21 @@ async function downloadExport(format: ExportFormat) {
title="Reporting"
data-testid="reporting_view"
class="overflow-hidden">
<ReportingExportModal
v-model:show="showExportModal"
:exportUrl="exportUrl"></ReportingExportModal>
<MainContainer
class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
<TabBar>
<TabBarItem @click="router.visit(route('reporting'))" active
>Overview</TabBarItem
>
<TabBarItem
@click="router.visit(route('reporting.detailed'))"
>Detailed</TabBarItem
>
</TabBar>
<ReportingTabNavbar active="reporting"></ReportingTabNavbar>
</div>
<div class="flex space-x-2">
<ReportingExportButton
:download="downloadExport"></ReportingExportButton>
<ReportSaveButton
:reportProperties="reportProperties"></ReportSaveButton>
</div>
<ReportingExportButton
:download="downloadExport"></ReportingExportButton>
</MainContainer>
<div class="py-2.5 w-full border-b border-default-background-separator">
<MainContainer
@@ -360,8 +442,8 @@ async function downloadExport(format: ExportFormat) {
?.length > 0
">
<ReportingRow
v-for="entry in aggregatedTableTimeEntries.grouped_data"
:key="entry.key ?? 'none'"
v-for="entry in tableData"
:key="entry.description ?? 'none'"
:entry="entry"
:type="
aggregatedTableTimeEntries.grouped_type
@@ -402,10 +484,7 @@ async function downloadExport(format: ExportFormat) {
</div>
<div class="px-2 lg:px-4">
<ReportingPieChart
:type="aggregatedTableTimeEntries?.grouped_type"
:data="
aggregatedTableTimeEntries?.grouped_data
"></ReportingPieChart>
:data="groupedPieChartData"></ReportingPieChart>
</div>
</div>
</MainContainer>

View File

@@ -29,7 +29,6 @@ import {
type CreateClientBody,
type CreateProjectBody,
type Project,
type TimeEntriesQueryParams,
type TimeEntry,
type TimeEntryResponse,
} from '@/packages/api/src';
@@ -41,9 +40,6 @@ import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
import { useTagsStore } from '@/utils/useTags';
import { useSessionStorage } from '@vueuse/core';
import { router } from '@inertiajs/vue3';
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { useProjectsStore } from '@/utils/useProjects';
@@ -64,12 +60,14 @@ import {
import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
import type { ExportFormat } from '@/types/reporting';
import { useNotificationsStore } from '@/utils/notification';
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canCreateProjects } from '@/utils/permissions';
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
const startDate = useSessionStorage<string>(
'reporting-start-date',
@@ -91,15 +89,15 @@ const pageLimit = 15;
const currentPage = ref(1);
function getFilterAttributes() {
let params: TimeEntriesQueryParams = {
const defaultParams = {
start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(),
end: getLocalizedDayJs(endDate.value).endOf('day').utc().format(),
active: 'false',
active: 'false' as 'true' | 'false',
limit: pageLimit,
offset: currentPage.value * pageLimit - pageLimit,
};
params = {
...params,
const params = {
...defaultParams,
member_ids:
selectedMembers.value.length > 0
? selectedMembers.value
@@ -137,7 +135,7 @@ const { data: timeEntryResponse } = useQuery<TimeEntryResponse>({
params: {
organization: getCurrentOrganizationId() || '',
},
queries: getFilterAttributes(),
queries: { ...getFilterAttributes() },
}),
});
@@ -170,6 +168,9 @@ const { clients } = storeToRefs(clientStore);
const selectedTimeEntries = ref<TimeEntry[]>([]);
const showExportModal = ref(false);
const exportUrl = ref<string | null>(null);
async function createTag(name: string) {
return await useTagsStore().createTag(name);
}
@@ -236,7 +237,10 @@ async function downloadExport(format: ExportFormat) {
'Export successful',
'Export failed'
);
window.open(response.download_url, '_self')?.focus();
if (response?.download_url) {
showExportModal.value = true;
exportUrl.value = response.download_url as string;
}
}
}
</script>
@@ -246,24 +250,19 @@ async function downloadExport(format: ExportFormat) {
title="Reporting"
data-testid="reporting_view"
class="overflow-hidden">
<ReportingExportModal
v-model:show="showExportModal"
:exportUrl="exportUrl"></ReportingExportModal>
<MainContainer
class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
<TabBar>
<TabBarItem @click="router.visit(route('reporting'))"
>Overview
</TabBarItem>
<TabBarItem
@click="router.visit(route('reporting.detailed'))"
active
>Detailed
</TabBarItem>
</TabBar>
<ReportingTabNavbar active="detailed"></ReportingTabNavbar>
</div>
<ReportingExportButton
:download="downloadExport"></ReportingExportButton>
</MainContainer>
<div class="py-2.5 w-full border-b border-default-background-separator">
<MainContainer
class="sm:flex space-y-4 sm:space-y-0 justify-between">

View File

@@ -0,0 +1,186 @@
<script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue';
import PageTitle from '@/Components/Common/PageTitle.vue';
import {
ChartBarIcon,
ChevronLeftIcon,
ChevronDoubleLeftIcon,
ChevronRightIcon,
ChevronDoubleRightIcon,
CreditCardIcon,
UserGroupIcon,
} from '@heroicons/vue/20/solid';
import { computed, ref, watch } from 'vue';
import { api, type ReportIndexResponse } from '@/packages/api/src';
import {
PaginationEllipsis,
PaginationFirst,
PaginationLast,
PaginationList,
PaginationListItem,
PaginationNext,
PaginationPrev,
PaginationRoot,
} from 'radix-vue';
import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
import ReportTable from '@/Components/Common/Report/ReportTable.vue';
import {
isAllowedToPerformPremiumAction,
isBillingActivated,
} from '@/utils/billing';
import { canManageBilling, canUpdateOrganization } from '@/utils/permissions';
import PrimaryButton from '../packages/ui/src/Buttons/PrimaryButton.vue';
import { Link } from '@inertiajs/vue3';
const pageLimit = 15;
const currentPage = ref(1);
const { data: reportsResponse } = useQuery<ReportIndexResponse>({
queryKey: ['reports', currentPage],
enabled: !!getCurrentOrganizationId(),
queryFn: () =>
api.getReports({
params: {
organization: getCurrentOrganizationId() || '',
},
}),
});
const reports = computed(() => {
return reportsResponse.value?.data ?? [];
});
const totalPages = computed(() => {
return 1;
});
const queryClient = useQueryClient();
async function updateFilteredTimeEntries() {
await queryClient.invalidateQueries({
queryKey: ['reports'],
});
}
watch(currentPage, () => {
updateFilteredTimeEntries();
});
</script>
<template>
<AppLayout
title="Reporting"
data-testid="reporting_view"
class="overflow-hidden">
<MainContainer
class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
<ReportingTabNavbar active="shared"></ReportingTabNavbar>
</div>
</MainContainer>
<div v-if="!isAllowedToPerformPremiumAction()">
<div class="py-12">
<div
class="rounded-full flex items-center justify-center w-20 h-20 mx-auto border border-border-tertiary bg-secondary">
<UserGroupIcon class="w-12"></UserGroupIcon>
</div>
<div class="max-w-sm text-center mx-auto py-4 text-base">
<p class="py-1">
<slot></slot>
</p>
<p class="py-1">
If you want to use <strong>sharable reports</strong> ,
<strong>please upgrade to a paid plan</strong>.
</p>
<Link
v-if="isBillingActivated() && canManageBilling()"
href="/billing">
<PrimaryButton
type="button"
class="mt-6"
:icon="CreditCardIcon"
v-if="
isBillingActivated() && canUpdateOrganization()
">
Go to Billing
</PrimaryButton>
</Link>
</div>
</div>
</div>
<ReportTable
v-if="reports.length > 0 || isAllowedToPerformPremiumAction()"
:reports="reports"></ReportTable>
<PaginationRoot
v-if="reports.length > 0 || isAllowedToPerformPremiumAction()"
:total="totalPages"
:items-per-page="pageLimit"
class="flex justify-center items-center py-8"
v-model:page="currentPage"
:sibling-count="1"
show-edges>
<PaginationList
v-slot="{ items }"
class="flex items-center space-x-1 relative">
<div
class="pr-2 flex items-center space-x-1 border-r border-border-primary mr-1">
<PaginationFirst class="navigation-item">
<ChevronDoubleLeftIcon class="w-4">
</ChevronDoubleLeftIcon>
</PaginationFirst>
<PaginationPrev class="mr-4 navigation-item">
<ChevronLeftIcon
class="w-4 text-text-tertiary hover:text-text-primary">
</ChevronLeftIcon>
</PaginationPrev>
</div>
<template v-for="(page, index) in items">
<PaginationListItem
v-if="page.type === 'page'"
:key="index"
class="pagination-item"
:value="page.value">
{{ page.value }}
</PaginationListItem>
<PaginationEllipsis
v-else
:key="page.type"
:index="index"
class="PaginationEllipsis">
<div class="px-2">&#8230;</div>
</PaginationEllipsis>
</template>
<div
class="!ml-2 pl-2 flex items-center space-x-1 border-l border-border-primary">
<PaginationNext class="navigation-item">
<ChevronRightIcon
class="w-4 text-text-tertiary hover:text-text-primary"></ChevronRightIcon>
</PaginationNext>
<PaginationLast class="navigation-item">
<ChevronDoubleRightIcon
class="w-4 text-text-tertiary hover:text-text-primary"></ChevronDoubleRightIcon>
</PaginationLast>
</div>
</PaginationList>
</PaginationRoot>
</AppLayout>
</template>
<style lang="postcss">
.navigation-item {
@apply bg-quaternary h-8 w-8 flex items-center justify-center rounded border border-border-primary text-text-tertiary hover:text-text-primary transition cursor-pointer hover:border-border-secondary hover:bg-secondary focus-visible:text-text-primary focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-white/80;
}
.pagination-item {
@apply bg-secondary h-8 w-8 flex items-center justify-center rounded border border-border-tertiary text-text-secondary hover:text-text-primary transition cursor-pointer hover:border-border-secondary hover:bg-secondary focus-visible:text-text-primary focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-white/80;
}
.pagination-item[data-selected] {
@apply text-white bg-accent-300/10 border border-accent-300/20 rounded-md font-medium hover:bg-accent-300/20 active:bg-accent-300/20 outline-0 focus-visible:ring-2 focus:ring-white/80 transition ease-in-out duration-150;
}
</style>

View File

@@ -0,0 +1,236 @@
<script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import PageTitle from '@/Components/Common/PageTitle.vue';
import { ChartBarIcon } from '@heroicons/vue/20/solid';
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
import { computed, onMounted, ref } from 'vue';
import { useQuery } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
import { useReportingStore } from '@/utils/useReporting';
import { Head } from '@inertiajs/vue3';
const sharedSecret = ref<string | null>(null);
const hasSharedSecret = computed(() => {
return sharedSecret.value !== null;
});
const { data: sharedReportResponseData } = useQuery({
enabled: hasSharedSecret,
queryKey: ['reporting', sharedSecret],
queryFn: () =>
api.getPublicReport({
headers: {
'X-Api-Key': sharedSecret.value,
},
}),
});
onMounted(() => {
const currentUrl = window.location.href;
// check if # exists exactly once in the URL
if (currentUrl.split('#').length === 2) {
sharedSecret.value = currentUrl.split('#')[1];
}
});
const aggregatedTableTimeEntries = computed(() => {
if (sharedReportResponseData.value) {
return sharedReportResponseData.value?.data;
}
return {
grouped_data: [],
grouped_type: 'project',
seconds: 0,
cost: 0,
};
});
const aggregatedGraphTimeEntries = computed(() => {
if (sharedReportResponseData.value) {
return sharedReportResponseData.value?.history_data;
}
// Placeholder Data
return {
grouped_data: [],
grouped_type: 'project',
seconds: 0,
cost: 0,
};
});
const group = computed(() => {
if (sharedReportResponseData.value) {
return sharedReportResponseData.value?.properties.group;
}
return 'billable';
});
const subGroup = computed(() => {
if (sharedReportResponseData.value) {
return sharedReportResponseData.value?.properties.sub_group;
}
return 'project';
});
const { emptyPlaceholder } = useReportingStore();
const groupedPieChartData = computed(() => {
return (
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
if (entry.description === null) {
return {
value: entry.seconds,
name: emptyPlaceholder[
aggregatedTableTimeEntries.value?.grouped_type ??
'project'
],
color: '#CCCCCC',
};
}
return {
value: entry.seconds,
name: entry.description,
color:
entry.color ??
getRandomColorWithSeed(entry.description ?? 'none'),
};
}) ?? []
);
});
const tableData = computed(() => {
return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
return {
seconds: entry.seconds,
cost: entry.cost,
description:
entry.description ??
emptyPlaceholder[
aggregatedTableTimeEntries.value?.grouped_type ?? 'project'
],
grouped_data:
entry.grouped_data?.map((el) => {
return {
seconds: el.seconds,
cost: el.cost,
description:
el.description ??
emptyPlaceholder[
aggregatedTableTimeEntries.value
?.grouped_type ?? 'project'
],
};
}) ?? [],
};
});
});
const { groupByOptions } = useReportingStore();
function getGroupLabel(key: string) {
return groupByOptions.find((option) => {
return option.value === key;
})?.label;
}
</script>
<template>
<Head :title="sharedReportResponseData?.name" />
<div class="text-muted">
<MainContainer
class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
</div>
</MainContainer>
<MainContainer>
<div class="pt-10 w-full px-3 relative">
<ReportingChart
:groupedType="aggregatedGraphTimeEntries?.grouped_type"
:groupedData="
aggregatedGraphTimeEntries?.grouped_data
"></ReportingChart>
</div>
</MainContainer>
<MainContainer>
<div class="sm:grid grid-cols-4 pt-6 items-start">
<div
class="col-span-3 bg-card-background rounded-lg border border-card-border pt-3">
<div
class="text-sm flex text-white items-center font-medium px-6 border-b border-card-background-separator pb-3">
Group by
<strong class="px-2">{{ getGroupLabel(group) }}</strong>
and
<strong class="px-2">{{
getGroupLabel(subGroup)
}}</strong>
</div>
<div
class="grid items-center"
style="grid-template-columns: 1fr 100px 150px">
<div
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:bg-tertiary [&>*]:pb-1.5 [&>*]:pt-1 text-muted text-sm">
<div class="pl-6">Name</div>
<div class="text-right">Duration</div>
<div class="text-right pr-6">Cost</div>
</div>
<template
v-if="
aggregatedTableTimeEntries?.grouped_data &&
aggregatedTableTimeEntries.grouped_data
?.length > 0
">
<ReportingRow
v-for="entry in tableData"
:key="entry.description ?? 'none'"
:entry="entry"
:type="
aggregatedTableTimeEntries.grouped_type
"></ReportingRow>
<div
class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
<div class="flex items-center pl-6 font-medium">
<span>Total</span>
</div>
<div
class="justify-end flex items-center font-medium">
{{
formatHumanReadableDuration(
aggregatedTableTimeEntries.seconds
)
}}
</div>
<div
class="justify-end pr-6 flex items-center font-medium">
{{
formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString()
)
}}
</div>
</div>
</template>
<div
class="chart flex flex-col items-center justify-center py-12 col-span-3"
v-else>
<p class="text-lg text-white font-semibold">
No time entries found
</p>
<p>Try to change the filters and time range</p>
</div>
</div>
</div>
<div class="px-2 lg:px-4">
<ReportingPieChart
:data="groupedPieChartData"></ReportingPieChart>
</div>
</div>
</MainContainer>
</div>
</template>

View File

@@ -64,8 +64,7 @@ const updateTeamName = () => {
<Link
v-if="isBillingActivated() && canManageBilling()"
href="/billing">
<PrimaryButton type="button">
<CreditCardIcon class="w-5 h-5 me-2" />
<PrimaryButton :icon="CreditCardIcon" type="button">
Go to Billing
</PrimaryButton>
</Link>

View File

@@ -32,7 +32,7 @@ createInertiaApp({
user: User;
};
}>();
return page.props.auth.user.week_start;
return page.props.auth.user.week_start ?? 'monday';
};
window.getTimezoneSetting = function () {
const page = usePage<{

View File

@@ -152,6 +152,16 @@ export type OrganizationExportResponse = ZodiosResponseByAlias<
'exportOrganization'
>;
export type ReportIndexResponse = ZodiosResponseByAlias<
SolidTimeApi,
'getReports'
>;
export type CreateReportBody = ZodiosBodyByAlias<SolidTimeApi, 'createReport'>;
export type UpdateReportBody = ZodiosBodyByAlias<SolidTimeApi, 'updateReport'>;
export type CreateReportBodyProperties = CreateReportBody['properties'];
export type Report = ReportIndexResponse['data'][0];
const api = createApiClient('/api', { validate: 'none' });
export { createApiClient, api };

View File

@@ -52,6 +52,7 @@ const OrganizationResource = z
is_personal: z.boolean(),
billable_rate: z.union([z.number(), z.null()]),
employees_can_see_billable_rates: z.boolean(),
currency: z.string(),
})
.passthrough();
const OrganizationUpdateRequest = z
@@ -72,6 +73,7 @@ const ProjectResource = z
is_billable: z.boolean(),
estimated_time: z.union([z.number(), z.null()]),
spent_time: z.number().int(),
is_public: z.boolean(),
})
.passthrough();
const ProjectStoreRequest = z
@@ -82,6 +84,7 @@ const ProjectStoreRequest = z
billable_rate: z.union([z.number(), z.null()]).optional(),
client_id: z.union([z.string(), z.null()]).optional(),
estimated_time: z.union([z.number(), z.null()]).optional(),
is_public: z.boolean().optional(),
})
.passthrough();
const ProjectUpdateRequest = z
@@ -90,6 +93,7 @@ const ProjectUpdateRequest = z
color: z.string().max(255),
is_billable: z.boolean(),
is_archived: z.boolean().optional(),
is_public: z.boolean().optional(),
client_id: z.union([z.string(), z.null()]).optional(),
billable_rate: z.union([z.number(), z.null()]).optional(),
estimated_time: z.union([z.number(), z.null()]).optional(),
@@ -113,6 +117,231 @@ const ProjectMemberUpdateRequest = z
.object({ billable_rate: z.union([z.number(), z.null()]) })
.partial()
.passthrough();
const ReportResource = z
.object({
id: z.string(),
name: z.string(),
description: z.union([z.string(), z.null()]),
is_public: z.boolean(),
public_until: z.union([z.string(), z.null()]),
shareable_link: z.union([z.string(), z.null()]),
created_at: z.string(),
updated_at: z.string(),
})
.passthrough();
const TimeEntryAggregationType = z.enum([
'day',
'week',
'month',
'year',
'user',
'project',
'task',
'client',
'billable',
'description',
]);
const TimeEntryAggregationTypeInterval = z.enum([
'day',
'week',
'month',
'year',
]);
const Weekday = z.enum([
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
]);
const ReportStoreRequest = z
.object({
name: z.string().max(255),
description: z.union([z.string(), z.null()]).optional(),
is_public: z.boolean(),
public_until: z.union([z.string(), z.null()]).optional(),
properties: z
.object({
start: z.string(),
end: z.string(),
active: z.union([z.boolean(), z.null()]).optional(),
member_ids: z
.union([z.array(z.string().uuid()), z.null()])
.optional(),
billable: z.union([z.boolean(), z.null()]).optional(),
client_ids: z
.union([z.array(z.string().uuid()), z.null()])
.optional(),
project_ids: z
.union([z.array(z.string().uuid()), z.null()])
.optional(),
tag_ids: z
.union([z.array(z.string().uuid()), z.null()])
.optional(),
task_ids: z
.union([z.array(z.string().uuid()), z.null()])
.optional(),
group: TimeEntryAggregationType.optional(),
sub_group: TimeEntryAggregationType.optional(),
history_group: TimeEntryAggregationTypeInterval.optional(),
week_start: Weekday.optional(),
timezone: z.union([z.string(), z.null()]).optional(),
})
.passthrough(),
'properties.member_ids': z.string().optional(),
'properties.client_ids': z.string().optional(),
'properties.project_ids': z.string().optional(),
'properties.tag_ids': z.string().optional(),
'properties.task_ids': z.string().optional(),
'properties.week_start': z.string().optional(),
'properties.timezone': z.string().optional(),
})
.passthrough();
const DetailedReportResource = z
.object({
id: z.string(),
name: z.string(),
description: z.union([z.string(), z.null()]),
is_public: z.boolean(),
public_until: z.union([z.string(), z.null()]),
shareable_link: z.union([z.string(), z.null()]),
properties: z
.object({
group: z.string(),
sub_group: z.string(),
history_group: z.string(),
start: z.string(),
end: z.string(),
active: z.union([z.boolean(), z.null()]),
member_ids: z.union([z.array(z.string()), z.null()]),
billable: z.union([z.boolean(), z.null()]),
client_ids: z.union([z.array(z.string()), z.null()]),
project_ids: z.union([z.array(z.string()), z.null()]),
tag_ids: z.union([z.array(z.string()), z.null()]),
task_ids: z.union([z.array(z.string()), z.null()]),
})
.passthrough(),
created_at: z.string(),
updated_at: z.string(),
})
.passthrough();
const ReportUpdateRequest = z
.object({
name: z.string().max(255),
description: z.union([z.string(), z.null()]),
is_public: z.boolean(),
public_until: z.union([z.string(), z.null()]),
})
.partial()
.passthrough();
const DetailedWithDataReportResource = z
.object({
name: z.string(),
description: z.union([z.string(), z.null()]),
public_until: z.union([z.string(), z.null()]),
currency: z.string(),
properties: z
.object({
group: z.string(),
sub_group: z.string(),
history_group: z.string(),
start: z.string(),
end: z.string(),
})
.passthrough(),
data: z
.object({
grouped_type: z.union([z.string(), z.null()]),
grouped_data: z.union([
z.array(
z
.object({
key: z.union([z.string(), z.null()]),
description: z.union([z.string(), z.null()]),
color: z.union([z.string(), z.null()]),
seconds: z.number().int(),
cost: z.number().int(),
grouped_type: z.union([z.string(), z.null()]),
grouped_data: z.union([
z.array(
z
.object({
key: z.union([
z.string(),
z.null(),
]),
description: z.union([
z.string(),
z.null(),
]),
color: z.union([
z.string(),
z.null(),
]),
seconds: z.number().int(),
cost: z.number().int(),
grouped_type: z.null(),
grouped_data: z.null(),
})
.passthrough()
),
z.null(),
]),
})
.passthrough()
),
z.null(),
]),
seconds: z.number().int(),
cost: z.number().int(),
})
.passthrough(),
history_data: z
.object({
grouped_type: z.union([z.string(), z.null()]),
grouped_data: z.union([
z.array(
z
.object({
key: z.union([z.string(), z.null()]),
description: z.union([z.string(), z.null()]),
seconds: z.number().int(),
cost: z.number().int(),
grouped_type: z.union([z.string(), z.null()]),
grouped_data: z.union([
z.array(
z
.object({
key: z.union([
z.string(),
z.null(),
]),
description: z.union([
z.string(),
z.null(),
]),
seconds: z.number().int(),
cost: z.number().int(),
grouped_type: z.null(),
grouped_data: z.null(),
})
.passthrough()
),
z.null(),
]),
})
.passthrough()
),
z.null(),
]),
seconds: z.number().int(),
cost: z.number().int(),
})
.passthrough(),
})
.passthrough();
const TagResource = z
.object({
id: z.string(),
@@ -211,15 +440,6 @@ const TimeEntryUpdateRequest = z
})
.partial()
.passthrough();
const Weekday = z.enum([
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
]);
const UserResource = z
.object({
id: z.string(),
@@ -234,7 +454,7 @@ const PersonalMembershipResource = z
.object({
id: z.string(),
organization: z
.object({ id: z.string(), name: z.string() })
.object({ id: z.string(), name: z.string(), currency: z.string() })
.passthrough(),
role: z.string(),
})
@@ -260,6 +480,14 @@ export const schemas = {
ProjectMemberResource,
ProjectMemberStoreRequest,
ProjectMemberUpdateRequest,
ReportResource,
TimeEntryAggregationType,
TimeEntryAggregationTypeInterval,
Weekday,
ReportStoreRequest,
DetailedReportResource,
ReportUpdateRequest,
DetailedWithDataReportResource,
TagResource,
TagCollection,
TagStoreRequest,
@@ -272,7 +500,6 @@ export const schemas = {
TimeEntryStoreRequest,
TimeEntryUpdateMultipleRequest,
TimeEntryUpdateRequest,
Weekday,
UserResource,
PersonalMembershipResource,
PersonalMembershipCollection,
@@ -1696,6 +1923,238 @@ const endpoints = makeApi([
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/reports',
alias: 'getReports',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
],
response: z
.object({
data: z.array(ReportResource),
links: z
.object({
first: z.union([z.string(), z.null()]),
last: z.union([z.string(), z.null()]),
prev: z.union([z.string(), z.null()]),
next: z.union([z.string(), z.null()]),
})
.passthrough(),
meta: z
.object({
current_page: z.number().int(),
from: z.union([z.number(), z.null()]),
last_page: z.number().int(),
links: z.array(
z
.object({
url: z.union([z.string(), z.null()]),
label: z.string(),
active: z.boolean(),
})
.passthrough()
),
path: z.union([z.string(), z.null()]),
per_page: z.number().int(),
to: z.union([z.number(), z.null()]),
total: z.number().int(),
})
.passthrough(),
})
.passthrough(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
{
method: 'post',
path: '/v1/organizations/:organization/reports',
alias: 'createReport',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: ReportStoreRequest,
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: DetailedReportResource }).passthrough(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/reports/:report',
alias: 'getReport',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'report',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: DetailedReportResource }).passthrough(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
{
method: 'put',
path: '/v1/organizations/:organization/reports/:report',
alias: 'updateReport',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: ReportUpdateRequest,
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'report',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: DetailedReportResource }).passthrough(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
},
{
method: 'delete',
path: '/v1/organizations/:organization/reports/:report',
alias: 'deleteReport',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'report',
type: 'Path',
schema: z.string(),
},
],
response: z.null(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/tags',
@@ -2744,6 +3203,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'debug',
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'member_ids',
type: 'Query',
@@ -2770,7 +3234,12 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
schema: z.array(z.string()).min(1).optional(),
},
],
response: z.object({ download_url: z.string() }).passthrough(),
response: z.union([
z.object({ download_url: z.string() }).passthrough(),
z
.object({ html: z.string(), footer_html: z.string() })
.passthrough(),
]),
errors: [
{
status: 400,
@@ -2834,12 +3303,12 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
{
name: 'start',
type: 'Query',
schema: start,
schema: z.string(),
},
{
name: 'end',
type: 'Query',
schema: start,
schema: z.string(),
},
{
name: 'active',
@@ -2861,6 +3330,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'debug',
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'member_ids',
type: 'Query',
@@ -2882,7 +3356,12 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
schema: z.array(z.string().uuid()).min(1).optional(),
},
],
response: z.object({ download_url: z.string() }).passthrough(),
response: z.union([
z.object({ download_url: z.string() }).passthrough(),
z
.object({ html: z.string(), footer_html: z.string() })
.passthrough(),
]),
errors: [
{
status: 400,
@@ -2922,6 +3401,23 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
},
],
},
{
method: 'get',
path: '/v1/public/reports',
alias: 'getPublicReport',
description: `This endpoint is public and does not require authentication. The report must be public and not expired.
The report is considered expired if the &#x60;public_until&#x60; field is set and the date is in the past.
The report is considered public if the &#x60;is_public&#x60; field is set to &#x60;true&#x60;.`,
requestFormat: 'json',
response: DetailedWithDataReportResource,
errors: [
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
{
method: 'get',
path: '/v1/users/me',

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import type { HtmlButtonType } from '@/types/dom';
import LoadingSpinner from '../LoadingSpinner.vue';
import type { Component } from 'vue';
import { twMerge } from 'tailwind-merge';
withDefaults(
const props = withDefaults(
defineProps<{
type: HtmlButtonType;
icon?: Component;
loading: boolean;
}>(),
{
@@ -19,7 +22,18 @@ withDefaults(
:type="type"
:disabled="loading"
class="inline-flex items-center px-2 sm:px-3 py-1 sm:py-2 bg-accent-300/10 border border-accent-300/20 rounded-md font-medium text-xs sm:text-sm text-white hover:bg-accent-300/20 active:bg-accent-300/20 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition ease-in-out duration-150">
<LoadingSpinner v-if="loading"></LoadingSpinner>
<slot />
<span
:class="
twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')
">
<LoadingSpinner v-if="loading"></LoadingSpinner>
<component
v-if="props.icon && !loading"
:is="props.icon"
class="text-text-secondary w-4 -ml-0.5 mr-1"></component>
<span>
<slot />
</span>
</span>
</button>
</template>

View File

@@ -46,7 +46,7 @@ const emit = defineEmits(['changed']);
</script>
<template>
<div class="flex items-center justify-center text-muted">
<div class="flex items-center text-muted">
<input
ref="datePicker"
@change="updateTempValue"

View File

@@ -2,7 +2,11 @@
import { CalendarIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import { formatDate, getDayJsInstance } from '@/packages/ui/src/utils/time';
import {
formatDateLocalized,
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { ref } from 'vue';
const start = defineModel('start', { default: '' });
@@ -12,39 +16,46 @@ const emit = defineEmits(['submit']);
const open = ref(false);
function setToday() {
start.value = getLocalizedDayJs().startOf('day').format();
end.value = getLocalizedDayJs().endOf('day').format();
emit('submit');
open.value = false;
}
function setThisWeek() {
start.value = getDayJsInstance()().startOf('week').format();
end.value = getDayJsInstance()().endOf('week').format();
start.value = getLocalizedDayJs().startOf('week').format();
end.value = getLocalizedDayJs().endOf('week').format();
emit('submit');
open.value = false;
}
function setLastWeek() {
start.value = getDayJsInstance()()
start.value = getLocalizedDayJs()
.subtract(1, 'week')
.startOf('week')
.format();
end.value = getDayJsInstance()().subtract(1, 'week').endOf('week').format();
end.value = getLocalizedDayJs().subtract(1, 'week').endOf('week').format();
emit('submit');
open.value = false;
}
function setLast14Days() {
start.value = getDayJsInstance()().subtract(14, 'days').format();
end.value = getDayJsInstance()().format();
start.value = getLocalizedDayJs().subtract(14, 'days').format();
end.value = getLocalizedDayJs().format();
emit('submit');
open.value = false;
}
function setThisMonth() {
start.value = getDayJsInstance()().startOf('month').format();
end.value = getDayJsInstance()().endOf('month').format();
start.value = getLocalizedDayJs().startOf('month').format();
end.value = getLocalizedDayJs().endOf('month').format();
emit('submit');
open.value = false;
}
function setLastMonth() {
start.value = getDayJsInstance()()
start.value = getLocalizedDayJs()
.subtract(1, 'month')
.startOf('month')
.format();
end.value = getDayJsInstance()()
end.value = getLocalizedDayJs()
.subtract(1, 'month')
.endOf('month')
.format();
@@ -52,8 +63,8 @@ function setLastMonth() {
open.value = false;
}
function setLast30Days() {
start.value = getDayJsInstance()().subtract(30, 'days').format();
end.value = getDayJsInstance()().format();
start.value = getLocalizedDayJs().subtract(30, 'days').format();
end.value = getLocalizedDayJs().format();
emit('submit');
open.value = false;
}
@@ -64,23 +75,23 @@ function setLast90Days() {
open.value = false;
}
function setLast12Months() {
start.value = getDayJsInstance()().subtract(12, 'months').format();
end.value = getDayJsInstance()().format();
start.value = getLocalizedDayJs().subtract(12, 'months').format();
end.value = getLocalizedDayJs().format();
emit('submit');
open.value = false;
}
function setThisYear() {
start.value = getDayJsInstance()().startOf('year').format();
end.value = getDayJsInstance()().endOf('year').format();
start.value = getLocalizedDayJs().startOf('year').format();
end.value = getLocalizedDayJs().endOf('year').format();
emit('submit');
open.value = false;
}
function setLastYear() {
start.value = getDayJsInstance()()
start.value = getLocalizedDayJs()
.subtract(1, 'year')
.startOf('year')
.format();
end.value = getDayJsInstance()().subtract(1, 'year').endOf('year').format();
end.value = getLocalizedDayJs().subtract(1, 'year').endOf('year').format();
emit('submit');
open.value = false;
}
@@ -97,9 +108,9 @@ function setLastYear() {
class="px-2 py-1 bg-input-background border border-input-border font-medium rounded-lg flex items-center space-x-2">
<CalendarIcon class="w-5"></CalendarIcon>
<div class="text-white">
{{ formatDate(start) }}
{{ formatDateLocalized(start) }}
<span class="px-1.5 text-muted">-</span>
{{ formatDate(end) }}
{{ formatDateLocalized(end) }}
</div>
</button>
</template>
@@ -109,6 +120,7 @@ function setLastYear() {
class="flex divide-x divide-border-secondary justify-between">
<div
class="text-white text-sm flex flex-col space-y-0.5 items-start py-2 [&_button:hover]:bg-tertiary [&_button]:rounded [&_button]:px-2 [&_button]:py-1">
<button @click="setToday">Today</button>
<button @click="setThisWeek">This Week</button>
<button @click="setLastWeek">Last Week</button>
<button @click="setLast14Days">Last 14 days</button>

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, watch } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
import { useId } from 'radix-vue';
import { isLastLayer, layers } from '@/packages/ui/src/utils/dismissableLayer';
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap';
import { ref } from 'vue';
const props = defineProps({
show: {
@@ -74,6 +76,25 @@ const maxWidthClass = computed(() => {
'2xl': 'sm:max-w-2xl',
}[props.maxWidth];
});
const target = ref();
const { activate, deactivate } = useFocusTrap(target, {
allowOutsideClick: true,
});
watch(
() => props.show,
(value) => {
if (value) {
nextTick(() => {
activate();
});
} else {
nextTick(() => {
deactivate();
});
}
}
);
</script>
<template>
@@ -109,6 +130,7 @@ const maxWidthClass = computed(() => {
<div
v-show="show"
role="dialog"
ref="target"
class="mb-6 bg-default-background border border-card-border rounded-lg shadow-xl transform transition-all sm:w-full sm:mx-auto"
:class="maxWidthClass">
<slot v-if="show" />

View File

@@ -49,7 +49,10 @@ function getNameForKey(key: BillableKey | undefined) {
:get-name-for-item="getNameFromItem"
:items="options">
<template #trigger>
<Badge size="xlarge" class="bg-input-background cursor-pointer">
<Badge
tag="button"
size="xlarge"
class="bg-input-background cursor-pointer">
<span>
{{ getNameForKey(model) }}
</span>

View File

@@ -113,6 +113,7 @@ const currentClientName = computed(() => {
v-model="project.client_id">
<template #trigger>
<Badge
tag="button"
class="bg-input-background cursor-pointer hover:bg-tertiary"
size="xlarge">
<div class="flex items-center space-x-2">

View File

@@ -57,7 +57,7 @@ const emit = defineEmits(['submit']);
<template>
<div class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4 pt-6">
<div>
<div class="flex items-center space-x-1">
<div class="flex items-center space-x-1 mb-2">
<BillableIcon
class="text-text-quaternary h-4 ml-1 mr-0.5"></BillableIcon>
<InputLabel for="billable" value="Billable Default" />

View File

@@ -21,6 +21,7 @@ async function submit() {
const newTag = props.createTag(tag.value.name);
if (newTag !== undefined) {
show.value = false;
tag.value.name = '';
}
}

View File

@@ -70,6 +70,19 @@ const timeEntryDefaultValues = {
const timeEntry = ref({ ...timeEntryDefaultValues });
watch(
() => timeEntry.value.project_id,
(value) => {
if (value) {
// check if project is billable by default and set billable accordingly
const project = props.projects.find((p) => p.id === value);
if (project) {
timeEntry.value.billable = project.is_billable;
}
}
}
);
const localStart = ref(
getLocalizedDayJs(timeEntryDefaultValues.start).format()
);

View File

@@ -1,7 +1,10 @@
<script setup lang="ts">
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { defineProps, ref } from 'vue';
import { formatDate, formatStartEnd } from '@/packages/ui/src/utils/time';
import {
formatDateLocalized,
formatStartEnd,
} from '@/packages/ui/src/utils/time';
import TimeRangeSelector from '@/packages/ui/src/Input/TimeRangeSelector.vue';
import { twMerge } from 'tailwind-merge';
@@ -39,7 +42,7 @@ const open = ref(false);
">
{{ formatStartEnd(start, end) }}
<span v-if="showDate" class="text-text-tertiary font-medium"
>{{ formatDate(start) }}
>{{ formatDateLocalized(start) }}
</span>
</button>
</template>

View File

@@ -180,6 +180,7 @@ watchEffect(() => {
tasks: [],
estimated_time: null,
spent_time: 0,
is_public: false,
},
],
});

View File

@@ -6,6 +6,7 @@ import isYesterday from 'dayjs/plugin/isYesterday';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import { getUserTimezone, getWeekStart } from './settings';
import updateLocale from 'dayjs/plugin/updateLocale';
import { computed } from 'vue';
@@ -69,7 +70,7 @@ export function formatTime(date: string) {
return dayjs.utc(date).tz(getUserTimezone()).format('HH:mm');
}
export function getLocalizedDayJs(timestamp: string | null) {
export function getLocalizedDayJs(timestamp?: string | null) {
return dayjs.utc(timestamp).tz(getUserTimezone());
}
@@ -82,7 +83,20 @@ export function getLocalizedDateFromTimestamp(timestamp: string) {
* @param date - date in the format of 'YYYY-MM-DD'
*/
export function formatDate(date: string): string {
return dayjs(date).format('DD.MM.YYYY');
if (date?.includes('+')) {
console.warn(
'Date contains timezone information, use formatDateLocalized instead'
);
}
return getDayJsInstance()(date).format('DD.MM.YYYY');
}
/*
* Returns a formatted date.
* @param date - date in the format of 'YYYY-MM-DD'
*/
export function formatDateLocalized(date: string): string {
return getLocalizedDayJs(date).format('DD.MM.YYYY');
}
export function formatWeek(date: string | null): string {

View File

@@ -100,3 +100,10 @@ export function canDeleteTags() {
export function canManageBilling() {
return currentUserHasPermission('billing');
}
export function canUpdateReport() {
return currentUserHasPermission('reports:update');
}
export function canDeleteReport() {
return currentUserHasPermission('reports:delete');
}

45
resources/pdf/echarts.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,127 +0,0 @@
@use('Brick\Math\BigDecimal')
@use('PhpOffice\PhpSpreadsheet\Cell\DataType')
@use('Carbon\CarbonInterval')
@inject('interval', 'App\Service\IntervalService')
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Report</title>
<style>
body {
font-family: "Open Sans", sans-serif;
}
table {
font-size: 10px;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
</head>
<body>
<h1>Report</h1>
<div>
<span>{{ $start->format('Y-m-d') }} - {{ $end->format('Y-m-d') }}</span><br><br>
</div>
<div>
<span>Duration: {{ $interval->format(CarbonInterval::seconds($aggregatedData['seconds'])) }}</span><br>
<span>Total cost: {{ round(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->toFloat(), 2) }}</span><br>
</div>
<div id="main-chart" style="width: 800px; height:400px;"></div>
@foreach($aggregatedData['grouped_data'] as $group1Entry)
<h2>{{ $group->description() }}: {{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }}</h2>
<table>
<thead>
<tr>
<th>
{{ $subGroup->description() }}
</th>
<th>
Duration
</th>
<th>
Duration (decimal)
</th>
<th>
Amount ({{ Str::upper($currency) }})
</th>
</tr>
</thead>
<tbody>
@php
$counter = 1;
$totalDuration = 0;
$totalCost = 0;
@endphp
@foreach($group1Entry['grouped_data'] as $group2Entry)
@php
$duration = CarbonInterval::seconds($group2Entry['seconds']);
@endphp
<tr>
<td>
{{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
</td>
<td>
{{ $interval->format($duration) }}
</td>
<td>
{{ round($duration->totalHours, 2) }}
</td>
<td>
{{ round(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->toFloat(), 2) }}
</td>
</tr>
@php
$totalDuration += $group2Entry['seconds'];
$totalCost += $group2Entry['cost'];
@endphp
@endforeach
</tbody>
</table>
@endforeach
<script>
// Initialize the echarts instance based on the prepared dom
let element = document.getElementById('main-chart');
let myChart = echarts.init(element, null, {
renderer: 'svg'
});
// Specify the configuration items and data for the chart
let option = {
tooltip: {},
xAxis: {
data: ['{!! collect($dataHistoryChart['grouped_data'])->pluck('key')->implode("', '") !!}'],
rotate: 0
},
yAxis: {
minInterval: 1,
axisLabel: {
formatter: function (value, index) {
let totalSeconds = value;
let hours = Math.floor(totalSeconds / 3600);
totalSeconds %= 3600;
let minutes = Math.floor(totalSeconds / 60);
let seconds = totalSeconds % 60;
return hours + ':' + minutes + ':' + seconds;
}
}
},
series: [
{
name: 'time',
type: 'bar',
data: [{!! collect($dataHistoryChart['grouped_data'])->pluck('seconds')->implode(', ') !!}],
}
]
};
// Display the chart using the configuration items and data just specified.
myChart.setOption(option);
</script>
</body>
</html>

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<style>
.page-number {
position: absolute;
bottom: 0;
left: 0;
font-size: 12px;
margin-left: 46px;
margin-bottom: 40px;
}
</style>
</head>
<body>
<div class="page-number">
Page <span class="pageNumber"></span> of <span class="totalPages"></span>
</div>
</body>
</html>

View File

@@ -0,0 +1,447 @@
@use('Brick\Math\BigDecimal')
@use('Brick\Money\Money')
@use('PhpOffice\PhpSpreadsheet\Cell\DataType')
@use('Carbon\CarbonInterval')
@inject('interval', 'App\Service\IntervalService')
@inject('colorService', 'App\Service\ColorService')
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Report</title>
<style>
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
vertical-align: baseline;
box-sizing: border-box;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
text-align: left;
}
@font-face {
font-family: 'Outfit';
src: url('outfit.ttf');
}
body {
font-family: 'Outfit', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif;
color: #18181b
}
table {
font-size: 14px;
}
thead {
border-bottom: 1px #d4d4d8 solid;
}
tfoot {
border-top: 1px #d4d4d8 solid;
}
table th, table tfoot td {
font-weight: 500;
padding: 6px 12px;
color: #18181b;
}
.table-wrapper table th {
background-color: #fafafa;
}
.table-wrapper {
border: 1px solid #d4d4d8;
border-radius: 8px;
overflow: hidden;
width: calc(100% - 2px)
}
table tr {
border-bottom: 1px #e4e4e7 solid;
}
table tr:last-of-type {
border-bottom: none;
}
table tr td {
font-weight: 400;
color: #3f3f46;
padding: 6px 12px;
}
.data-table {
break-after: auto;
}
.no-break {
break-after: avoid-page;
}
</style>
<script>
window.status = "processing";
</script>
<script
src="{{ $debug ? 'https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js' : 'echarts.min.js' }}"></script>
@if($debug)
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=outfit:200,300,400,500,600,700,800" rel="stylesheet" />
@endif
</head>
<body>
<div>
<p style="font-size: 32px; font-weight: 600; margin-bottom: 5px;">Report</p>
<div style="font-size: 16px; font-weight: 600; color: #71717a;">
<span>{{ $start->format('d.m.Y') }} - {{ $end->format('d.m.Y') }}</span><br><br>
</div>
</div>
<div class="table-wrapper">
<div
style="background-color: #fafafa; padding: 5px 14px; border-bottom: 1px #d4d4d8 solid; display: flex; gap: 20px;">
<div style="padding: 8px 12px; border-radius: 8px;">
<div style="color: #71717a; font-weight: 600;">Duration</div>
<div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $interval->format(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
</div>
<div style="padding: 8px 12px; border-radius: 8px;">
<div style="color: #71717a; font-weight: 600;">Total cost</div>
<div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)->formatTo('en_US') }} </div>
</div>
</div>
<div id="main-chart" style="width: 700px; height: 300px; margin: 20px auto;"></div>
</div>
<div style="display: flex; align-items: center; padding-top: 40px;">
<div style="padding: 10px 0;">
<div id="pie-chart" style="width: 300px; height: 180px; margin-bottom: 20px;"></div>
</div>
<div style="flex: 1 1 0%;">
<div class="">
<table style="width: 100%; ">
<thead>
<tr>
<th>
{{ $group->description() }}
</th>
<th>Duration</th>
<th style="text-align: right;">Cost</th>
</tr>
</thead>
@foreach($aggregatedData['grouped_data'] as $group1Entry)
<tr>
<td style="display: flex; align-items: center;">
<div style="width: 12px; height: 12px; border-radius: 50%; background-color: {{
$group1Entry['color'] ?? ($group1Entry['key'] ? $colorService->getRandomColor($group1Entry['key']) : '#CCCCCC')
}};">
</div>
<span style="padding-left: 8px;">
@if($group->is(\App\Enums\TimeEntryAggregationType::Billable))
{{ $group1Entry['key'] === '1' ? 'Billable' : 'Non-billable' }}
@else
{{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }}
@endif
</span>
</td>
<td style="text-align: left;">
{{ $interval->format(CarbonInterval::seconds($group1Entry['seconds'])) }}
</td>
<td style="text-align: right;">
{{ Money::of(BigDecimal::ofUnscaledValue($group1Entry['cost'], 2)->__toString(), $currency)->formatTo('en_US') }}
</td>
</tr>
@endforeach
<tfoot>
<tr>
<td style="font-weight: 500;color: #18181b;">
Total
</td>
<td style="font-weight: 500;color: #18181b;">
{{ $interval->format(CarbonInterval::seconds($aggregatedData['seconds'])) }}
</td>
<td style="text-align: right; font-weight: 500;color: #18181b;">
{{ Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)->formatTo('en_US') }}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
@foreach($aggregatedData['grouped_data'] as $group1Entry)
<div class="data-table">
<h2 class="no-break"
style="padding-top: 16px; padding-bottom: 8px; font-size: 16px; font-weight: 600; padding-left: 6px; color: #3f3f46;">
@if($group->is(\App\Enums\TimeEntryAggregationType::Billable))
{{ $group1Entry['key'] === '1' ? 'Billable' : 'Non-billable' }}
@else
<span style="color: #a1a1aa;">
{{ $group->description() }}:
</span>
{{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }}
@endif
</h2>
<div class="table-wrapper">
<table style="width: 100%;">
<thead>
<tr>
<th>
{{ $subGroup->description() }}
</th>
<th>
Duration
</th>
<th>
Duration (h)
</th>
<th>
Cost
</th>
</tr>
</thead>
<tbody>
@php
$counter = 1;
$totalDuration = 0;
$totalCost = 0;
@endphp
@foreach($group1Entry['grouped_data'] as $group2Entry)
@php
$duration = CarbonInterval::seconds($group2Entry['seconds']);
@endphp
<tr>
<td>
@if($subGroup->is(\App\Enums\TimeEntryAggregationType::Billable))
{{ $group2Entry['key'] === '1' ? 'Billable' : 'Non-billable' }}
@else
{{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
@endif
</td>
<td>
{{ $interval->format($duration) }}
</td>
<td>
{{ round($duration->totalHours, 2) }}
</td>
<td>
{{ Money::of(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->__toString(), $currency)->formatTo('en_US') }}
</td>
</tr>
@php
$totalDuration += $group2Entry['seconds'];
$totalCost += $group2Entry['cost'];
@endphp
@endforeach
</tbody>
</table>
</div>
</div>
@endforeach
<script>
let elementPieChart = document.getElementById("pie-chart");
let pieChart = echarts.init(elementPieChart, null, {
renderer: "svg"
});
let pieChartOptions = {
animation: false,
backgroundColor: "transparent",
series: [
{
data: {!! json_encode(collect($aggregatedData['grouped_data'])->map(function (array $data) use (&$colorService, $group): object {
$color = $data['color'];
if ($color === null) {
$color = $colorService->getRandomColor($data['key']);
}
if ($data['key'] === null) {
$color = '#CCCCCC';
}
return (object)[
'value' => $data['seconds'],
'name' => $data['description'] ?? $data['key'] ?? 'No '.Str::lower($group->description()),
'color' => $color,
'itemStyle' => (object) [
'color' => $color,
],
'emphasis' => (object) [
'itemStyle' => (object) [
'color' => $color,
],
],
];
})->toArray()) !!},
radius: ["40%", "80%"],
type: "pie",
label: {
formatter: "{d}%",
overflow: "truncate"
}
}
]
};
pieChart.on("finished", () => {
window.pieChartFinished = true;
if (window.mainChartFinished && window.pieChartFinished) {
window.status = "ready";
}
});
pieChart.setOption(pieChartOptions);
let elementMainChart = document.getElementById("main-chart");
let mainChart = echarts.init(elementMainChart, null, {
renderer: "svg"
});
let mainChartOptions = {
animation: false,
tooltip: {},
xAxis: {
data: ['{!! collect($dataHistoryChart['grouped_data'])->pluck('key')->implode("', '") !!}'],
axisLabel: {
fontSize: 10,
fontWeight: 400,
color: "rgb(120, 120, 120)",
margin: 16,
fontFamily: "Outfit, sans-serif"
},
axisTick: {
interval: 0,
alignWithLabel: true
}
},
grid: {
containLabel: true,
left: 15,
top: 15,
right: 15,
bottom: 0
},
yAxis: {
minInterval: 1,
axisLabel: {
show: false,
inside: true,
formatter: function(value, index) {
let totalSeconds = value;
let hours = Math.floor(totalSeconds / 3600);
if (hours < 10) {
hours = "0" + hours;
}
totalSeconds %= 3600;
let minutes = Math.floor(totalSeconds / 60);
if (minutes < 10) {
minutes = "0" + minutes;
}
return hours + ":" + minutes;
}
}
},
series: [
{
name: "time",
type: "bar",
data: [{!! collect($dataHistoryChart['grouped_data'])->pluck('seconds')->implode(', ') !!}],
itemStyle: {
borderColor: "#7dd3fc",
color: "#7dd3fc"
},
label: {
show: true,
@if(count($dataHistoryChart['grouped_data']) > 15)
rotate: 90,
offset: [10, 5],
@endif
fontSize: 10,
position: "top",
formatter: function(params) {
let value = params.value;
if (value === 0) {
return "";
}
let totalSeconds = value;
let hours = Math.floor(totalSeconds / 3600);
if (hours < 10) {
hours = "0" + hours;
}
totalSeconds %= 3600;
let minutes = Math.floor(totalSeconds / 60);
if (minutes < 10) {
minutes = "0" + minutes;
}
return hours + ":" + minutes;
}
}
}
]
};
mainChart.on("finished", () => {
window.mainChartFinished = true;
if (window.mainChartFinished && window.pieChartFinished) {
window.status = "ready";
}
});
mainChart.setOption(mainChartOptions);
</script>
</body>
</html>

View File

@@ -1,15 +0,0 @@
<html>
<head>
<style>
body {
font-size: 12px;
margin: auto 20px;
}
</style>
</head>
<body>
<p>
<span class="pageNumber"></span> of <span class="totalPages"></span>
</p>
</body>
</html>

View File

@@ -1,60 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Report</title>
<style>
body {
font-family: "Open Sans", sans-serif;
}
table {
font-size: 10px;
}
</style>
</head>
<body>
<h1>Detailed Report</h1>
<div>
<span>01.01.2020 - 01.01.2024</span>
</div>
<div>
<span>Duration: 20:10:10</span>
</div>
<div>
<table>
<thead>
<tr>
<th>Description</th>
<th>Task</th>
<th>Project</th>
<th>Client</th>
<th>User</th>
<th>Duration</th>
<th>Billable</th>
<th>Tags</th>
</tr>
</thead>
<tbody>
@foreach($timeEntries as $timeEntry)
<tr>
<td>{{ $timeEntry->description }}</td>
<td>{{ $timeEntry->task?->name ?? '-' }}</td>
<td>{{ $timeEntry->project?->name ?? '-' }}</td>
<td>{{ $timeEntry->client?->name ?? '-' }}</td>
<td>{{ $timeEntry->user->name }}</td>
<td>
00:00:01
{{ $timeEntry->start->format('Y-m-d H:i:s') }} - {{ $timeEntry->end->format('Y-m-d H:i:s') }}
</td>
<td>{{ $timeEntry->billable ? 'Yes' : 'no' }}</td>
<td>{{ $timeEntry->tagsRelation->implode('name', ', ') }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<style>
.page-number {
position: absolute;
bottom: 0;
left: 0;
font-size: 12px;
margin-left: 46px;
margin-bottom: 40px;
}
</style>
</head>
<body>
<div class="page-number">
Page <span class="pageNumber"></span> of <span class="totalPages"></span>
</div>
</body>
</html>

View File

@@ -0,0 +1,204 @@
@use('Brick\Math\BigDecimal')
@use('Brick\Money\Money')
@use('PhpOffice\PhpSpreadsheet\Cell\DataType')
@use('Carbon\CarbonInterval')
@inject('interval', 'App\Service\IntervalService')
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Report</title>
<style>
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
vertical-align: baseline;
box-sizing: border-box;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
@font-face {
font-family: 'Outfit';
src: url('outfit.ttf');
}
body {
font-family: 'Outfit', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif;
color: #18181b
}
table {
font-size: 10px;
}
table thead {
background-color: #eee;
}
.table-wrapper table th {
background-color: #fafafa;
}
.table-wrapper {
border: 1px solid #d4d4d8;
border-radius: 8px;
overflow: hidden;
width: calc(100% - 2px)
}
table {
border-collapse: collapse;
border-spacing: 0;
text-align: left;
}
thead {
border-bottom: 1px #d4d4d8 solid;
}
tfoot {
border-top: 1px #d4d4d8 solid;
}
table th, table tfoot td {
font-weight: 500;
padding: 6px 12px;
color: #18181b;
}
table td, table th {
font-size: 12px;
}
table tr {
border-bottom: 1px #e4e4e7 solid;
}
table tr:last-of-type {
border-bottom: none;
}
table tr td {
font-weight: 400;
color: #3f3f46;
padding: 6px 12px;
}
</style>
</head>
<body>
<div>
<p style="font-size: 32px; font-weight: 600; margin-bottom: 5px;">Detailed Report</p>
<div style="font-size: 16px; font-weight: 600; color: #71717a;">
<span>{{ $start->format('d.m.Y') }} - {{ $end->format('d.m.Y') }}</span><br><br>
</div>
</div>
<div class="table-wrapper">
<div
style="background-color: #fafafa; padding: 5px 14px; display: flex; gap: 20px;">
<div style="padding: 8px 12px; border-radius: 8px;">
<div style="color: #71717a; font-weight: 600;">Duration</div>
<div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $interval->format(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
</div>
<div style="padding: 8px 12px; border-radius: 8px;">
<div style="color: #71717a; font-weight: 600;">Total cost</div>
<div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)->formatTo('en_US') }} </div>
</div>
</div>
<div>
<table style="width: 100%;">
<thead>
<tr style="border-top: 1px #d4d4d8 solid;">
<th>Time Entry</th>
<th>User</th>
<th style="text-align: center;">Time</th>
<th>Duration</th>
<th>Billable</th>
<th>Tags</th>
</tr>
</thead>
<tbody>
@foreach($timeEntries as $timeEntry)
<tr>
<td style="overflow-wrap: break-word; max-width: 250px;">
{{ $timeEntry->description === '' ? '-' : $timeEntry->description }} <br>
@if($timeEntry->task?->name)
<span style="font-weight: 600;">Task:</span> {{ $timeEntry->task?->name ?? '-' }} <br>
@endif
@if($timeEntry->project?->name)
<span style="font-weight: 600;">Project:</span> {{ $timeEntry->project?->name }} <br>
@endif
@if($timeEntry->client?->name)
<span style="font-weight: 600;">
Client:
</span>{{ $timeEntry->client?->name }} <br>
@endif
</td>
<td style="overflow-wrap: break-word; min-width: 75px;">{{ $timeEntry->user->name }}</td>
<td style="overflow-wrap: break-word; min-width: 150px; text-align: center;">
@if($timeEntry->start->format('Y-m-d') === $timeEntry->end->format('Y-m-d'))
{{ $timeEntry->start->format('Y-m-d') }}
@else
{{ $timeEntry->start->format('Y-m-d') }} - <br> {{ $timeEntry->end->format('Y-m-d') }}
@endif
<br>
{{ $timeEntry->start->format('H:i:s') }} - {{ $timeEntry->end->format('H:i:s') }}
</td>
<td style="overflow-wrap: break-word; min-width: 75px;">
{{ $interval->format($timeEntry->getDuration()) }}
</td>
<td style="overflow-wrap: break-word;">{{ $timeEntry->billable ? 'Yes' : 'No' }}</td>
<td style="overflow-wrap: break-word; min-width: 75px;">{{ count($timeEntry->tagsRelation) === 0 ? '-' : $timeEntry->tagsRelation->implode('name', ', ') }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -10,6 +10,8 @@ use App\Http\Controllers\Api\V1\MemberController;
use App\Http\Controllers\Api\V1\OrganizationController;
use App\Http\Controllers\Api\V1\ProjectController;
use App\Http\Controllers\Api\V1\ProjectMemberController;
use App\Http\Controllers\Api\V1\Public\ReportController as PublicReportController;
use App\Http\Controllers\Api\V1\ReportController;
use App\Http\Controllers\Api\V1\TagController;
use App\Http\Controllers\Api\V1\TaskController;
use App\Http\Controllers\Api\V1\TimeEntryController;
@@ -30,110 +32,126 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
*/
Route::middleware([
'auth:api',
'verified',
])->prefix('v1')->name('v1.')->group(static function (): void {
// Organization routes
Route::name('organizations.')->group(static function (): void {
Route::get('/organizations/{organization}', [OrganizationController::class, 'show'])->name('show');
Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::prefix('v1')->name('v1.')->group(static function (): void {
Route::middleware([
'auth:api',
'verified',
])->group(static function (): void {
// Organization routes
Route::name('organizations.')->group(static function (): void {
Route::get('/organizations/{organization}', [OrganizationController::class, 'show'])->name('show');
Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update');
});
// Member routes
Route::name('members.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::get('/members', [MemberController::class, 'index'])->name('index');
Route::put('/members/{member}', [MemberController::class, 'update'])->name('update');
Route::delete('/members/{member}', [MemberController::class, 'destroy'])->name('destroy');
Route::post('/members/{member}/invite-placeholder', [MemberController::class, 'invitePlaceholder'])->name('invite-placeholder');
Route::post('/members/{member}/make-placeholder', [MemberController::class, 'makePlaceholder'])->name('make-placeholder');
});
// User routes
Route::name('users.')->group(static function (): void {
Route::get('/users/me', [UserController::class, 'me'])->name('me');
});
// User Member routes
Route::name('users.memberships.')->group(static function (): void {
Route::get('/users/me/memberships', [UserMembershipController::class, 'myMemberships'])->name('my-memberships');
});
// Invitation routes
Route::name('invitations.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::get('/invitations', [InvitationController::class, 'index'])->name('index');
Route::post('/invitations', [InvitationController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::post('/invitations/{invitation}/resend', [InvitationController::class, 'resend'])->name('resend')->middleware('check-organization-blocked');
Route::delete('/invitations/{invitation}', [InvitationController::class, 'destroy'])->name('destroy')->middleware('check-organization-blocked');
});
// Project routes
Route::name('projects.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::get('/projects', [ProjectController::class, 'index'])->name('index');
Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('show');
Route::post('/projects', [ProjectController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/projects/{project}', [ProjectController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::delete('/projects/{project}', [ProjectController::class, 'destroy'])->name('destroy');
});
// Project member routes
Route::name('project-members.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::get('/projects/{project}/project-members', [ProjectMemberController::class, 'index'])->name('index');
Route::post('/projects/{project}/project-members', [ProjectMemberController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/project-members/{projectMember}', [ProjectMemberController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::delete('/project-members/{projectMember}', [ProjectMemberController::class, 'destroy'])->name('destroy');
});
// Time entry routes
Route::name('time-entries.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::get('/time-entries', [TimeEntryController::class, 'index'])->name('index');
Route::get('/time-entries/export', [TimeEntryController::class, 'indexExport'])->name('index-export');
Route::get('/time-entries/aggregate', [TimeEntryController::class, 'aggregate'])->name('aggregate');
Route::get('/time-entries/aggregate/export', [TimeEntryController::class, 'aggregateExport'])->name('aggregate-export');
Route::post('/time-entries', [TimeEntryController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::patch('/time-entries', [TimeEntryController::class, 'updateMultiple'])->name('update-multiple')->middleware('check-organization-blocked');
Route::delete('/time-entries/{timeEntry}', [TimeEntryController::class, 'destroy'])->name('destroy');
Route::delete('/time-entries', [TimeEntryController::class, 'destroyMultiple'])->name('destroy-multiple');
});
Route::name('users.time-entries.')->group(static function (): void {
Route::get('/users/me/time-entries/active', [UserTimeEntryController::class, 'myActive'])->name('my-active');
});
// Report routes
Route::name('reports.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::get('/reports', [ReportController::class, 'index'])->name('index');
Route::get('/reports/{report}', [ReportController::class, 'show'])->name('show');
Route::post('/reports', [ReportController::class, 'store'])->name('store');
Route::put('/reports/{report}', [ReportController::class, 'update'])->name('update');
Route::delete('/reports/{report}', [ReportController::class, 'destroy'])->name('destroy');
});
// Tag routes
Route::name('tags.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::get('/tags', [TagController::class, 'index'])->name('index');
Route::post('/tags', [TagController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/tags/{tag}', [TagController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::delete('/tags/{tag}', [TagController::class, 'destroy'])->name('destroy');
});
// Client routes
Route::name('clients.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::get('/clients', [ClientController::class, 'index'])->name('index');
Route::post('/clients', [ClientController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/clients/{client}', [ClientController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::delete('/clients/{client}', [ClientController::class, 'destroy'])->name('destroy');
});
// Task routes
Route::name('tasks.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::get('/tasks', [TaskController::class, 'index'])->name('index');
Route::post('/tasks', [TaskController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/tasks/{task}', [TaskController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::delete('/tasks/{task}', [TaskController::class, 'destroy'])->name('destroy');
});
// Import routes
Route::name('import.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::get('/importers', [ImportController::class, 'index'])->name('index');
Route::post('/import', [ImportController::class, 'import'])->name('import')->middleware('check-organization-blocked');
});
// Export routes
Route::name('export.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::post('/export', [ExportController::class, 'export'])->name('export');
});
});
// Member routes
Route::name('members.')->group(static function (): void {
Route::get('/organizations/{organization}/members', [MemberController::class, 'index'])->name('index');
Route::put('/organizations/{organization}/members/{member}', [MemberController::class, 'update'])->name('update');
Route::delete('/organizations/{organization}/members/{member}', [MemberController::class, 'destroy'])->name('destroy');
Route::post('/organizations/{organization}/members/{member}/invite-placeholder', [MemberController::class, 'invitePlaceholder'])->name('invite-placeholder');
Route::post('/organizations/{organization}/members/{member}/make-placeholder', [MemberController::class, 'makePlaceholder'])->name('make-placeholder');
});
// User routes
Route::name('users.')->group(static function (): void {
Route::get('/users/me', [UserController::class, 'me'])->name('me');
});
// User Member routes
Route::name('users.memberships.')->group(static function (): void {
Route::get('/users/me/memberships', [UserMembershipController::class, 'myMemberships'])->name('my-memberships');
});
// Invitation routes
Route::name('invitations.')->group(static function (): void {
Route::get('/organizations/{organization}/invitations', [InvitationController::class, 'index'])->name('index');
Route::post('/organizations/{organization}/invitations', [InvitationController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::post('/organizations/{organization}/invitations/{invitation}/resend', [InvitationController::class, 'resend'])->name('resend')->middleware('check-organization-blocked');
Route::delete('/organizations/{organization}/invitations/{invitation}', [InvitationController::class, 'destroy'])->name('destroy')->middleware('check-organization-blocked');
});
// Project routes
Route::name('projects.')->group(static function (): void {
Route::get('/organizations/{organization}/projects', [ProjectController::class, 'index'])->name('index');
Route::get('/organizations/{organization}/projects/{project}', [ProjectController::class, 'show'])->name('show');
Route::post('/organizations/{organization}/projects', [ProjectController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/organizations/{organization}/projects/{project}', [ProjectController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::delete('/organizations/{organization}/projects/{project}', [ProjectController::class, 'destroy'])->name('destroy');
});
// Project member routes
Route::name('project-members.')->group(static function (): void {
Route::get('/organizations/{organization}/projects/{project}/project-members', [ProjectMemberController::class, 'index'])->name('index');
Route::post('/organizations/{organization}/projects/{project}/project-members', [ProjectMemberController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/organizations/{organization}/project-members/{projectMember}', [ProjectMemberController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::delete('/organizations/{organization}/project-members/{projectMember}', [ProjectMemberController::class, 'destroy'])->name('destroy');
});
// Time entry routes
Route::name('time-entries.')->group(static function (): void {
Route::get('/organizations/{organization}/time-entries', [TimeEntryController::class, 'index'])->name('index');
Route::get('/organizations/{organization}/time-entries/export', [TimeEntryController::class, 'indexExport'])->name('index-export');
Route::get('/organizations/{organization}/time-entries/aggregate', [TimeEntryController::class, 'aggregate'])->name('aggregate');
Route::get('/organizations/{organization}/time-entries/aggregate/export', [TimeEntryController::class, 'aggregateExport'])->name('aggregate-export');
Route::post('/organizations/{organization}/time-entries', [TimeEntryController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::patch('/organizations/{organization}/time-entries', [TimeEntryController::class, 'updateMultiple'])->name('update-multiple')->middleware('check-organization-blocked');
Route::delete('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'destroy'])->name('destroy');
Route::delete('/organizations/{organization}/time-entries', [TimeEntryController::class, 'destroyMultiple'])->name('destroy-multiple');
});
Route::name('users.time-entries.')->group(static function (): void {
Route::get('/users/me/time-entries/active', [UserTimeEntryController::class, 'myActive'])->name('my-active');
});
// Tag routes
Route::name('tags.')->group(static function (): void {
Route::get('/organizations/{organization}/tags', [TagController::class, 'index'])->name('index');
Route::post('/organizations/{organization}/tags', [TagController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/organizations/{organization}/tags/{tag}', [TagController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::delete('/organizations/{organization}/tags/{tag}', [TagController::class, 'destroy'])->name('destroy');
});
// Client routes
Route::name('clients.')->group(static function (): void {
Route::get('/organizations/{organization}/clients', [ClientController::class, 'index'])->name('index');
Route::post('/organizations/{organization}/clients', [ClientController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/organizations/{organization}/clients/{client}', [ClientController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::delete('/organizations/{organization}/clients/{client}', [ClientController::class, 'destroy'])->name('destroy');
});
// Task routes
Route::name('tasks.')->group(static function (): void {
Route::get('/organizations/{organization}/tasks', [TaskController::class, 'index'])->name('index');
Route::post('/organizations/{organization}/tasks', [TaskController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/organizations/{organization}/tasks/{task}', [TaskController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::delete('/organizations/{organization}/tasks/{task}', [TaskController::class, 'destroy'])->name('destroy');
});
// Import routes
Route::name('import.')->group(static function (): void {
Route::get('/organizations/{organization}/importers', [ImportController::class, 'index'])->name('index');
Route::post('/organizations/{organization}/import', [ImportController::class, 'import'])->name('import')->middleware('check-organization-blocked');
});
// Export routes
Route::name('export.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::post('/export', [ExportController::class, 'export'])->name('export');
// Public routes
Route::name('public.')->prefix('/public')->group(static function (): void {
Route::get('/reports', [PublicReportController::class, 'show'])->name('reports.show');
});
});

View File

@@ -2,12 +2,8 @@
declare(strict_types=1);
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Http\Controllers\Web\DashboardController;
use App\Http\Controllers\Web\HomeController;
use Gotenberg\Gotenberg;
use Gotenberg\Stream;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Laravel\Jetstream\Jetstream;
@@ -25,6 +21,10 @@ use Laravel\Jetstream\Jetstream;
Route::get('/', [HomeController::class, 'index']);
Route::get('/shared-report', function () {
return Inertia::render('SharedReport');
})->name('shared-report');
Route::middleware([
'auth:web',
config('jetstream.auth_session'),
@@ -44,6 +44,10 @@ Route::middleware([
return Inertia::render('ReportingDetailed');
})->name('reporting.detailed');
Route::get('/reporting/shared', function () {
return Inertia::render('ReportingShared');
})->name('reporting.shared');
Route::get('/projects', function () {
return Inertia::render('Projects');
})->name('projects');
@@ -70,22 +74,4 @@ Route::middleware([
return Inertia::render('Import');
})->name('import');
Route::get('/pdf-test', function () {
if (config('services.gotenberg.url') === null) {
throw new PdfRendererIsNotConfiguredException;
}
$viewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate-index.blade.php'));
$html = Blade::render($viewFile, ['aggregatedData' => []]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index-footer.blade.php'));
$footerHtml = Blade::render($footerViewFile);
$request = Gotenberg::chromium(config('services.gotenberg.url'))
->pdf()
->pdfa('PDF/A-3b')
->paperSize('8.27', '11.7') // A4
->footer(Stream::string('footer', $footerHtml))
->html(Stream::string('body', $html));
return Gotenberg::send($request);
});
});

View File

@@ -32,7 +32,7 @@ class DeleteOrganizationTest extends TestCase
);
// Act
$response = $this->withoutExceptionHandling()->delete('/teams/'.$organization->getKey());
$response = $this->delete('/teams/'.$organization->getKey());
// Assert
$this->assertNull($organization->fresh());

View File

@@ -11,6 +11,7 @@ use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Mockery\MockInterface;
@@ -25,6 +26,7 @@ abstract class TestCase extends BaseTestCase
parent::setUp();
Mail::fake();
LogFake::bind();
Http::preventStrayRequests();
$this->actAsOrganizationWithoutSubscriptionAndWithoutTrial();
// Note: The following line can be used to test timezone edge cases.
// $this->travelTo(Carbon::now()->timezone('Europe/Vienna')->setHour(0)->setMinute(59)->setSecond(0));

Some files were not shown because too many files have changed in this diff Show More