mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
35 Commits
v0.4.0
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccc07c4235 | ||
|
|
453dbaac9e | ||
|
|
62270382dc | ||
|
|
29929467f6 | ||
|
|
02fe89dfdf | ||
|
|
03550a0ca6 | ||
|
|
2f1056dddb | ||
|
|
6e226cd743 | ||
|
|
19ed966504 | ||
|
|
33818f10b3 | ||
|
|
ee9d818d75 | ||
|
|
e3d8457523 | ||
|
|
67e42a0a54 | ||
|
|
fdbf88a9a6 | ||
|
|
c4daca32c5 | ||
|
|
4e10f9538f | ||
|
|
959cad8f74 | ||
|
|
e308ca78b1 | ||
|
|
4281736a6d | ||
|
|
9b0cf37bc7 | ||
|
|
a4f3e014d9 | ||
|
|
32bce2f749 | ||
|
|
ae7f5a98e7 | ||
|
|
e3f981aac2 | ||
|
|
bcb298bd6d | ||
|
|
620c4c97dc | ||
|
|
05da595470 | ||
|
|
a4d8a02b80 | ||
|
|
0860aa9d24 | ||
|
|
9c82efdf07 | ||
|
|
2560619c15 | ||
|
|
c03aad1abd | ||
|
|
0ee0175f04 | ||
|
|
0c1f06face | ||
|
|
86d625b18a |
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
90
app/Http/Controllers/Api/V1/Public/ReportController.php
Normal file
90
app/Http/Controllers/Api/V1/Public/ReportController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
172
app/Http/Controllers/Api/V1/ReportController.php
Normal file
172
app/Http/Controllers/Api/V1/ReportController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
208
app/Http/Requests/V1/Report/ReportStoreRequest.php
Normal file
208
app/Http/Requests/V1/Report/ReportStoreRequest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
65
app/Http/Requests/V1/Report/ReportUpdateRequest.php
Normal file
65
app/Http/Requests/V1/Report/ReportUpdateRequest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
68
app/Http/Resources/V1/Report/DetailedReportResource.php
Normal file
68
app/Http/Resources/V1/Report/DetailedReportResource.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
136
app/Http/Resources/V1/Report/DetailedWithDataReportResource.php
Normal file
136
app/Http/Resources/V1/Report/DetailedWithDataReportResource.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
18
app/Http/Resources/V1/Report/ReportCollection.php
Normal file
18
app/Http/Resources/V1/Report/ReportCollection.php
Normal 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;
|
||||
}
|
||||
42
app/Http/Resources/V1/Report/ReportResource.php
Normal file
42
app/Http/Resources/V1/Report/ReportResource.php
Normal 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
64
app/Models/Report.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
|
||||
217
app/Service/Dto/ReportPropertiesDto.php
Normal file
217
app/Service/Dto/ReportPropertiesDto.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
15
app/Service/ReportService.php
Normal file
15
app/Service/ReportService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
171
composer.lock
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
76
database/factories/ReportFactory.php
Normal file
76
database/factories/ReportFactory.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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">
|
||||
|
||||
130
resources/js/Components/Common/Report/ReportCreateModal.vue
Normal file
130
resources/js/Components/Common/Report/ReportCreateModal.vue
Normal 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>
|
||||
139
resources/js/Components/Common/Report/ReportEditModal.vue
Normal file
139
resources/js/Components/Common/Report/ReportEditModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
41
resources/js/Components/Common/Report/ReportSaveButton.vue
Normal file
41
resources/js/Components/Common/Report/ReportSaveButton.vue
Normal 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>
|
||||
52
resources/js/Components/Common/Report/ReportTable.vue
Normal file
52
resources/js/Components/Common/Report/ReportTable.vue
Normal 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>
|
||||
26
resources/js/Components/Common/Report/ReportTableHeading.vue
Normal file
26
resources/js/Components/Common/Report/ReportTableHeading.vue
Normal 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>
|
||||
108
resources/js/Components/Common/Report/ReportTableRow.vue
Normal file
108
resources/js/Components/Common/Report/ReportTableRow.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
...{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
36
resources/js/Components/NavigationSidebarLink.vue
Normal file
36
resources/js/Components/NavigationSidebarLink.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
186
resources/js/Pages/ReportingShared.vue
Normal file
186
resources/js/Pages/ReportingShared.vue
Normal 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">…</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>
|
||||
236
resources/js/Pages/SharedReport.vue
Normal file
236
resources/js/Pages/SharedReport.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 `null` 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 `null` 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 `null` 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 `null` 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 `null` 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 `null` 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 `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`.`,
|
||||
requestFormat: 'json',
|
||||
response: DetailedWithDataReportResource,
|
||||
errors: [
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/users/me',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -21,6 +21,7 @@ async function submit() {
|
||||
const newTag = props.createTag(tag.value.name);
|
||||
if (newTag !== undefined) {
|
||||
show.value = false;
|
||||
tag.value.name = '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -180,6 +180,7 @@ watchEffect(() => {
|
||||
tasks: [],
|
||||
estimated_time: null,
|
||||
spent_time: 0,
|
||||
is_public: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
45
resources/pdf/echarts.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
@@ -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>
|
||||
447
resources/views/reports/time-entry-aggregate/pdf.blade.php
Normal file
447
resources/views/reports/time-entry-aggregate/pdf.blade.php
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
204
resources/views/reports/time-entry-index/pdf.blade.php
Normal file
204
resources/views/reports/time-entry-index/pdf.blade.php
Normal 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>
|
||||
222
routes/api.php
222
routes/api.php
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user