Compare commits

...

48 Commits

Author SHA1 Message Date
Gregor Vostrak
857dc7f4a2 start time entry on click in recently tracked time entries dropdown 2025-02-06 18:26:55 +01:00
Gregor Vostrak
b71ac06cb6 add recently tracked timeentries dropdown to timetracker 2025-02-04 15:12:59 +01:00
Gregor Vostrak
0e8f9c58fd update dependencies, update eslint config, update optional ts props types 2025-01-29 17:58:03 +01:00
Gregor Vostrak
71ec1e9e0f fix TimeTrackerRangeSelector detection so it does not open the Dropdown again after pressing Escape 2025-01-29 15:38:22 +01:00
Gregor Vostrak
a8318a458e fix time update test to respect new taborder logic 2025-01-29 14:41:20 +01:00
Gregor Vostrak
84622c3fcc fix enter submits in the time range dropdown 2025-01-29 13:55:07 +01:00
Gregor Vostrak
1a03637e31 fix focus state for dropdowns, fix taborder for timerange select in timetracker and timeentryrows 2025-01-29 13:16:54 +01:00
Gregor Vostrak
cd207c6173 improve focus state styling 2025-01-27 14:12:56 +01:00
Constantin Graf
49e045809b Enhanced description for Clockify imports 2024-12-20 19:57:50 -05:00
Constantin Graf
e90fa8307f Fixed timezones in unit tests 2024-12-20 19:57:50 -05:00
dependabot[bot]
895540d0a9 Bump codecov/codecov-action from 4.5.0 to 5.1.2
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.5.0 to 5.1.2.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4.5.0...v5.1.2)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

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

View File

@@ -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'],
}

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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),
/*
|--------------------------------------------------------------------------

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:

View File

@@ -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'),
]);
});

View File

@@ -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
View 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
);

View File

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

3424
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>{{

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>{{

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>{{

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -0,0 +1,130 @@
<script setup lang="ts">
import TextInput from '../../../packages/ui/src/Input/TextInput.vue';
import SecondaryButton from '../../../packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref } from 'vue';
import PrimaryButton from '../../../packages/ui/src/Buttons/PrimaryButton.vue';
import InputLabel from '../../../packages/ui/src/Input/InputLabel.vue';
import type {
CreateReportBody,
CreateReportBodyProperties,
} from '@/packages/api/src';
import { useMutation } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '@/packages/api/src';
import { Checkbox } from '@/packages/ui/src';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import { useNotificationsStore } from '@/utils/notification';
const show = defineModel('show', { default: false });
const saving = ref(false);
const createReportMutation = useMutation({
mutationFn: async (report: CreateReportBody) => {
const organizationId = getCurrentOrganizationId();
if (organizationId === null) {
throw new Error('No current organization id - create report');
}
return await api.createReport(report, {
params: {
organization: organizationId,
},
});
},
});
const props = defineProps<{
properties: CreateReportBodyProperties;
}>();
const report = ref({
name: '',
description: '',
is_public: true,
public_until: null,
});
const { handleApiRequestNotifications } = useNotificationsStore();
async function submit() {
await handleApiRequestNotifications(
() =>
createReportMutation.mutateAsync({
...report.value,
properties: { ...props.properties },
}),
'Success',
'Error',
() => {
report.value = {
name: '',
description: '',
is_public: false,
public_until: null,
};
show.value = false;
}
);
}
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Create Report </span>
</div>
</template>
<template #content>
<div class="items-center space-y-4 w-full">
<div class="w-full">
<InputLabel for="name" value="Name" />
<TextInput
id="name"
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>

View File

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

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';
import type { Report } from '@/packages/api/src';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
import { canDeleteReport, canUpdateReport } from '@/utils/permissions';
const emit = defineEmits<{
delete: [];
edit: [];
archive: [];
}>();
const props = defineProps<{
report: Report;
}>();
</script>
<template>
<MoreOptionsDropdown :label="'Actions for Project ' + props.report.name">
<div class="min-w-[150px]">
<button
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>

View File

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

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { FolderPlusIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { computed } from 'vue';
import { canCreateProjects } from '@/utils/permissions';
import type { Report } from '@/packages/api/src';
import ReportTableHeading from '@/Components/Common/Report/ReportTableHeading.vue';
import ReportTableRow from '@/Components/Common/Report/ReportTableRow.vue';
import { router } from '@inertiajs/vue3';
defineProps<{
reports: Report[];
}>();
const gridTemplate = computed(() => {
return `grid-template-columns: minmax(150px, auto) minmax(250px, 1fr) minmax(140px, auto) minmax(130px, auto) 80px;`;
});
</script>
<template>
<div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle">
<div
data-testid="report_table"
class="grid min-w-full"
:style="gridTemplate">
<ReportTableHeading></ReportTableHeading>
<div
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>

View File

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

View File

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

View File

@@ -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>

View File

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

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import {
ArrowDownTrayIcon,
CheckCircleIcon,
XMarkIcon,
} from '@heroicons/vue/20/solid';
import { Modal, PrimaryButton } from '@/packages/ui/src';
const props = defineProps<{
exportUrl: string | null;
}>();
const showExportModal = defineModel('show', { default: false });
function downloadCurrentExport() {
if (props.exportUrl) {
window.open(props.exportUrl, '_blank')?.focus();
}
}
</script>
<template>
<Modal
closeable
max-width="lg"
: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>

View File

@@ -21,6 +21,7 @@ const activeClass = computed(() => {
<template>
<Badge
size="large"
tag="button"
:class="
twMerge(
'cursor-pointer hover:bg-card-background transition flex',

View File

@@ -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">

View File

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

View File

@@ -4,30 +4,23 @@ import { formatCents } from '@/packages/ui/src/utils/money';
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
import { ref } from 'vue';
import { twMerge } from 'tailwind-merge';
import { useReportingStore } from '@/utils/useReporting';
import { getOrganizationCurrencyString } from '@/utils/money';
const { getNameForReportingRowEntry } = useReportingStore();
type AggregatedGroupedData = GroupedData & {
grouped_type?: string | null;
grouped_data?: GroupedData[] | null;
};
type GroupedData = {
key: string | null;
seconds: number;
cost: number;
description: string | null | undefined;
};
const props = defineProps<{
entry: AggregatedGroupedData;
indent?: boolean;
type: string | null;
}>();
function getNameForKey(key: string | null) {
return getNameForReportingRowEntry(key, props.type);
}
const expanded = ref(false);
</script>
@@ -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