mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
48 Commits
feature/up
...
feature/im
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
857dc7f4a2 | ||
|
|
b71ac06cb6 | ||
|
|
0e8f9c58fd | ||
|
|
71ec1e9e0f | ||
|
|
a8318a458e | ||
|
|
84622c3fcc | ||
|
|
1a03637e31 | ||
|
|
cd207c6173 | ||
|
|
49e045809b | ||
|
|
e90fa8307f | ||
|
|
895540d0a9 | ||
|
|
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 | ||
|
|
83e17d4a40 | ||
|
|
5b27853546 | ||
|
|
f49f7b2c9b | ||
|
|
9e77500d94 |
@@ -1,13 +0,0 @@
|
||||
/* eslint-env node */
|
||||
require("@rushstack/eslint-patch/modern-module-resolution")
|
||||
|
||||
module.exports = {
|
||||
extends: ['plugin:vue/vue3-essential', '@vue/eslint-config-typescript/recommended', '@vue/eslint-config-prettier'],
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": "error",
|
||||
},
|
||||
plugins: ['unused-imports'],
|
||||
}
|
||||
2
.github/workflows/phpunit.yml
vendored
2
.github/workflows/phpunit.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
||||
|
||||
- name: "Upload coverage reports to Codecov"
|
||||
uses: codecov/codecov-action@v4.5.0
|
||||
uses: codecov/codecov-action@v5.1.2
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: solidtime-io/solidtime
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -14,7 +14,7 @@ class ActiveUserOverview extends BaseWidget
|
||||
{
|
||||
protected static ?int $sort = 1;
|
||||
|
||||
protected static ?string $heading = 'A Registrations';
|
||||
protected ?string $heading = 'A Registrations';
|
||||
|
||||
protected function getCards(): array
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -45,18 +45,34 @@ class HealthCheckController extends Controller
|
||||
|
||||
$dbTimezone = DB::select('show timezone;');
|
||||
|
||||
$response = [
|
||||
'ip_address' => $ipAddress,
|
||||
'url' => $request->url(),
|
||||
'path' => $request->path(),
|
||||
'hostname' => $hostname,
|
||||
'timestamp' => Carbon::now()->timestamp,
|
||||
'date_time_utc' => Carbon::now('UTC')->toDateTimeString(),
|
||||
'date_time_app' => Carbon::now()->toDateTimeString(),
|
||||
'timezone' => $dbTimezone[0]->TimeZone,
|
||||
'secure' => $secure,
|
||||
'is_trusted_proxy' => $isTrustedProxy,
|
||||
];
|
||||
|
||||
if (app()->hasDebugModeEnabled()) {
|
||||
$response['app_debug'] = true;
|
||||
$response['app_url'] = config('app.url');
|
||||
$response['app_env'] = app()->environment();
|
||||
$response['app_timezone'] = config('app.timezone');
|
||||
$response['app_force_https'] = config('app.force_https');
|
||||
$response['trusted_proxies'] = config('trustedproxy.proxies');
|
||||
$headers = $request->headers->all();
|
||||
if (isset($headers['cookie'])) {
|
||||
$headers['cookie'] = '***';
|
||||
}
|
||||
$response['headers'] = $headers;
|
||||
}
|
||||
|
||||
return response()
|
||||
->json([
|
||||
'ip_address' => $ipAddress,
|
||||
'url' => $request->url(),
|
||||
'path' => $request->path(),
|
||||
'hostname' => $hostname,
|
||||
'timestamp' => Carbon::now()->timestamp,
|
||||
'date_time_utc' => Carbon::now('UTC')->toDateTimeString(),
|
||||
'date_time_app' => Carbon::now()->toDateTimeString(),
|
||||
'timezone' => $dbTimezone[0]->TimeZone,
|
||||
'secure' => $secure,
|
||||
'is_trusted_proxy' => $isTrustedProxy,
|
||||
]);
|
||||
->json($response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
"require": {
|
||||
"php": "8.3.*",
|
||||
"ext-zip": "*",
|
||||
"brick/money": "^0.9.0",
|
||||
"datomatic/laravel-enum-helper": "^1.1",
|
||||
"dedoc/scramble": "dev-main",
|
||||
"brick/money": "^0.10.0",
|
||||
"datomatic/laravel-enum-helper": "^2.0.0",
|
||||
"dedoc/scramble": "^0.11.28",
|
||||
"filament/filament": "^3.2",
|
||||
"flowframe/laravel-trend": "^0.2.0",
|
||||
"flowframe/laravel-trend": "^0.3.0",
|
||||
"gotenberg/gotenberg-php": "^2.8",
|
||||
"guzzlehttp/guzzle": "^7.2",
|
||||
"inertiajs/inertia-laravel": "^1.0",
|
||||
@@ -121,12 +121,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/korridor/scramble"
|
||||
}
|
||||
],
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
|
||||
1346
composer.lock
generated
1346
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -65,7 +65,7 @@ return [
|
||||
|
||||
'asset_url' => env('ASSET_URL'),
|
||||
|
||||
'force_https' => env('APP_FORCE_HTTPS', false),
|
||||
'force_https' => (bool) env('APP_FORCE_HTTPS', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
- sail
|
||||
- reverse-proxy
|
||||
playwright:
|
||||
image: mcr.microsoft.com/playwright:v1.46.1-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.50.0-jammy
|
||||
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
||||
working_dir: /src
|
||||
extra_hosts:
|
||||
|
||||
@@ -191,7 +191,7 @@ test('test that updating a the start of an existing time entry in the overview w
|
||||
'time_entry_range_selector'
|
||||
);
|
||||
await timeEntryRangeElement.click();
|
||||
await page.getByTestId('time_picker_input').first().fill('1');
|
||||
await page.getByTestId('time_entry_range_start').first().fill('1');
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
@@ -204,10 +204,7 @@ test('test that updating a the start of an existing time entry in the overview w
|
||||
(await response.json()).data.end !== null
|
||||
);
|
||||
}),
|
||||
page
|
||||
.getByTestId('time_entry_range_end')
|
||||
.getByTestId('time_picker_input')
|
||||
.press('Enter'),
|
||||
page.getByTestId('time_entry_range_end').press('Enter'),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,38 @@ test('test that starting and stopping a timer with a description works', async (
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
test('test that starting the time entry starts the live timer and that it keeps running after reload', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToDashboard(page);
|
||||
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
await page.waitForTimeout(500);
|
||||
const beforeTimerValue = await page
|
||||
.getByTestId('time_entry_time')
|
||||
.inputValue();
|
||||
await page.waitForTimeout(2000);
|
||||
const afterWaitTimeValue = await page
|
||||
.getByTestId('time_entry_time')
|
||||
.inputValue();
|
||||
expect(afterWaitTimeValue).not.toEqual(beforeTimerValue);
|
||||
await page.reload();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const afterReloadTimerValue = await page
|
||||
.getByTestId('time_entry_time')
|
||||
.inputValue();
|
||||
await page.waitForTimeout(2000);
|
||||
const afterReloadAfterWaitTimerValue = await page
|
||||
.getByTestId('time_entry_time')
|
||||
.inputValue();
|
||||
expect(afterReloadTimerValue).not.toEqual(afterReloadAfterWaitTimerValue);
|
||||
});
|
||||
|
||||
test('test that starting and updating the description while running works', async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -120,7 +152,7 @@ test('test that starting and updating the time while running works', async ({
|
||||
JSON.stringify([])
|
||||
);
|
||||
}),
|
||||
page.getByTestId('time_entry_time').press('Tab'),
|
||||
page.getByTestId('time_entry_time').press('Enter'),
|
||||
]);
|
||||
|
||||
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/);
|
||||
|
||||
36
eslint.config.mjs
Normal file
36
eslint.config.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
import eslint from '@eslint/js';
|
||||
import eslintConfigPrettier from 'eslint-config-prettier';
|
||||
import eslintPluginVue from 'eslint-plugin-vue';
|
||||
import globals from 'globals';
|
||||
import typescriptEslint from 'typescript-eslint';
|
||||
import unusedImports from "eslint-plugin-unused-imports";
|
||||
|
||||
export default typescriptEslint.config(
|
||||
{ ignores: ['*.d.ts', '**/coverage', '**/dist'] },
|
||||
{
|
||||
extends: [
|
||||
eslint.configs.recommended,
|
||||
...typescriptEslint.configs.recommended,
|
||||
...eslintPluginVue.configs['flat/recommended'],
|
||||
],
|
||||
files: ['**/*.{ts,vue,js}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
parser: typescriptEslint.parser,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"unused-imports": unusedImports,
|
||||
},
|
||||
rules: {
|
||||
"vue/multi-word-component-names": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": "error",
|
||||
},
|
||||
},
|
||||
eslintConfigPrettier
|
||||
);
|
||||
@@ -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',
|
||||
|
||||
3424
package-lock.json
generated
3424
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
41
package.json
41
package.json
@@ -4,53 +4,60 @@
|
||||
"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 resources/js",
|
||||
"lint:fix": "eslint --fix 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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@inertiajs/vue3": "^1.0.0",
|
||||
"@playwright/test": "^1.41.1",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/node": "^20.11.5",
|
||||
"@vitejs/plugin-vue": "^4.5.0",
|
||||
"@types/node": "^22.10.10",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios": "^1.6.4",
|
||||
"eslint-plugin-unused-imports": "^3.1.0",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"laravel-vite-plugin": "^1.0.0",
|
||||
"openapi-zod-client": "^1.16.2",
|
||||
"postcss": "^8.4.47",
|
||||
"postcss-nesting": "^12.1.5",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-checker": "^0.7.2",
|
||||
"vue": "^3.4.0",
|
||||
"vue-tsc": "^2.0.28"
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.11",
|
||||
"vite-plugin-checker": "^0.8.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-tsc": "^2.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.6.0",
|
||||
"@floating-ui/vue": "^1.0.6",
|
||||
"@heroicons/vue": "^2.1.1",
|
||||
"@rushstack/eslint-patch": "^1.7.0",
|
||||
"@rushstack/eslint-patch": "^1.10.5",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tanstack/vue-query": "^5.56.2",
|
||||
"@tanstack/vue-query-devtools": "^5.58.0",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"@vueuse/core": "^10.11.0",
|
||||
"@vueuse/integrations": "^11.1.0",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.3.0",
|
||||
"@vueuse/core": "^12.5.0",
|
||||
"@vueuse/integrations": "^12.5.0",
|
||||
"dayjs": "^1.11.11",
|
||||
"echarts": "^5.5.0",
|
||||
"focus-trap": "^7.6.0",
|
||||
"parse-duration": "^1.1.0",
|
||||
"parse-duration": "^2.0.1",
|
||||
"pinia": "^2.1.7",
|
||||
"radix-vue": "^1.9.6",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"vue-echarts": "^6.7.2"
|
||||
"vue-echarts": "^7.0.3"
|
||||
},
|
||||
"overrides": {
|
||||
"vite-plugin-checker": {
|
||||
"vue-tsc": "$vue-tsc"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 class="p-1" @click="hideBlackFridayBanner = true">
|
||||
<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">
|
||||
@@ -63,7 +142,7 @@ const showFreeUpgradeBanner = computed(
|
||||
<span>Upgrade now</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button @click="hideTrialBanner = true" class="p-1">
|
||||
<button class="p-1" @click="hideTrialBanner = true">
|
||||
<XMarkIcon
|
||||
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||
</button>
|
||||
@@ -95,7 +174,7 @@ const showFreeUpgradeBanner = computed(
|
||||
<span>Upgrade now</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button @click="hideBlockedBanner = true" class="p-1">
|
||||
<button class="p-1" @click="hideBlockedBanner = true">
|
||||
<XMarkIcon
|
||||
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||
</button>
|
||||
@@ -127,7 +206,7 @@ const showFreeUpgradeBanner = computed(
|
||||
<span>Upgrade now</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button @click="hideFreeUpgradeBanner = true" class="p-1">
|
||||
<button class="p-1" @click="hideFreeUpgradeBanner = true">
|
||||
<XMarkIcon
|
||||
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||
</button>
|
||||
|
||||
@@ -45,10 +45,10 @@ useFocus(clientNameInput, { initialValue: true });
|
||||
v-model="client.name"
|
||||
type="text"
|
||||
placeholder="Client Name"
|
||||
@keydown.enter="submit"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="clientName" />
|
||||
autocomplete="clientName"
|
||||
@keydown.enter="submit" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -46,10 +46,10 @@ useFocus(clientNameInput, { initialValue: true });
|
||||
v-model="clientBody.name"
|
||||
type="text"
|
||||
placeholder="Client Name"
|
||||
@keydown.enter="submit"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="clientName" />
|
||||
autocomplete="clientName"
|
||||
@keydown.enter="submit" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -23,28 +23,28 @@ const props = defineProps<{
|
||||
<div class="min-w-[150px]">
|
||||
<button
|
||||
v-if="canUpdateClients()"
|
||||
@click="emit('edit')"
|
||||
:aria-label="'Edit Client ' + props.client.name"
|
||||
data-testid="client_edit"
|
||||
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">
|
||||
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"
|
||||
@click="emit('edit')">
|
||||
<PencilSquareIcon
|
||||
class="w-5 text-icon-active"></PencilSquareIcon>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button
|
||||
@click.prevent="emit('archive')"
|
||||
v-if="canUpdateClients()"
|
||||
:aria-label="'Archive Client ' + props.client.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">
|
||||
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"
|
||||
@click.prevent="emit('archive')">
|
||||
<ArchiveBoxIcon class="w-5 text-icon-active"></ArchiveBoxIcon>
|
||||
<span>{{ client.is_archived ? 'Unarchive' : 'Archive' }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canDeleteClients()"
|
||||
@click="emit('delete')"
|
||||
:aria-label="'Delete Client ' + props.client.name"
|
||||
data-testid="client_delete"
|
||||
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">
|
||||
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"
|
||||
@click="emit('delete')">
|
||||
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
|
||||
@@ -18,7 +18,7 @@ function getNameForItem(item: Client) {
|
||||
|
||||
<template>
|
||||
<MultiselectDropdown
|
||||
searchPlaceholder="Search for a Client..."
|
||||
search-placeholder="Search for a Client..."
|
||||
:items="clients"
|
||||
:get-key-from-item="getKeyFromItem"
|
||||
:get-name-for-item="getNameForItem">
|
||||
|
||||
@@ -25,18 +25,18 @@ const createClient = ref(false);
|
||||
style="grid-template-columns: 1fr 150px 200px 80px">
|
||||
<ClientTableHeading></ClientTableHeading>
|
||||
<div
|
||||
class="col-span-2 py-24 text-center"
|
||||
v-if="clients.length === 0">
|
||||
v-if="clients.length === 0"
|
||||
class="col-span-2 py-24 text-center">
|
||||
<UserCircleIcon
|
||||
class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
|
||||
<h3 class="text-white font-semibold">No clients found</h3>
|
||||
<p class="pb-5" v-if="canCreateClients()">
|
||||
<p v-if="canCreateClients()" class="pb-5">
|
||||
Create your first client now!
|
||||
</p>
|
||||
<SecondaryButton
|
||||
v-if="canCreateClients()"
|
||||
@click="createClient = true"
|
||||
:icon="PlusIcon as Component"
|
||||
@click="createClient = true"
|
||||
>Create your First Client
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
|
||||
@@ -38,8 +38,8 @@ const showEditModal = ref(false);
|
||||
<template>
|
||||
<TableRow>
|
||||
<ClientEditModal
|
||||
:client="client"
|
||||
v-model:show="showEditModal"></ClientEditModal>
|
||||
v-model:show="showEditModal"
|
||||
:client="client"></ClientEditModal>
|
||||
<div
|
||||
class="whitespace-nowrap 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>
|
||||
|
||||
@@ -10,16 +10,16 @@ const emit = defineEmits<{
|
||||
<template>
|
||||
<MoreOptionsDropdown label="Actions for the invitation">
|
||||
<button
|
||||
@click="emit('resend')"
|
||||
data-testid="invitation_delete"
|
||||
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">
|
||||
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"
|
||||
@click="emit('resend')">
|
||||
<ArrowPathIcon class="w-5 text-icon-active"></ArrowPathIcon>
|
||||
<span>Resend Invitation</span>
|
||||
</button>
|
||||
<button
|
||||
@click="emit('delete')"
|
||||
data-testid="invitation_delete"
|
||||
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">
|
||||
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"
|
||||
@click="emit('delete')">
|
||||
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
|
||||
@@ -18,10 +18,10 @@ defineEmits<{
|
||||
|
||||
<template>
|
||||
<BillableRateModal
|
||||
@submit="$emit('submit')"
|
||||
v-model:show="show"
|
||||
v-model:saving="saving"
|
||||
title="Update Member Billable Rate">
|
||||
title="Update Member Billable Rate"
|
||||
@submit="$emit('submit')">
|
||||
<p class="py-1 text-center">
|
||||
The billable rate of {{ memberName }} will be updated to
|
||||
<strong>{{
|
||||
|
||||
@@ -17,8 +17,8 @@ const model = defineModel<string>({
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
hiddenMembers: ProjectMember[];
|
||||
disabled: boolean;
|
||||
hiddenMembers?: ProjectMember[];
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
hiddenMembers: () => [] as ProjectMember[],
|
||||
@@ -76,7 +76,7 @@ const currentValue = computed(() => {
|
||||
:items="filteredMembers"
|
||||
:get-key-from-item="(member) => member.id"
|
||||
:get-name-for-item="(member) => member.name">
|
||||
<template v-slot:trigger>
|
||||
<template #trigger>
|
||||
<Badge
|
||||
tag="button"
|
||||
class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5">
|
||||
@@ -84,7 +84,7 @@ const currentValue = computed(() => {
|
||||
<div v-if="currentValue" class="flex-1 truncate">
|
||||
{{ currentValue }}
|
||||
</div>
|
||||
<div class="flex-1" v-else>Select a member...</div>
|
||||
<div v-else class="flex-1">Select a member...</div>
|
||||
<ChevronDownIcon class="w-4 text-muted"></ChevronDownIcon>
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
@@ -108,11 +108,11 @@ const roleDescription = computed(() => {
|
||||
v-model:saving="saving"
|
||||
v-model:show="showBillableRateModal"
|
||||
:member-name="member.name"
|
||||
:newBillableRate="memberBody.billable_rate"
|
||||
:new-billable-rate="memberBody.billable_rate"
|
||||
@submit="submitBillableRate"></MemberBillableRateModal>
|
||||
<MemberOwnershipTransferConfirmModal
|
||||
:member-name="member.name"
|
||||
v-model:show="showOwnershipTransferConfirmModal"
|
||||
:member-name="member.name"
|
||||
@submit="submit"></MemberOwnershipTransferConfirmModal>
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
@@ -127,9 +127,9 @@ const roleDescription = computed(() => {
|
||||
<div>
|
||||
<InputLabel for="role" value="Role" />
|
||||
<MemberRoleSelect
|
||||
v-model="memberBody.role"
|
||||
class="mt-2"
|
||||
name="role"
|
||||
v-model="memberBody.role"></MemberRoleSelect>
|
||||
name="role"></MemberRoleSelect>
|
||||
</div>
|
||||
<div class="flex-1 text-xs flex items-center pt-6">
|
||||
<p>{{ roleDescription }}</p>
|
||||
@@ -140,28 +140,28 @@ const roleDescription = computed(() => {
|
||||
<div>
|
||||
<InputLabel for="billableType" value="Billable" />
|
||||
<MemberBillableSelect
|
||||
class="mt-2"
|
||||
name="billableType"
|
||||
v-model="
|
||||
billableRateSelect
|
||||
"></MemberBillableSelect>
|
||||
"
|
||||
class="mt-2"
|
||||
name="billableType"></MemberBillableSelect>
|
||||
</div>
|
||||
<div
|
||||
class="flex-1"
|
||||
v-if="billableRateSelect === 'custom-rate'">
|
||||
v-if="billableRateSelect === 'custom-rate'"
|
||||
class="flex-1">
|
||||
<InputLabel
|
||||
for="memberBillableRate"
|
||||
class="mb-2"
|
||||
value="Billable Rate" />
|
||||
<BillableRateInput
|
||||
v-model="
|
||||
memberBody.billable_rate
|
||||
"
|
||||
focus
|
||||
class="w-full"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
@keydown.enter="saveWithChecks()"
|
||||
name="memberBillableRate"
|
||||
v-model="
|
||||
memberBody.billable_rate
|
||||
"></BillableRateInput>
|
||||
@keydown.enter="saveWithChecks()"></BillableRateInput>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -112,11 +112,11 @@ useFocus(clientNameInput, { initialValue: true });
|
||||
v-if="isBillingActivated() && canManageBilling()"
|
||||
href="/billing">
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
class="mt-6"
|
||||
v-if="
|
||||
isBillingActivated() && canUpdateOrganization()
|
||||
">
|
||||
"
|
||||
type="button"
|
||||
class="mt-6">
|
||||
<CreditCardIcon class="w-5 h-5 me-2" />
|
||||
Go to Billing
|
||||
</PrimaryButton>
|
||||
@@ -128,15 +128,15 @@ useFocus(clientNameInput, { initialValue: true });
|
||||
<InputLabel for="email" value="Email" />
|
||||
<TextInput
|
||||
id="email"
|
||||
name="email"
|
||||
ref="memberEmailInput"
|
||||
v-model="addTeamMemberForm.email"
|
||||
name="email"
|
||||
type="text"
|
||||
placeholder="Member Email"
|
||||
@keydown.enter="submit"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="memberName" />
|
||||
autocomplete="memberName"
|
||||
@keydown.enter="submit" />
|
||||
<InputError :message="errors.email" class="mt-2" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,19 +20,19 @@ const props = defineProps<{
|
||||
<div class="min-w-[150px]">
|
||||
<button
|
||||
v-if="canUpdateMembers()"
|
||||
@click="emit('edit')"
|
||||
:aria-label="'Edit Member ' + props.member.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">
|
||||
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"
|
||||
@click="emit('edit')">
|
||||
<PencilSquareIcon
|
||||
class="w-5 text-icon-active"></PencilSquareIcon>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canDeleteMembers()"
|
||||
@click="emit('delete')"
|
||||
:aria-label="'Delete Member ' + props.member.name"
|
||||
data-testid="member_delete"
|
||||
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">
|
||||
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"
|
||||
@click="emit('delete')">
|
||||
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
|
||||
@@ -18,7 +18,7 @@ function getNameForItem(item: Member) {
|
||||
|
||||
<template>
|
||||
<MultiselectDropdown
|
||||
searchPlaceholder="Search for a Member..."
|
||||
search-placeholder="Search for a Member..."
|
||||
:items="members"
|
||||
:get-key-from-item="getKeyFromItem"
|
||||
:get-name-for-item="getNameForItem">
|
||||
|
||||
@@ -36,9 +36,9 @@ const emit = defineEmits<{
|
||||
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
class="ms-3"
|
||||
@click="emit('submit')"
|
||||
:class="{ 'opacity-25': saving }"
|
||||
:disabled="saving">
|
||||
:disabled="saving"
|
||||
@click="emit('submit')">
|
||||
Confirm Transfer
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
|
||||
@@ -89,8 +89,8 @@ async function invitePlaceholder(id: string) {
|
||||
member.is_placeholder === true &&
|
||||
canInvitePlaceholderMembers()
|
||||
"
|
||||
@click="invitePlaceholder(member.id)"
|
||||
size="small"
|
||||
@click="invitePlaceholder(member.id)"
|
||||
>Invite</SecondaryButton
|
||||
>
|
||||
<MemberMoreOptionsDropdown
|
||||
@@ -99,8 +99,8 @@ async function invitePlaceholder(id: string) {
|
||||
@delete="removeMember"></MemberMoreOptionsDropdown>
|
||||
</div>
|
||||
<MemberEditModal
|
||||
:member="member"
|
||||
v-model:show="showEditMemberModal"></MemberEditModal>
|
||||
v-model:show="showEditMemberModal"
|
||||
:member="member"></MemberEditModal>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
<div class="ml-4 flex flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
@click="show = false"
|
||||
class="inline-flex rounded-md bg-card-background text-muted hover:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
|
||||
class="inline-flex rounded-md bg-card-background text-muted hover:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
@click="show = false">
|
||||
<span class="sr-only">Close</span>
|
||||
<XMarkIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
@@ -17,10 +17,10 @@ defineEmits<{
|
||||
|
||||
<template>
|
||||
<BillableRateModal
|
||||
@submit="$emit('submit')"
|
||||
v-model:show="show"
|
||||
v-model:saving="saving"
|
||||
title="Update Organization Billable Rate">
|
||||
title="Update Organization Billable Rate"
|
||||
@submit="$emit('submit')">
|
||||
<p class="py-0.5 text-center">
|
||||
The organization billable rate will be updated to
|
||||
<strong>{{
|
||||
|
||||
@@ -40,7 +40,7 @@ const shownProjects = computed(() => {
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
border: boolean;
|
||||
border?: boolean;
|
||||
}>(),
|
||||
{
|
||||
border: true,
|
||||
@@ -123,17 +123,17 @@ function updateValue(project: Project) {
|
||||
<template #content>
|
||||
<ComboboxRoot
|
||||
:open="open"
|
||||
:modelValue="currentProject"
|
||||
@update:modelValue="updateValue"
|
||||
@update:searchTerm="(e) => console.log(e)"
|
||||
:searchTerm="searchValue"
|
||||
class="relative">
|
||||
:model-value="currentProject"
|
||||
:search-term="searchValue"
|
||||
class="relative"
|
||||
@update:model-value="updateValue"
|
||||
@update:search-term="(e) => console.log(e)">
|
||||
<ComboboxAnchor>
|
||||
<ComboboxInput
|
||||
@keydown.enter="addProjectIfNoneExists"
|
||||
ref="searchInput"
|
||||
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||
placeholder="Search for a project..." />
|
||||
placeholder="Search for a project..."
|
||||
@keydown.enter="addProjectIfNoneExists" />
|
||||
</ComboboxAnchor>
|
||||
<ComboboxContent>
|
||||
<ComboboxViewport
|
||||
|
||||
@@ -90,8 +90,8 @@ async function submitBillableRate() {
|
||||
<div class="text-center">
|
||||
<InputLabel for="color" value="Color" />
|
||||
<ProjectColorSelector
|
||||
class="mt-1"
|
||||
v-model="project.color"></ProjectColorSelector>
|
||||
v-model="project.color"
|
||||
class="mt-1"></ProjectColorSelector>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
@@ -102,18 +102,18 @@ async function submitBillableRate() {
|
||||
v-model="project.name"
|
||||
type="text"
|
||||
placeholder="Project Name"
|
||||
@keydown.enter="submit()"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="projectName" />
|
||||
autocomplete="projectName"
|
||||
@keydown.enter="submit()" />
|
||||
</div>
|
||||
<div class="">
|
||||
<InputLabel for="client" value="Client" />
|
||||
<ClientDropdown
|
||||
:createClient
|
||||
v-model="project.client_id"
|
||||
:create-client
|
||||
:clients="clients"
|
||||
class="mt-1"
|
||||
v-model="project.client_id">
|
||||
class="mt-1">
|
||||
<template #trigger>
|
||||
<Badge
|
||||
class="bg-input-background cursor-pointer hover:bg-tertiary"
|
||||
@@ -133,18 +133,18 @@ async function submitBillableRate() {
|
||||
<div class="lg:grid grid-cols-2 gap-12">
|
||||
<div>
|
||||
<ProjectEditBillableSection
|
||||
@submit="submit"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
v-model:isBillable="project.is_billable"
|
||||
v-model:billableRate="
|
||||
v-model:is-billable="project.is_billable"
|
||||
v-model:billable-rate="
|
||||
project.billable_rate
|
||||
"></ProjectEditBillableSection>
|
||||
"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
@submit="submit"></ProjectEditBillableSection>
|
||||
</div>
|
||||
<div>
|
||||
<EstimatedTimeSection
|
||||
v-if="isAllowedToPerformPremiumAction()"
|
||||
@submit="submit()"
|
||||
v-model="project.estimated_time"></EstimatedTimeSection>
|
||||
v-model="project.estimated_time"
|
||||
@submit="submit()"></EstimatedTimeSection>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -163,9 +163,9 @@ async function submitBillableRate() {
|
||||
<ProjectBillableRateModal
|
||||
v-model:show="showBillableRateModal"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
@submit="submitBillableRate"
|
||||
:new-billable-rate="project.billable_rate"
|
||||
:project-name="project.name"></ProjectBillableRateModal>
|
||||
:project-name="project.name"
|
||||
@submit="submitBillableRate"></ProjectBillableRateModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -21,29 +21,29 @@ const props = defineProps<{
|
||||
<MoreOptionsDropdown :label="'Actions for Project ' + props.project.name">
|
||||
<div class="min-w-[150px]">
|
||||
<button
|
||||
@click.prevent="emit('edit')"
|
||||
v-if="canUpdateProjects()"
|
||||
:aria-label="'Edit Project ' + props.project.name"
|
||||
data-testid="project_edit"
|
||||
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">
|
||||
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"
|
||||
@click.prevent="emit('edit')">
|
||||
<PencilSquareIcon
|
||||
class="w-5 text-icon-active"></PencilSquareIcon>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button
|
||||
@click.prevent="emit('archive')"
|
||||
v-if="canUpdateProjects()"
|
||||
:aria-label="'Archive Project ' + props.project.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">
|
||||
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"
|
||||
@click.prevent="emit('archive')">
|
||||
<ArchiveBoxIcon class="w-5 text-icon-active"></ArchiveBoxIcon>
|
||||
<span>{{ project.is_archived ? 'Unarchive' : 'Archive' }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click.prevent="emit('delete')"
|
||||
v-if="canDeleteProjects()"
|
||||
:aria-label="'Delete Project ' + props.project.name"
|
||||
data-testid="project_delete"
|
||||
v-if="canDeleteProjects()"
|
||||
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">
|
||||
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"
|
||||
@click.prevent="emit('delete')">
|
||||
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
|
||||
@@ -18,7 +18,7 @@ function getNameForItem(item: Project) {
|
||||
|
||||
<template>
|
||||
<MultiselectDropdown
|
||||
searchPlaceholder="Search for a Project..."
|
||||
search-placeholder="Search for a Project..."
|
||||
:items="projects"
|
||||
:get-key-from-item="getKeyFromItem"
|
||||
:get-name-for-item="getNameForItem">
|
||||
|
||||
@@ -44,12 +44,12 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
|
||||
<template>
|
||||
<ProjectCreateModal
|
||||
:createProject
|
||||
:createClient
|
||||
v-model:show="showCreateProjectModal"
|
||||
:create-project
|
||||
:create-client
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:clients="clients"
|
||||
:enableEstimatedTime="isAllowedToPerformPremiumAction"
|
||||
v-model:show="showCreateProjectModal"></ProjectCreateModal>
|
||||
:enable-estimated-time="isAllowedToPerformPremiumAction"></ProjectCreateModal>
|
||||
<div class="flow-root max-w-[100vw] overflow-x-auto">
|
||||
<div class="inline-block min-w-full align-middle">
|
||||
<div
|
||||
@@ -57,12 +57,12 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
class="grid min-w-full"
|
||||
:style="gridTemplate">
|
||||
<ProjectTableHeading
|
||||
:showBillableRate="
|
||||
:show-billable-rate="
|
||||
props.showBillableRate
|
||||
"></ProjectTableHeading>
|
||||
<div
|
||||
class="col-span-5 py-24 text-center"
|
||||
v-if="projects.length === 0">
|
||||
v-if="projects.length === 0"
|
||||
class="col-span-5 py-24 text-center">
|
||||
<FolderPlusIcon
|
||||
class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
||||
<h3 class="text-white font-semibold">
|
||||
@@ -81,14 +81,14 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
</p>
|
||||
<SecondaryButton
|
||||
v-if="canCreateProjects()"
|
||||
@click="showCreateProjectModal = true"
|
||||
:icon="PlusIcon"
|
||||
@click="showCreateProjectModal = true"
|
||||
>Create your First Project
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
<template v-for="project in projects" :key="project.id">
|
||||
<ProjectTableRow
|
||||
:showBillableRate="props.showBillableRate"
|
||||
:show-billable-rate="props.showBillableRate"
|
||||
:project="project"></ProjectTableRow>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -19,8 +19,8 @@ defineProps<{
|
||||
Progress
|
||||
</div>
|
||||
<div
|
||||
class="px-3 py-1.5 text-left font-semibold text-white"
|
||||
v-if="showBillableRate">
|
||||
v-if="showBillableRate"
|
||||
class="px-3 py-1.5 text-left font-semibold text-white">
|
||||
Billable Rate
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-white">Status</div>
|
||||
|
||||
@@ -83,8 +83,8 @@ const showEditProjectModal = ref(false);
|
||||
</div>
|
||||
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-muted">
|
||||
<div
|
||||
class="overflow-ellipsis overflow-hidden"
|
||||
v-if="project.client_id">
|
||||
v-if="project.client_id"
|
||||
class="overflow-ellipsis overflow-hidden">
|
||||
{{ client?.name }}
|
||||
</div>
|
||||
<div v-else>No client</div>
|
||||
@@ -106,8 +106,8 @@ const showEditProjectModal = ref(false);
|
||||
<span v-else> -- </span>
|
||||
</div>
|
||||
<div
|
||||
class="whitespace-nowrap px-3 py-4 text-sm text-muted"
|
||||
v-if="showBillableRate">
|
||||
v-if="showBillableRate"
|
||||
class="whitespace-nowrap px-3 py-4 text-sm text-muted">
|
||||
{{ billableRateInfo }}
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -18,10 +18,10 @@ defineEmits<{
|
||||
|
||||
<template>
|
||||
<BillableRateModal
|
||||
@submit="$emit('submit')"
|
||||
v-model:show="show"
|
||||
v-model:saving="saving"
|
||||
title="Update Project Member Billable Rate">
|
||||
title="Update Project Member Billable Rate"
|
||||
@submit="$emit('submit')">
|
||||
<p class="py-1 text-center">
|
||||
The billable rate of {{ memberName }} will be updated to
|
||||
<strong>{{
|
||||
|
||||
@@ -52,16 +52,16 @@ useFocus(projectNameInput, { initialValue: true });
|
||||
<div class="grid grid-cols-3 items-center space-x-4">
|
||||
<div class="col-span-3 sm:col-span-2">
|
||||
<MemberCombobox
|
||||
:hidden-members="props.existingMembers"
|
||||
v-model="projectMember.member_id"></MemberCombobox>
|
||||
v-model="projectMember.member_id"
|
||||
:hidden-members="props.existingMembers"></MemberCombobox>
|
||||
</div>
|
||||
<div class="col-span-3 sm:col-span-1 flex-1">
|
||||
<BillableRateInput
|
||||
name="billable_rate"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
v-model="
|
||||
projectMember.billable_rate
|
||||
"></BillableRateInput>
|
||||
"
|
||||
name="billable_rate"
|
||||
:currency="getOrganizationCurrencyString()"></BillableRateInput>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -75,8 +75,8 @@ useFocus(projectNameInput, { initialValue: true });
|
||||
|
||||
<template #content>
|
||||
<ProjectMemberBillableRateModal
|
||||
:member-name="props.name"
|
||||
v-model:show="showBillableRateModal"
|
||||
:member-name="props.name"
|
||||
:new-billable-rate="projectMemberBody.billable_rate"
|
||||
@close="showBillableRateModal = false"
|
||||
@submit="submitBillableRate"></ProjectMemberBillableRateModal>
|
||||
@@ -92,12 +92,12 @@ useFocus(projectNameInput, { initialValue: true });
|
||||
class="mb-2"
|
||||
value="Billable Rate"></InputLabel>
|
||||
<BillableRateInput
|
||||
@keydown.enter="submit"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
name="billable_rate"
|
||||
v-model="
|
||||
projectMemberBody.billable_rate
|
||||
"></BillableRateInput>
|
||||
"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
name="billable_rate"
|
||||
@keydown.enter="submit"></BillableRateInput>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -27,17 +27,17 @@ const currentMember = computed(() => {
|
||||
<MoreOptionsDropdown
|
||||
:label="'Actions for Project Member ' + currentMember?.name">
|
||||
<button
|
||||
@click.prevent="emit('edit')"
|
||||
:aria-label="'Edit Project Member ' + currentMember?.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">
|
||||
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"
|
||||
@click.prevent="emit('edit')">
|
||||
<PencilSquareIcon class="w-5 text-icon-active"></PencilSquareIcon>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button
|
||||
@click.prevent="emit('delete')"
|
||||
:aria-label="'Delete Project Member ' + currentMember?.name"
|
||||
data-testid="project_delete"
|
||||
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">
|
||||
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"
|
||||
@click.prevent="emit('delete')">
|
||||
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
|
||||
<span>Remove from Team</span>
|
||||
</button>
|
||||
|
||||
@@ -18,9 +18,9 @@ const createProjectMember = ref(false);
|
||||
|
||||
<template>
|
||||
<ProjectMemberCreateModal
|
||||
v-model:show="createProjectMember"
|
||||
:existing-members="projectMembers"
|
||||
:project-id="projectId"
|
||||
v-model:show="createProjectMember"></ProjectMemberCreateModal>
|
||||
:project-id="projectId"></ProjectMemberCreateModal>
|
||||
<div class="flow-root">
|
||||
<div class="inline-block min-w-full align-middle">
|
||||
<div
|
||||
@@ -29,15 +29,15 @@ const createProjectMember = ref(false);
|
||||
style="grid-template-columns: 1fr 150px 150px 80px">
|
||||
<ProjectMemberTableHeading></ProjectMemberTableHeading>
|
||||
<div
|
||||
class="col-span-5 py-24 text-center"
|
||||
v-if="projectMembers.length === 0">
|
||||
v-if="projectMembers.length === 0"
|
||||
class="col-span-5 py-24 text-center">
|
||||
<UserGroupIcon
|
||||
class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
|
||||
<h3 class="text-white font-semibold">No project members</h3>
|
||||
<p class="pb-5">Add the first project member!</p>
|
||||
<SecondaryButton
|
||||
@click="createProjectMember = true"
|
||||
:icon="PlusIcon"
|
||||
@click="createProjectMember = true"
|
||||
>Add a new Project Member
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
|
||||
@@ -37,8 +37,8 @@ const showEditModal = ref(false);
|
||||
<template>
|
||||
<TableRow>
|
||||
<ProjectMemberEditModal
|
||||
:name="member?.name"
|
||||
v-model:show="showEditModal"
|
||||
:name="member?.name"
|
||||
:project-member="projectMember"></ProjectMemberEditModal>
|
||||
<div
|
||||
class="whitespace-nowrap 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">
|
||||
|
||||
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"
|
||||
v-model="report.name"
|
||||
class="mt-1.5 w-full"></TextInput>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="description" value="Description" />
|
||||
<TextInput
|
||||
id="description"
|
||||
v-model="report.description"
|
||||
class="mt-1.5 w-full"></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
|
||||
id="is_public"
|
||||
v-model:checked="report.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"
|
||||
v-model="report.name"
|
||||
class="mt-1.5 w-full"></TextInput>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="description" value="Description" />
|
||||
<TextInput
|
||||
id="description"
|
||||
v-model="report.description"
|
||||
class="mt-1.5 w-full"></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
|
||||
id="is_public"
|
||||
v-model:checked="report.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
|
||||
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"
|
||||
@click.prevent="emit('edit')">
|
||||
<PencilSquareIcon
|
||||
class="w-5 text-icon-active"></PencilSquareIcon>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canDeleteReport()"
|
||||
:aria-label="'Delete Report ' + props.report.name"
|
||||
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"
|
||||
@click.prevent="emit('delete')">
|
||||
<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
|
||||
v-model:show="showCreateReportModal"
|
||||
:properties="reportProperties"></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
|
||||
v-if="reports.length === 0"
|
||||
class="col-span-5 py-24 text-center">
|
||||
<FolderPlusIcon
|
||||
class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
||||
<h3 class="text-white font-semibold">
|
||||
No shared reports found
|
||||
</h3>
|
||||
<p v-if="canCreateProjects()" class="pb-5">
|
||||
Create your first project now!
|
||||
</p>
|
||||
<SecondaryButton
|
||||
:icon="PlusIcon"
|
||||
@click="router.visit(route('reporting'))"
|
||||
>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-ring"
|
||||
@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>
|
||||
@@ -157,7 +157,7 @@ const option = ref({
|
||||
:autoresize="true"
|
||||
class="chart"
|
||||
:option="option" />
|
||||
<div class="chart flex flex-col items-center justify-center" v-else>
|
||||
<div v-else class="chart flex flex-col items-center justify-center">
|
||||
<p class="text-lg text-white font-semibold">
|
||||
No time entries found
|
||||
</p>
|
||||
|
||||
@@ -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"
|
||||
:show="showExportModal"
|
||||
@close="showExportModal = false">
|
||||
<button
|
||||
class="text-text-tertiary w-6 mx-auto absolute focus-visible:outline-none focus-visible:ring-2 rounded-full focus-visible:ring-ring 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>
|
||||
@@ -21,6 +21,7 @@ const activeClass = computed(() => {
|
||||
<template>
|
||||
<Badge
|
||||
size="large"
|
||||
tag="button"
|
||||
:class="
|
||||
twMerge(
|
||||
'cursor-pointer hover:bg-card-background transition flex',
|
||||
|
||||
@@ -23,7 +23,7 @@ const title = computed(() => {
|
||||
:get-key-from-item="(item) => item.value"
|
||||
:get-name-for-item="(item) => item.label"
|
||||
:items="groupByOptions">
|
||||
<template v-slot:trigger>
|
||||
<template #trigger>
|
||||
<Badge
|
||||
size="large"
|
||||
class="cursor-pointer hover:bg-card-background transition space-x-5 flex">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -42,13 +35,13 @@ const expanded = ref(false);
|
||||
)
|
||||
">
|
||||
<GroupedItemsCountButton
|
||||
v-if="entry.grouped_data && entry.grouped_data?.length > 0"
|
||||
:expanded="expanded"
|
||||
@click="expanded = !expanded"
|
||||
v-if="entry.grouped_data && entry.grouped_data?.length > 0">
|
||||
@click="expanded = !expanded">
|
||||
{{ entry.grouped_data?.length }}
|
||||
</GroupedItemsCountButton>
|
||||
<span>
|
||||
{{ getNameForKey(entry.key) }}
|
||||
{{ entry.description }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="justify-end flex items-center">
|
||||
@@ -59,14 +52,13 @@ const expanded = ref(false);
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="expanded && entry.grouped_data"
|
||||
class="col-span-3 grid bg-quaternary"
|
||||
style="grid-template-columns: 1fr 150px 150px"
|
||||
v-if="expanded && entry.grouped_data">
|
||||
style="grid-template-columns: 1fr 150px 150px">
|
||||
<ReportingRow
|
||||
indent
|
||||
v-for="subEntry in entry.grouped_data"
|
||||
:type="entry?.grouped_type ?? null"
|
||||
:key="subEntry.key ?? 'none'"
|
||||
:key="subEntry.description ?? 'none'"
|
||||
indent
|
||||
:entry="subEntry"></ReportingRow>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user