Compare commits

..

32 Commits

Author SHA1 Message Date
Constantin Graf
b8110e222a Fixed descriptions and billable in shared reports 2025-04-30 13:36:21 +02:00
Gregor Vostrak
7673b365ca fix light/dark theme not currectly initializing on shared report, unify logic 2025-04-30 13:32:25 +02:00
Gregor Vostrak
da5fc3f113 only show invoicing tab when module is activated 2025-04-30 12:06:48 +02:00
Gregor Vostrak
8c66068663 update openapi api client 2025-04-29 16:38:34 +02:00
Constantin Graf
dd0cc0d60b Add more validation for clockify importer 2025-04-29 16:38:08 +02:00
Gregor Vostrak
3a482c1e6a fix reporting not updating and client ui cue #458 2025-04-28 13:34:08 +02:00
Constantin Graf
ef9f353047 Fixed data type of project and task spend time 2025-04-25 22:32:37 +02:00
Constantin Graf
f1a1d2a266 Project name is now unique per client and organization 2025-04-25 17:55:29 +02:00
Constantin Graf
f5efbad703 Api docs for date time format 2025-04-25 17:55:29 +02:00
Constantin Graf
17242188c2 Updated composer dependencies 2025-04-25 17:55:29 +02:00
dependabot[bot]
0a376b1caa Bump codecov/codecov-action from 5.4.0 to 5.4.2
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.4.0 to 5.4.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/v5.4.0...v5.4.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-25 11:54:43 +02:00
Gregor Vostrak
10a8310e37 fix stage name in private build action 2025-04-23 15:54:33 +02:00
Gregor Vostrak
89131b9e77 prevent billable rate change modals from immediately sumbitting when pressing enter on the previous form 2025-04-23 14:33:32 +02:00
Gregor Vostrak
c17c5dc6c0 fix escape handling in tagdropdown and timetrackerprojecttaskdropdown after changing to radix dropdowns 2025-04-23 14:33:32 +02:00
Constantin Graf
3444281703 Add composer dependency “league/iso3166” 2025-04-23 14:33:32 +02:00
Gregor Vostrak
84e2365a6d add invoicing extension to private build action 2025-04-23 14:33:32 +02:00
Gregor Vostrak
92ac9948a0 add accordion component and countries api route 2025-04-23 14:33:32 +02:00
Gregor Vostrak
8da358dbe6 fix timeentry checkboxes 2025-04-23 14:33:32 +02:00
Gregor Vostrak
b7b9092e64 update api client, and report empty state improvement 2025-04-23 14:33:32 +02:00
Gregor Vostrak
15ac3e9a43 fix tests, add autofocus disable option for dropdown 2025-04-23 14:33:32 +02:00
Constantin Graf
d03dd60864 Add composer package korridor/laravel-has-many-sync 2025-04-23 14:33:32 +02:00
Constantin Graf
827e0fe377 Fixes for invoice feature 2025-04-23 14:33:32 +02:00
Gregor Vostrak
e78a551098 refactor to shadcn components, dynamically load extension frontend
add jetstream permissions, add dynamic inertia module loading, add shadcn components, change modals and dropdowns to shadcn dismissable layer,
2025-04-23 14:33:32 +02:00
Constantin Graf
ae00fdb0e9 Add localization settings 2025-04-23 14:33:32 +02:00
Constantin Graf
3c9160a08a Removed external font 2025-04-02 13:00:53 +02:00
Constantin Graf
4fb744db1d Fixed timezone issue in PDF reports 2025-04-02 13:00:53 +02:00
Gregor Vostrak
bc9b104c3f fix dropdown highlight color in dark mode 2025-04-01 19:31:01 +02:00
Gregor Vostrak
880c363ae4 fix light mode text color in some 2fa and auth views 2025-04-01 14:47:36 +02:00
Gregor Vostrak
8e6d1abbf3 raise sidebar title contrast, fix profile text colors in light mode 2025-03-30 17:11:30 +02:00
Gregor Vostrak
d202bd9c47 fix light mode icon colors on primary buttons 2025-03-30 16:48:00 +02:00
Gregor Vostrak
992d8945df fix vertical alignment of dropdown triggers (time entry row more) 2025-03-30 16:25:02 +02:00
Gregor Vostrak
df2fe1da1e add light mode 2025-03-28 14:54:31 +01:00
301 changed files with 9065 additions and 2302 deletions

View File

@@ -107,6 +107,24 @@ jobs:
- name: "Install npm dependencies in services extension" - name: "Install npm dependencies in services extension"
run: cd extensions/Services && npm ci run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v4
with:
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
- name: "Install composer dependencies in invoicing extension"
uses: php-actions/composer@v6
with:
working_dir: "extensions/Invoicing"
command: install
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
php_version: 8.3
- name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci
- name: "Setup PHP with PECL extension" - name: "Setup PHP with PECL extension"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
@@ -127,6 +145,9 @@ jobs:
- name: "Activate services extension" - name: "Activate services extension"
run: php artisan module:enable Services run: php artisan module:enable Services
- name: "Activate invoicing extension"
run: php artisan module:enable Invoicing
- name: "Install npm dependencies" - name: "Install npm dependencies"
run: npm ci run: npm ci

View File

@@ -63,7 +63,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov" - name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v5.4.0 uses: codecov/codecov-action@v5.4.2
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime slug: solidtime-io/solidtime

View File

@@ -76,6 +76,11 @@ class CreateNewUser implements CreatesNewUsers
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip()); $ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());
$startOfWeek = Weekday::Monday; $startOfWeek = Weekday::Monday;
$numberFormat = null;
$currencyFormat = null;
$dateFormat = null;
$intervalFormat = null;
$timeFormat = null;
$currency = null; $currency = null;
if ($ipLookupResponse !== null) { if ($ipLookupResponse !== null) {
$startOfWeek = $ipLookupResponse->startOfWeek ?? Weekday::Monday; $startOfWeek = $ipLookupResponse->startOfWeek ?? Weekday::Monday;
@@ -85,7 +90,7 @@ class CreateNewUser implements CreatesNewUsers
$currency = $ipLookupResponse->currency; $currency = $ipLookupResponse->currency;
} }
$user = null; $user = null;
DB::transaction(function () use (&$user, $input, $timezone, $startOfWeek, $currency): void { DB::transaction(function () use (&$user, $input, $timezone, $startOfWeek, $currency, $numberFormat, $currencyFormat, $dateFormat, $intervalFormat, $timeFormat): void {
$userService = app(UserService::class); $userService = app(UserService::class);
$user = $userService->createUser( $user = $userService->createUser(
$input['name'], $input['name'],
@@ -93,7 +98,12 @@ class CreateNewUser implements CreatesNewUsers
$input['password'], $input['password'],
$timezone ?? 'UTC', $timezone ?? 'UTC',
$startOfWeek, $startOfWeek,
$currency ?? 'EUR', $currency,
$numberFormat,
$currencyFormat,
$dateFormat,
$intervalFormat,
$timeFormat
); );
}); });

View File

@@ -4,10 +4,11 @@ declare(strict_types=1);
namespace App\Actions\Jetstream; namespace App\Actions\Jetstream;
use App\Enums\Role;
use App\Events\AfterCreateOrganization; use App\Events\AfterCreateOrganization;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\OrganizationService;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@@ -33,16 +34,18 @@ class CreateOrganization implements CreatesTeams
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
])->validateWithBag('createTeam'); ])->validateWithBag('createTeam');
$organization = new Organization; $ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());
$organization->name = $input['name'];
$organization->personal_team = false;
$organization->owner()->associate($user);
$organization->save();
$organization->users()->attach( $currency = null;
$user, [ if ($ipLookupResponse !== null) {
'role' => Role::Owner->value, $currency = $ipLookupResponse->currency;
] }
$organization = app(OrganizationService::class)->createOrganization(
$input['name'],
$user,
false,
$currency
); );
$user->switchTeam($organization); $user->switchTeam($organization);

View File

@@ -64,8 +64,8 @@ class UserCreateCommand extends Command
$password, $password,
'UTC', 'UTC',
Weekday::Monday, Weekday::Monday,
'EUR', null,
$verifyEmail verifyEmail: $verifyEmail
); );
}); });
/** @var Organization|null $organization */ /** @var Organization|null $organization */

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum CurrencyFormat: string
{
use LaravelEnumHelper;
case ISOCodeBeforeWithSpace = 'iso-code-before-with-space';
case ISOCodeAfterWithSpace = 'iso-code-after-with-space';
case SymbolBefore = 'symbol-before';
case SymbolAfter = 'symbol-after';
case SymbolBeforeWithSpace = 'symbol-before-with-space';
case SymbolAfterWithSpace = 'symbol-after-with-space';
/**
* @return array<string, string>
*/
public static function toSelectArray(): array
{
$selectArray = [];
foreach (self::values() as $value) {
$selectArray[(string) $value] = (string) __('enum.currency_format.'.$value);
}
return $selectArray;
}
}

48
app/Enums/DateFormat.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum DateFormat: string
{
use LaravelEnumHelper;
case PointSeperatedDMYYYY = 'point-seperated-d-m-yyyy';
case SlashSeperatedMMDDYYYY = 'slash-seperated-mm-dd-yyyy';
case SlashSeperatedDDMMYYYY = 'slash-seperated-dd-mm-yyyy';
case HyphenSeperatedDDMMYYY = 'hyphen-seperated-dd-mm-yyyy';
case HyphenSeperatedMMDDDYYYY = 'hyphen-seperated-mm-dd-yyyy';
case HyphenSeperatedYYYYMMDD = 'hyphen-seperated-yyyy-mm-dd';
public function toCarbonFormat(): string
{
return match ($this->value) {
self::PointSeperatedDMYYYY->value => 'j.n.Y',
self::SlashSeperatedMMDDYYYY->value => 'm/d/Y',
self::SlashSeperatedDDMMYYYY->value => 'd/m/Y',
self::HyphenSeperatedDDMMYYY->value => 'd-m-Y',
self::HyphenSeperatedMMDDDYYYY->value => 'm-d-Y',
self::HyphenSeperatedYYYYMMDD->value => 'Y-m-d',
};
}
/**
* @return array<string, string>
*/
public static function toSelectArray(): array
{
$selectArray = [];
foreach (self::values() as $value) {
$selectArray[(string) $value] = (string) __('enum.date_format.'.$value);
}
return $selectArray;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum IntervalFormat: string
{
use LaravelEnumHelper;
case Decimal = 'decimal';
case HoursMinutes = 'hours-minutes';
case HoursMinutesColonSeperated = 'hours-minutes-colon-seperated';
case HoursMinutesSecondsColonSeperated = 'hours-minutes-seconds-colon-seperated';
/**
* @return array<string, string>
*/
public static function toSelectArray(): array
{
$selectArray = [];
foreach (self::values() as $value) {
$selectArray[(string) $value] = (string) __('enum.interval_format.'.$value);
}
return $selectArray;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
/**
* @info https://en.wikipedia.org/wiki/Decimal_separator
*/
enum NumberFormat: string
{
use LaravelEnumHelper;
case ThousandsPointDecimalComma = 'point-comma';
case ThousandsCommaDecimalPoint = 'comma-point';
case ThousandsSpaceDecimalComma = 'space-comma';
case ThousandsSpaceDecimalPoint = 'space-point';
case ThousandsApostropheDecimalPoint = 'apostrophe-point';
/**
* @return array<string, string>
*/
public static function toSelectArray(): array
{
$selectArray = [];
foreach (self::values() as $value) {
$selectArray[(string) $value] = (string) __('enum.number_format.'.$value);
}
return $selectArray;
}
}

28
app/Enums/TimeFormat.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TimeFormat: string
{
use LaravelEnumHelper;
case TwelveHours = '12-hours';
case TwentyFourHours = '24-hours';
/**
* @return array<string, string>
*/
public static function toSelectArray(): array
{
$selectArray = [];
foreach (self::values() as $value) {
$selectArray[(string) $value] = (string) __('enum.time_format.'.$value);
}
return $selectArray;
}
}

View File

@@ -13,7 +13,7 @@ use Filament\Tables;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Novadaemon\FilamentPrettyJson\PrettyJson; use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
class AuditResource extends Resource class AuditResource extends Resource
{ {
@@ -38,8 +38,8 @@ class AuditResource extends Resource
->maxLength(255), ->maxLength(255),
Forms\Components\TextInput::make('auditable_id') Forms\Components\TextInput::make('auditable_id')
->required(), ->required(),
PrettyJson::make('old_values'), PrettyJsonField::make('old_values'),
PrettyJson::make('new_values'), PrettyJsonField::make('new_values'),
Forms\Components\Textarea::make('url'), Forms\Components\Textarea::make('url'),
Forms\Components\TextInput::make('ip_address'), Forms\Components\TextInput::make('ip_address'),
Forms\Components\TextInput::make('user_agent') Forms\Components\TextInput::make('user_agent')

View File

@@ -20,7 +20,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Novadaemon\FilamentPrettyJson\PrettyJson; use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
/** /**
* @source https://gitlab.com/amvisor/filament-failed-jobs * @source https://gitlab.com/amvisor/filament-failed-jobs
@@ -50,7 +50,7 @@ class FailedJobResource extends Resource
// make text a little bit smaller because often a complete Stack Trace is shown: // make text a little bit smaller because often a complete Stack Trace is shown:
TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']), TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
PrettyJson::make('payload')->disabled()->columnSpan(4), PrettyJsonField::make('payload')->disabled()->columnSpan(4),
])->columns(4); ])->columns(4);
} }

View File

@@ -4,6 +4,11 @@ declare(strict_types=1);
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Filament\Resources\OrganizationResource\Pages; use App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource\RelationManagers\InvitationsRelationManager; use App\Filament\Resources\OrganizationResource\RelationManagers\InvitationsRelationManager;
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager; use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
@@ -56,6 +61,21 @@ class OrganizationResource extends Resource
->searchable(['name', 'email']) ->searchable(['name', 'email'])
->disabledOn(['edit']) ->disabledOn(['edit'])
->required(), ->required(),
Select::make('date_format')
->options(DateFormat::toSelectArray())
->required(),
Select::make('currency_format')
->options(CurrencyFormat::toSelectArray())
->required(),
Select::make('interval_format')
->options(IntervalFormat::toSelectArray())
->required(),
Select::make('number_format')
->options(NumberFormat::toSelectArray())
->required(),
Select::make('time_format')
->options(TimeFormat::toSelectArray())
->required(),
Forms\Components\Select::make('currency') Forms\Components\Select::make('currency')
->label('Currency') ->label('Currency')
->options(function (): array { ->options(function (): array {

View File

@@ -18,7 +18,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn; use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Novadaemon\FilamentPrettyJson\PrettyJson; use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
class ReportResource extends Resource class ReportResource extends Resource
{ {
@@ -58,7 +58,7 @@ class ReportResource extends Resource
Forms\Components\TextInput::make('share_secret') Forms\Components\TextInput::make('share_secret')
->label('Share Secret') ->label('Share Secret')
->nullable(), ->nullable(),
PrettyJson::make('properties') PrettyJsonField::make('properties')
->formatStateUsing(function (ReportPropertiesDto $state, Report $record): string { ->formatStateUsing(function (ReportPropertiesDto $state, Report $record): string {
return $record->getRawOriginal('properties'); return $record->getRawOriginal('properties');
}) })

View File

@@ -24,7 +24,7 @@ class CreateUser extends CreateRecord
$data['timezone'], $data['timezone'],
Weekday::from($data['week_start']), Weekday::from($data['week_start']),
$data['currency'], $data['currency'],
(bool) $data['is_email_verified'] verifyEmail: (bool) $data['is_email_verified']
); );
return $user; return $user;

View File

@@ -136,6 +136,8 @@ class MemberController extends Controller
} }
/** /**
* Merge one member into another
*
* @throws AuthorizationException * @throws AuthorizationException
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember * @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
* @throws \Throwable * @throws \Throwable

View File

@@ -40,15 +40,35 @@ class OrganizationController extends Controller
{ {
$this->checkPermission($organization, 'organizations:update'); $this->checkPermission($organization, 'organizations:update');
$organization->name = $request->input('name'); if ($request->getName() !== null) {
$oldBillableRate = $organization->billable_rate; $organization->name = $request->getName();
if ($request->has('employees_can_see_billable_rates')) { }
$organization->employees_can_see_billable_rates = $request->validated('employees_can_see_billable_rates'); if ($request->getEmployeesCanSeeBillableRates() !== null) {
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
}
if ($request->getNumberFormat() !== null) {
$organization->number_format = $request->getNumberFormat();
}
if ($request->getCurrencyFormat() !== null) {
$organization->currency_format = $request->getCurrencyFormat();
}
if ($request->getDateFormat() !== null) {
$organization->date_format = $request->getDateFormat();
}
if ($request->getIntervalFormat() !== null) {
$organization->interval_format = $request->getIntervalFormat();
}
if ($request->getTimeFormat() !== null) {
$organization->time_format = $request->getTimeFormat();
}
$hasBillableRate = $request->has('billable_rate');
if ($hasBillableRate) {
$oldBillableRate = $organization->billable_rate;
$organization->billable_rate = $request->getBillableRate();
} }
$organization->billable_rate = $request->getBillableRate();
$organization->save(); $organization->save();
if ($oldBillableRate !== $request->getBillableRate()) { if ($hasBillableRate && $oldBillableRate !== $request->getBillableRate()) {
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization); $billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
} }

View File

@@ -27,6 +27,7 @@ use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\Task; use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Service\LocalizationService;
use App\Service\ReportExport\TimeEntriesDetailedCsvExport; use App\Service\ReportExport\TimeEntriesDetailedCsvExport;
use App\Service\ReportExport\TimeEntriesDetailedExport; use App\Service\ReportExport\TimeEntriesDetailedExport;
use App\Service\ReportExport\TimeEntriesReportExport; use App\Service\ReportExport\TimeEntriesReportExport;
@@ -194,6 +195,7 @@ class TimeEntryController extends Controller
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension(); $filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$folderPath = 'exports'; $folderPath = 'exports';
$path = $folderPath.'/'.$filename; $path = $folderPath.'/'.$filename;
$localizationService = LocalizationService::forOrganization($organization);
if ($format === ExportFormat::CSV) { if ($format === ExportFormat::CSV) {
$export = new TimeEntriesDetailedCsvExport(config('filesystems.private'), $folderPath, $filename, $timeEntriesQuery, 1000, $timezone); $export = new TimeEntriesDetailedCsvExport(config('filesystems.private'), $folderPath, $filename, $timeEntriesQuery, 1000, $timezone);
$export->export(); $export->export();
@@ -223,6 +225,7 @@ class TimeEntryController extends Controller
'currency' => $organization->currency, 'currency' => $organization->currency,
'start' => $request->getStart()->timezone($timezone), 'start' => $request->getStart()->timezone($timezone),
'end' => $request->getEnd()->timezone($timezone), 'end' => $request->getEnd()->timezone($timezone),
'localization' => $localizationService,
]); ]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf-footer.blade.php')); $footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf-footer.blade.php'));
if ($footerViewFile === false) { if ($footerViewFile === false) {
@@ -257,7 +260,7 @@ class TimeEntryController extends Controller
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename); ->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else { } else {
Excel::store( Excel::store(
new TimeEntriesDetailedExport($timeEntriesQuery, $format, $timezone), new TimeEntriesDetailedExport($timeEntriesQuery, $format, $timezone, $localizationService),
$path, $path,
config('filesystems.private'), config('filesystems.private'),
$format->getExportPackageType(), $format->getExportPackageType(),
@@ -394,6 +397,7 @@ class TimeEntryController extends Controller
); );
$currency = $organization->currency; $currency = $organization->currency;
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user()); $timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
$localizationService = LocalizationService::forOrganization($organization);
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension(); $filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$folderPath = 'exports'; $folderPath = 'exports';
@@ -419,9 +423,11 @@ class TimeEntryController extends Controller
'currency' => $currency, 'currency' => $currency,
'group' => $group, 'group' => $group,
'subGroup' => $subGroup, 'subGroup' => $subGroup,
'timezone' => $timezone,
'start' => $request->getStart()->timezone($timezone), 'start' => $request->getStart()->timezone($timezone),
'end' => $request->getEnd()->timezone($timezone), 'end' => $request->getEnd()->timezone($timezone),
'debug' => $debug, 'debug' => $debug,
'localization' => $localizationService,
]); ]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php')); $footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php'));
if ($footerViewFile === false) { if ($footerViewFile === false) {

View File

@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Resources\V1\User\UserResource; use App\Http\Resources\V1\User\UserResource;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Resources\Json\JsonResource;
class UserController extends Controller class UserController extends Controller
{ {
@@ -19,7 +18,7 @@ class UserController extends Controller
* *
* @throws AuthorizationException * @throws AuthorizationException
*/ */
public function me(): JsonResource public function me(): UserResource
{ {
$user = $this->user(); $user = $this->user();

View File

@@ -40,6 +40,7 @@ class HandleInertiaRequests extends Middleware
public function share(Request $request): array public function share(Request $request): array
{ {
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing'); $hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
/** @var BillingContract $billing */ /** @var BillingContract $billing */
$billing = app(BillingContract::class); $billing = app(BillingContract::class);
@@ -48,6 +49,7 @@ class HandleInertiaRequests extends Middleware
return array_merge(parent::share($request), [ return array_merge(parent::share($request), [
'has_billing_extension' => $hasBilling, 'has_billing_extension' => $hasBilling,
'has_invoicing_extension' => $hasInvoicing,
'billing' => $billing !== null && $currentOrganization !== null ? [ 'billing' => $billing !== null && $currentOrganization !== null ? [
'has_subscription' => $billing->hasSubscription($currentOrganization), 'has_subscription' => $billing->hasSubscription($currentOrganization),
'has_trial' => $billing->hasTrial($currentOrganization), 'has_trial' => $billing->hasTrial($currentOrganization),

View File

@@ -4,9 +4,14 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Organization; namespace App\Http\Requests\V1\Organization;
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Models\Organization; use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
@@ -16,13 +21,12 @@ class OrganizationUpdateRequest extends FormRequest
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
* *
* @return array<string, array<string|ValidationRule>> * @return array<string, array<string|\Illuminate\Contracts\Validation\Rule>>
*/ */
public function rules(): array public function rules(): array
{ {
return [ return [
'name' => [ 'name' => [
'required',
'string', 'string',
'max:255', 'max:255',
], ],
@@ -35,13 +39,63 @@ class OrganizationUpdateRequest extends FormRequest
'employees_can_see_billable_rates' => [ 'employees_can_see_billable_rates' => [
'boolean', 'boolean',
], ],
'number_format' => [
Rule::enum(NumberFormat::class),
],
'currency_format' => [
Rule::enum(CurrencyFormat::class),
],
'date_format' => [
Rule::enum(DateFormat::class),
],
'interval_format' => [
Rule::enum(IntervalFormat::class),
],
'time_format' => [
Rule::enum(TimeFormat::class),
],
]; ];
} }
public function getName(): ?string
{
return $this->has('name') ? (string) $this->input('name') : null;
}
public function getNumberFormat(): ?NumberFormat
{
return $this->has('number_format') ? NumberFormat::from($this->input('number_format')) : null;
}
public function getCurrencyFormat(): ?CurrencyFormat
{
return $this->has('currency_format') ? CurrencyFormat::from($this->input('currency_format')) : null;
}
public function getDateFormat(): ?DateFormat
{
return $this->has('date_format') ? DateFormat::from($this->input('date_format')) : null;
}
public function getIntervalFormat(): ?IntervalFormat
{
return $this->has('interval_format') ? IntervalFormat::from($this->input('interval_format')) : null;
}
public function getTimeFormat(): ?TimeFormat
{
return $this->has('time_format') ? TimeFormat::from($this->input('time_format')) : null;
}
public function getBillableRate(): ?int public function getBillableRate(): ?int
{ {
$input = $this->input('billable_rate'); $input = $this->input('billable_rate');
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
} }
public function getEmployeesCanSeeBillableRates(): ?bool
{
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
}
} }

View File

@@ -11,6 +11,7 @@ use App\Rules\ColorRule;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -27,6 +28,7 @@ class ProjectStoreRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
// Name of the project, the name needs to be unique per client and organization
'name' => [ 'name' => [
'required', 'required',
'string', 'string',
@@ -34,7 +36,13 @@ class ProjectStoreRequest extends FormRequest
'max:255', 'max:255',
UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder { UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); $clientId = $this->input('client_id');
if (! is_string($clientId) || ! Str::isUuid($clientId)) {
$clientId = null;
}
return $builder->whereBelongsTo($this->organization, 'organization')
->where('client_id', $clientId);
})->withCustomTranslation('validation.project_name_already_exists'), })->withCustomTranslation('validation.project_name_already_exists'),
], ],
'color' => [ 'color' => [
@@ -55,6 +63,7 @@ class ProjectStoreRequest extends FormRequest
], ],
// ID of the client // ID of the client
'client_id' => [ 'client_id' => [
'present',
'nullable', 'nullable',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */ /** @var Builder<Client> $builder */

View File

@@ -11,6 +11,7 @@ use App\Rules\ColorRule;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -34,7 +35,13 @@ class ProjectUpdateRequest extends FormRequest
'max:255', 'max:255',
UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder { UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); $clientId = $this->input('client_id');
if (! is_string($clientId) || ! Str::isUuid($clientId)) {
$clientId = null;
}
return $builder->whereBelongsTo($this->organization, 'organization')
->where('client_id', $clientId);
})->ignore($this->project?->getKey())->withCustomTranslation('validation.project_name_already_exists'), })->ignore($this->project?->getKey())->withCustomTranslation('validation.project_name_already_exists'),
], ],
'color' => [ 'color' => [
@@ -54,6 +61,7 @@ class ProjectUpdateRequest extends FormRequest
'boolean', 'boolean',
], ],
'client_id' => [ 'client_id' => [
'present',
'nullable', 'nullable',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */ /** @var Builder<Client> $builder */

View File

@@ -40,7 +40,7 @@ class ReportStoreRequest extends FormRequest
'required', 'required',
'boolean', 'boolean',
], ],
// After this date the report will be automatically set to private (is_public=false) (ISO 8601 format, UTC timezone) // After this date the report will be automatically set to private (is_public=false) (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
'public_until' => [ 'public_until' => [
'nullable', 'nullable',
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',

View File

@@ -59,12 +59,12 @@ class TimeEntryStoreRequest extends FormRequest
->where('project_id', $this->input('project_id')); ->where('project_id', $this->input('project_id'));
})->uuid()->withMessage(__('validation.task_belongs_to_project')), })->uuid()->withMessage(__('validation.task_belongs_to_project')),
], ],
// Start of time entry (ISO 8601 format, UTC timezone) // Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
'start' => [ 'start' => [
'required', 'required',
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',
], ],
// End of time entry (ISO 8601 format, UTC timezone) // End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
'end' => [ 'end' => [
'nullable', 'nullable',
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',

View File

@@ -59,11 +59,11 @@ class TimeEntryUpdateRequest extends FormRequest
->where('project_id', $this->input('project_id')); ->where('project_id', $this->input('project_id'));
})->uuid()->withMessage(__('validation.task_belongs_to_project')), })->uuid()->withMessage(__('validation.task_belongs_to_project')),
], ],
// Start of time entry (ISO 8601 format, UTC timezone) // Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
'start' => [ 'start' => [
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',
], ],
// End of time entry (ISO 8601 format, UTC timezone) // End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
'end' => [ 'end' => [
'nullable', 'nullable',
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',

View File

@@ -12,6 +12,10 @@ abstract class BaseResource extends JsonResource
protected function formatDateTime(?Carbon $carbon): ?string protected function formatDateTime(?Carbon $carbon): ?string
{ {
return $carbon?->toIso8601ZuluString(); return $carbon?->toIso8601ZuluString();
}
protected function formatDate(?Carbon $carbon): ?string
{
return $carbon?->format('Y-m-d');
} }
} }

View File

@@ -47,6 +47,16 @@ class OrganizationResource extends BaseResource
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates, 'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
/** @var string $currency Currency code (ISO 4217) */ /** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->currency, 'currency' => $this->resource->currency,
/** @var string $number_format Number format */
'number_format' => $this->resource->number_format->value,
/** @var string $currency_format Currency format */
'currency_format' => $this->resource->currency_format->value,
/** @var string $date_format Date format */
'date_format' => $this->resource->date_format->value,
/** @var string $interval_format Interval format */
'interval_format' => $this->resource->interval_format->value,
/** @var string $time_format Time format */
'time_format' => $this->resource->time_format->value,
]; ];
} }
} }

View File

@@ -30,7 +30,7 @@ class DetailedReportResource extends BaseResource
/** @var bool $is_public Whether the report can be accessed via an external link */ /** @var bool $is_public Whether the report can be accessed via an external link */
'is_public' => $this->resource->is_public, 'is_public' => $this->resource->is_public,
/** @var string|null $public_until Date until the report is public */ /** @var string|null $public_until Date until the report is public */
'public_until' => $this->resource->public_until?->toIso8601ZuluString(), 'public_until' => $this->formatDateTime($this->resource->public_until),
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */ /** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
'shareable_link' => $this->resource->getShareableLink(), 'shareable_link' => $this->resource->getShareableLink(),
'properties' => [ 'properties' => [
@@ -41,9 +41,9 @@ class DetailedReportResource extends BaseResource
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */ /** @var string $history_group Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value, 'history_group' => $this->resource->properties->historyGroup->value,
/** @var string $start Start date of the report */ /** @var string $start Start date of the report */
'start' => $this->resource->properties->start->toIso8601ZuluString(), 'start' => $this->formatDateTime($this->resource->properties->start),
/** @var string $end End date of the report */ /** @var string $end End date of the report */
'end' => $this->resource->properties->end->toIso8601ZuluString(), 'end' => $this->formatDateTime($this->resource->properties->end),
/** @var bool|null $active Whether the report is active */ /** @var bool|null $active Whether the report is active */
'active' => $this->resource->properties->active, 'active' => $this->resource->properties->active,
/** @var array<string>|null $member_ids Filter by multiple member IDs, member IDs are OR combined */ /** @var array<string>|null $member_ids Filter by multiple member IDs, member IDs are OR combined */
@@ -60,9 +60,9 @@ class DetailedReportResource extends BaseResource
'task_ids' => $this->resource->properties->taskIds?->toArray(), 'task_ids' => $this->resource->properties->taskIds?->toArray(),
], ],
/** @var string $created_at Date when the report was created */ /** @var string $created_at Date when the report was created */
'created_at' => $this->resource->created_at?->toIso8601ZuluString(), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string $updated_at Date when the report was last updated */ /** @var string $updated_at Date when the report was last updated */
'updated_at' => $this->resource->updated_at?->toIso8601ZuluString(), 'updated_at' => $this->formatDateTime($this->resource->updated_at),
]; ];
} }
} }

View File

@@ -70,7 +70,7 @@ class DetailedWithDataReportResource extends BaseResource
/** @var string|null $email Description */ /** @var string|null $email Description */
'description' => $this->resource->description, 'description' => $this->resource->description,
/** @var string|null $public_until Date until the report is public */ /** @var string|null $public_until Date until the report is public */
'public_until' => $this->resource->public_until?->toIso8601ZuluString(), 'public_until' => $this->formatDateTime($this->resource->public_until),
/** @var string $currency Currency code (ISO 4217) */ /** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->organization->currency, 'currency' => $this->resource->organization->currency,
'properties' => [ 'properties' => [
@@ -81,9 +81,9 @@ class DetailedWithDataReportResource extends BaseResource
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */ /** @var string $history_group Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value, 'history_group' => $this->resource->properties->historyGroup->value,
/** @var string $start Start date of the report */ /** @var string $start Start date of the report */
'start' => $this->resource->properties->start->toIso8601ZuluString(), 'start' => $this->formatDateTime($this->resource->properties->start),
/** @var string $end End date of the report */ /** @var string $end End date of the report */
'end' => $this->resource->properties->end->toIso8601ZuluString(), 'end' => $this->formatDateTime($this->resource->properties->end),
], ],
/** @var array{ /** @var array{
* grouped_type: string|null, * grouped_type: string|null,

View File

@@ -30,13 +30,13 @@ class ReportResource extends BaseResource
/** @var bool $is_public Whether the report can be accessed via an external link */ /** @var bool $is_public Whether the report can be accessed via an external link */
'is_public' => $this->resource->is_public, 'is_public' => $this->resource->is_public,
/** @var string|null $public_until Date until the report is public */ /** @var string|null $public_until Date until the report is public */
'public_until' => $this->resource->public_until?->toIso8601ZuluString(), 'public_until' => $this->formatDateTime($this->resource->public_until),
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */ /** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
'shareable_link' => $this->resource->getShareableLink(), 'shareable_link' => $this->resource->getShareableLink(),
/** @var string $created_at Date when the report was created */ /** @var string $created_at Date when the report was created */
'created_at' => $this->resource->created_at?->toIso8601ZuluString(), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string $updated_at Date when the report was last updated */ /** @var string $updated_at Date when the report was last updated */
'updated_at' => $this->resource->updated_at?->toIso8601ZuluString(), 'updated_at' => $this->formatDateTime($this->resource->updated_at),
]; ];
} }
} }

View File

@@ -4,6 +4,11 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use Database\Factories\OrganizationFactory; use Database\Factories\OrganizationFactory;
@@ -18,7 +23,6 @@ use Illuminate\Support\Str;
use Laravel\Jetstream\Events\TeamCreated; use Laravel\Jetstream\Events\TeamCreated;
use Laravel\Jetstream\Events\TeamDeleted; use Laravel\Jetstream\Events\TeamDeleted;
use Laravel\Jetstream\Events\TeamUpdated; use Laravel\Jetstream\Events\TeamUpdated;
use Laravel\Jetstream\Jetstream;
use Laravel\Jetstream\Team as JetstreamTeam; use Laravel\Jetstream\Team as JetstreamTeam;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
@@ -37,6 +41,11 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Collection<int, User> $realUsers * @property Collection<int, User> $realUsers
* @property-read Collection<int, OrganizationInvitation> $teamInvitations * @property-read Collection<int, OrganizationInvitation> $teamInvitations
* @property Member $membership * @property Member $membership
* @property NumberFormat $number_format
* @property CurrencyFormat $currency_format
* @property DateFormat $date_format
* @property IntervalFormat $interval_format
* @property TimeFormat $time_format
* *
* @method HasMany<OrganizationInvitation> teamInvitations() * @method HasMany<OrganizationInvitation> teamInvitations()
* @method static OrganizationFactory factory() * @method static OrganizationFactory factory()
@@ -60,6 +69,11 @@ class Organization extends JetstreamTeam implements AuditableContract
'personal_team' => 'boolean', 'personal_team' => 'boolean',
'currency' => 'string', 'currency' => 'string',
'employees_can_see_billable_rates' => 'boolean', 'employees_can_see_billable_rates' => 'boolean',
'number_format' => NumberFormat::class,
'currency_format' => CurrencyFormat::class,
'date_format' => DateFormat::class,
'interval_format' => IntervalFormat::class,
'time_format' => TimeFormat::class,
]; ];
/** /**
@@ -89,7 +103,6 @@ class Organization extends JetstreamTeam implements AuditableContract
* @var array<string, mixed> * @var array<string, mixed>
*/ */
protected $attributes = [ protected $attributes = [
'currency' => 'EUR',
]; ];
/** /**

View File

@@ -133,6 +133,13 @@ class JetstreamServiceProvider extends ServiceProvider
'reports:create', 'reports:create',
'reports:update', 'reports:update',
'reports:delete', 'reports:delete',
'invoices:view',
'invoices:create',
'invoices:update',
'invoices:download',
'invoices:delete',
'invoice-settings:view',
'invoice-settings:update',
])->description('Owner users can perform any action. There is only one owner per organization.'); ])->description('Owner users can perform any action. There is only one owner per organization.');
Jetstream::role(Role::Admin->value, 'Administrator', [ Jetstream::role(Role::Admin->value, 'Administrator', [
@@ -185,6 +192,13 @@ class JetstreamServiceProvider extends ServiceProvider
'reports:create', 'reports:create',
'reports:update', 'reports:update',
'reports:delete', 'reports:delete',
'invoices:view',
'invoices:create',
'invoices:update',
'invoices:download',
'invoices:delete',
'invoice-settings:view',
'invoice-settings:update',
])->description('Administrator users can perform any action, except accessing the billing dashboard.'); ])->description('Administrator users can perform any action, except accessing the billing dashboard.');
Jetstream::role(Role::Manager->value, 'Manager', [ Jetstream::role(Role::Manager->value, 'Manager', [
@@ -227,6 +241,13 @@ class JetstreamServiceProvider extends ServiceProvider
'reports:create', 'reports:create',
'reports:update', 'reports:update',
'reports:delete', 'reports:delete',
'invoices:view',
'invoices:create',
'invoices:update',
'invoices:download',
'invoices:delete',
'invoice-settings:view',
'invoice-settings:update',
])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).'); ])->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', [ Jetstream::role(Role::Employee->value, 'Employee', [

View File

@@ -0,0 +1,377 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Brick\Money\Money;
class CurrencyService
{
/**
* @source https://gist.github.com/stephenfrank/a8245c2486f3e546107c5363706ac93e
*
* @const array<string, array<{ symbol: string }>>
*/
private const array CURRENCIES = [
'ALL' => [
'symbol' => 'L',
],
'AFN' => [
'symbol' => '؋',
],
'ARS' => [
'symbol' => '$',
],
'AWG' => [
'symbol' => 'ƒ',
],
'AUD' => [
'symbol' => '$',
],
'AZN' => [
'symbol' => '₼',
],
'BSD' => [
'symbol' => '$',
],
'BBD' => [
'symbol' => '$',
],
'BDT' => [
'symbol' => '৳',
],
'BYR' => [
'symbol' => 'Br',
],
'BZD' => [
'symbol' => 'BZ$',
],
'BMD' => [
'symbol' => '$',
],
'BOB' => [
'symbol' => '$b',
],
'BAM' => [
'symbol' => 'KM',
],
'BWP' => [
'symbol' => 'P',
],
'BGN' => [
'symbol' => 'лв',
],
'BRL' => [
'symbol' => 'R$',
],
'BND' => [
'symbol' => '$',
],
'KHR' => [
'symbol' => '៛',
],
'CAD' => [
'symbol' => '$',
],
'KYD' => [
'symbol' => '$',
],
'CLP' => [
'symbol' => '$',
],
'CNY' => [
'symbol' => '¥',
],
'COP' => [
'symbol' => '$',
],
'CRC' => [
'symbol' => '₡',
],
'HRK' => [
'symbol' => 'kn',
],
'CUP' => [
'symbol' => '₱',
],
'CZK' => [
'symbol' => 'Kč',
],
'DKK' => [
'symbol' => 'kr',
],
'DOP' => [
'symbol' => 'RD$',
],
'XCD' => [
'symbol' => '$',
],
'EGP' => [
'symbol' => '£',
],
'SVC' => [
'symbol' => '$',
],
'EEK' => [
'symbol' => 'kr',
],
'EUR' => [
'symbol' => '€',
],
'FKP' => [
'symbol' => '£',
],
'FJD' => [
'symbol' => '$',
],
'GHC' => [
'symbol' => '₵',
],
'GIP' => [
'symbol' => '£',
],
'GTQ' => [
'symbol' => 'Q',
],
'GGP' => [
'symbol' => '£',
],
'GYD' => [
'symbol' => '$',
],
'HNL' => [
'symbol' => 'L',
],
'HKD' => [
'symbol' => '$',
],
'HUF' => [
'symbol' => 'Ft',
],
'ISK' => [
'symbol' => 'kr',
],
'INR' => [
'symbol' => '₹',
],
'IDR' => [
'symbol' => 'Rp',
],
'IRR' => [
'symbol' => '﷼',
],
'IMP' => [
'symbol' => '£',
],
'ILS' => [
'symbol' => '₪',
],
'JMD' => [
'symbol' => 'J$',
],
'JPY' => [
'symbol' => '¥',
],
'JEP' => [
'symbol' => '£',
],
'KZT' => [
'symbol' => 'лв',
],
'KPW' => [
'symbol' => '₩',
],
'KRW' => [
'symbol' => '₩',
],
'KGS' => [
'symbol' => 'лв',
],
'LAK' => [
'symbol' => '₭',
],
'LVL' => [
'symbol' => 'Ls',
],
'LBP' => [
'symbol' => '£',
],
'LRD' => [
'symbol' => '$',
],
'LTL' => [
'symbol' => 'Lt',
],
'MKD' => [
'symbol' => 'ден',
],
'MYR' => [
'symbol' => 'RM',
],
'MUR' => [
'symbol' => '₨',
],
'MXN' => [
'symbol' => '$',
],
'MNT' => [
'symbol' => '₮',
],
'MZN' => [
'symbol' => 'MT',
],
'NAD' => [
'symbol' => '$',
],
'NPR' => [
'symbol' => '₨',
],
'ANG' => [
'symbol' => 'ƒ',
],
'NZD' => [
'symbol' => '$',
],
'NIO' => [
'symbol' => 'C$',
],
'NGN' => [
'symbol' => '₦',
],
'NOK' => [
'symbol' => 'kr',
],
'OMR' => [
'symbol' => '﷼',
],
'PKR' => [
'symbol' => '₨',
],
'PAB' => [
'symbol' => 'B/.',
],
'PYG' => [
'symbol' => 'Gs',
],
'PEN' => [
'symbol' => 'S/.',
],
'PHP' => [
'symbol' => '₱',
],
'PLN' => [
'symbol' => 'zł',
],
'QAR' => [
'symbol' => '﷼',
],
'RON' => [
'symbol' => 'lei',
],
'RUB' => [
'symbol' => '₽',
],
'SHP' => [
'symbol' => '£',
],
'SAR' => [
'symbol' => '﷼',
],
'RSD' => [
'symbol' => 'Дин.',
],
'SCR' => [
'symbol' => '₨',
],
'SGD' => [
'symbol' => '$',
],
'SBD' => [
'symbol' => '$',
],
'SOS' => [
'symbol' => 'S',
],
'ZAR' => [
'symbol' => 'R',
],
'LKR' => [
'symbol' => '₨',
],
'SEK' => [
'symbol' => 'kr',
],
'CHF' => [
'symbol' => 'CHF',
],
'SRD' => [
'symbol' => '$',
],
'SYP' => [
'symbol' => '£',
],
'TWD' => [
'symbol' => 'NT$',
],
'THB' => [
'symbol' => '฿',
],
'TTD' => [
'symbol' => 'TT$',
],
'TRY' => [
'symbol' => '₺',
],
'TRL' => [
'symbol' => '₤',
],
'TVD' => [
'symbol' => '$',
],
'UAH' => [
'symbol' => '₴',
],
'GBP' => [
'symbol' => '£',
],
'UGX' => [
'symbol' => 'USh',
],
'USD' => [
'symbol' => '$',
],
'UYU' => [
'symbol' => '$U',
],
'UZS' => [
'symbol' => 'лв',
],
'VEF' => [
'symbol' => 'Bs',
],
'VND' => [
'symbol' => '₫',
],
'YER' => [
'symbol' => '﷼',
],
'ZWD' => [
'symbol' => 'Z$',
],
];
public function getCurrencySymbolForMoney(Money $money): string
{
return $this->getCurrencySymbol($money->getCurrency()->getCurrencyCode());
}
public function getCurrencySymbol(string $currencyCode): string
{
if (isset(self::CURRENCIES[$currencyCode]['symbol'])) {
return self::CURRENCIES[$currencyCode]['symbol'];
}
return $currencyCode;
}
}

View File

@@ -37,9 +37,9 @@ class ClockifyProjectsImporter extends DefaultImporter
if ($record['Project'] !== '') { if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([ $projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'], 'name' => $record['Project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
], [ ], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(), 'color' => $this->colorService->getRandomColor(),
'is_billable' => $record['Billability'] === 'Yes', 'is_billable' => $record['Billability'] === 'Yes',
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null, 'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,

View File

@@ -83,9 +83,9 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
if ($record['Project'] !== '') { if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([ $projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'], 'name' => $record['Project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
], [ ], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(), 'color' => $this->colorService->getRandomColor(),
'is_billable' => false, 'is_billable' => false,
]); ]);
@@ -124,34 +124,59 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$timeEntry->is_imported = true; $timeEntry->is_imported = true;
// Start // Start
$start = null;
try { try {
if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['Start Time']) === 1) { $startDateStr = $record['Start Date'];
$start = Carbon::createFromFormat('m/d/Y h:i A', $record['Start Date'].' '.$record['Start Time'], $timezone); $startTimeStr = $record['Start Time'];
} else { $startStr = $startDateStr.' '.$startTimeStr;
$start = Carbon::createFromFormat('m/d/Y H:i:s A', $record['Start Date'].' '.$record['Start Time'], $timezone); $matches = [];
$checkResult = preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4}) ([0-9]{1,2}):([0-9]{1,2})(:[0-9]{1,2})? (AM|PM)$/', $startStr, $matches);
if ($checkResult === 1) {
if ((int) $matches[1] > 12) {
throw new ImportException('Start date ("'.$startDateStr.'") is invalid, please select the correct date format before exporting from Clockify');
}
if ($matches[6] === '') {
$start = Carbon::createFromFormat('m/d/Y h:i A', $startStr, $timezone);
} else {
$start = Carbon::createFromFormat('m/d/Y H:i:s A', $startStr, $timezone);
}
} }
} catch (InvalidFormatException) { } catch (InvalidFormatException) {
throw new ImportException('Start date ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid'); throw new ImportException('Start date ("'.$startDateStr.'") or time ("'.$startTimeStr.'") are invalid');
} }
if ($start === null) { if ($start === null) {
throw new ImportException('Start date ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid'); throw new ImportException('Start date ("'.$startDateStr.'") or time ("'.$startTimeStr.'") are invalid');
} }
$timeEntry->start = $start->utc(); $timeEntry->start = $start->utc();
// End // End
$end = null;
try { try {
if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['End Time']) === 1) { $endDateStr = $record['End Date'];
$end = Carbon::createFromFormat('m/d/Y h:i A', $record['End Date'].' '.$record['End Time'], $timezone); $endTimeStr = $record['End Time'];
} else { $endStr = $endDateStr.' '.$endTimeStr;
$end = Carbon::createFromFormat('m/d/Y H:i:s A', $record['End Date'].' '.$record['End Time'], $timezone); $matches = [];
$checkResult = preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4}) ([0-9]{1,2}):([0-9]{1,2})(:[0-9]{1,2})? (AM|PM)$/', $endStr, $matches);
if ($checkResult === 1) {
if ((int) $matches[1] > 12) {
throw new ImportException('Start date ("'.$endDateStr.'") is invalid, please select the correct date format before exporting from Clockify');
}
if ($matches[6] === '') {
$end = Carbon::createFromFormat('m/d/Y h:i A', $endStr, $timezone);
} else {
$end = Carbon::createFromFormat('m/d/Y H:i:s A', $endStr, $timezone);
}
} }
} catch (InvalidFormatException) { } catch (InvalidFormatException) {
throw new ImportException('End date ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid'); throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") are invalid');
} }
if ($end === null) { if ($end === null) {
throw new ImportException('End date ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid'); throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") are invalid');
} }
$timeEntry->end = $end->utc(); $timeEntry->end = $end->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( $timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
$timeEntry, $timeEntry,
$projectMember, $projectMember,

View File

@@ -97,7 +97,7 @@ abstract class DefaultImporter implements ImporterContract
'in:placeholder', 'in:placeholder',
], ],
]); ]);
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) { $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'client_id', 'organization_id'], true, function (Builder $builder) {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->where('organization_id', $this->organization->id); return $builder->where('organization_id', $this->organization->id);
}, validate: [ }, validate: [
@@ -114,6 +114,11 @@ abstract class DefaultImporter implements ImporterContract
'integer', 'integer',
'max:2147483647', 'max:2147483647',
], ],
'client_id' => [
'nullable',
'string',
'uuid',
],
], beforeSave: function (Project $project): void { ], beforeSave: function (Project $project): void {
if ($project->billable_rate === 0) { if ($project->billable_rate === 0) {
$project->billable_rate = null; $project->billable_rate = null;

View File

@@ -55,12 +55,12 @@ class GenericProjectsImporter extends DefaultImporter
} }
$this->projectImportHelper->getKey([ $this->projectImportHelper->getKey([
'name' => $record['name'], 'name' => $record['name'],
'client_id' => $clientId,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
], [ ], [
'color' => isset($record['color']) && $record['color'] !== '' ? $record['color'] : app(ColorService::class)->getRandomColor(), 'color' => isset($record['color']) && $record['color'] !== '' ? $record['color'] : app(ColorService::class)->getRandomColor(),
'billable_rate' => isset($record['billable_rate']) && $record['billable_rate'] !== '' ? (int) $record['billable_rate'] : null, 'billable_rate' => isset($record['billable_rate']) && $record['billable_rate'] !== '' ? (int) $record['billable_rate'] : null,
'is_public' => isset($record['is_public']) && $record['is_public'] === 'true', 'is_public' => isset($record['is_public']) && $record['is_public'] === 'true',
'client_id' => $clientId,
'is_billable' => isset($record['billable_default']) && $record['billable_default'] === 'true', 'is_billable' => isset($record['billable_default']) && $record['billable_default'] === 'true',
'estimated_time' => isset($record['estimated_time']) && $record['estimated_time'] !== '' && is_numeric($record['estimated_time']) && ((int) $record['estimated_time'] !== 0) ? (int) $record['estimated_time'] : null, 'estimated_time' => isset($record['estimated_time']) && $record['estimated_time'] !== '' && is_numeric($record['estimated_time']) && ((int) $record['estimated_time'] !== 0) ? (int) $record['estimated_time'] : null,
'archived_at' => $archivedAt, 'archived_at' => $archivedAt,

View File

@@ -99,9 +99,9 @@ class GenericTimeEntriesImporter extends DefaultImporter
if ($record['project'] !== '') { if ($record['project'] !== '') {
$projectId = $this->projectImportHelper->getKey([ $projectId = $this->projectImportHelper->getKey([
'name' => $record['project'], 'name' => $record['project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
], [ ], [
'client_id' => $clientId,
'is_billable' => false, 'is_billable' => false,
'color' => $this->colorService->getRandomColor(), 'color' => $this->colorService->getRandomColor(),
]); ]);

View File

@@ -60,10 +60,10 @@ class HarvestProjectsImporter extends DefaultImporter
$billableHours = $billableHoursField !== '' && is_numeric($billableHoursField) ? (int) ((float) $billableHoursField) : null; $billableHours = $billableHoursField !== '' && is_numeric($billableHoursField) ? (int) ((float) $billableHoursField) : null;
$this->projectImportHelper->getKey([ $this->projectImportHelper->getKey([
'name' => $record['Project'], 'name' => $record['Project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
], [ ], [
'color' => $this->colorService->getRandomColor(), 'color' => $this->colorService->getRandomColor(),
'client_id' => $clientId,
'estimated_time' => $estimatedTime, 'estimated_time' => $estimatedTime,
'is_billable' => $billableHours > 0, 'is_billable' => $billableHours > 0,
]); ]);

View File

@@ -78,9 +78,9 @@ class HarvestTimeEntriesImporter extends DefaultImporter
if ($record['Project'] !== '') { if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([ $projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'], 'name' => $record['Project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
], [ ], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(), 'color' => $this->colorService->getRandomColor(),
'is_billable' => true, 'is_billable' => true,
]); ]);

View File

@@ -176,12 +176,12 @@ class SolidtimeImporter extends DefaultImporter
$this->projectImportHelper->getKey([ $this->projectImportHelper->getKey([
'name' => $project['name'], 'name' => $project['name'],
'client_id' => $clientId,
'organization_id' => $this->organization->getKey(), 'organization_id' => $this->organization->getKey(),
], [ ], [
'color' => $project['color'], 'color' => $project['color'],
'billable_rate' => $project['billable_rate'] === '' ? null : (int) $project['billable_rate'], 'billable_rate' => $project['billable_rate'] === '' ? null : (int) $project['billable_rate'],
'is_public' => $project['is_public'] === 'true', 'is_public' => $project['is_public'] === 'true',
'client_id' => $clientId,
'is_billable' => $project['is_billable'] === 'true', 'is_billable' => $project['is_billable'] === 'true',
'archived_at' => $project['archived_at'] !== '' ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $project['archived_at'], 'UTC') : null, 'archived_at' => $project['archived_at'] !== '' ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $project['archived_at'], 'UTC') : null,
], $project['id']); ], $project['id']);

View File

@@ -137,9 +137,9 @@ class TogglDataImporter extends DefaultImporter
$projectId = $this->projectImportHelper->getKey([ $projectId = $this->projectImportHelper->getKey([
'name' => $project->name, 'name' => $project->name,
'client_id' => $clientId,
'organization_id' => $this->organization->getKey(), 'organization_id' => $this->organization->getKey(),
], [ ], [
'client_id' => $clientId,
'color' => $project->color, 'color' => $project->color,
'is_billable' => $project->billable, 'is_billable' => $project->billable,
'is_public' => ! $project->is_private, 'is_public' => ! $project->is_private,

View File

@@ -83,9 +83,9 @@ class TogglTimeEntriesImporter extends DefaultImporter
if ($record['Project'] !== '') { if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([ $projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'], 'name' => $record['Project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
], [ ], [
'client_id' => $clientId,
'is_billable' => false, 'is_billable' => false,
'color' => $this->colorService->getRandomColor(), 'color' => $this->colorService->getRandomColor(),
]); ]);

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Models\Organization;
use Brick\Math\BigDecimal;
use Brick\Money\Money;
use Carbon\CarbonInterface;
use Carbon\CarbonInterval;
class LocalizationService
{
private CurrencyFormat $currencyFormat;
private IntervalFormat $intervalFormat;
private DateFormat $dateFormat;
private TimeFormat $timeFormat;
private NumberFormat $numberFormat;
public function __construct(CurrencyFormat $currencyFormat, DateFormat $dateFormat, TimeFormat $timeFormat, NumberFormat $numberFormat, IntervalFormat $intervalFormat)
{
$this->currencyFormat = $currencyFormat;
$this->dateFormat = $dateFormat;
$this->timeFormat = $timeFormat;
$this->numberFormat = $numberFormat;
$this->intervalFormat = $intervalFormat;
}
public static function forOrganization(Organization $organization): self
{
return new LocalizationService(
$organization->currency_format,
$organization->date_format,
$organization->time_format,
$organization->number_format,
$organization->interval_format
);
}
public function formatNumber(BigDecimal|float $number): string
{
$numberFloat = $number instanceof BigDecimal ? $number->toFloat() : $number;
if ($this->numberFormat === NumberFormat::ThousandsPointDecimalComma) {
return number_format($numberFloat, 2, ',', '.');
} elseif ($this->numberFormat === NumberFormat::ThousandsSpaceDecimalPoint) {
return number_format($numberFloat, 2, '.', ' ');
} elseif ($this->numberFormat === NumberFormat::ThousandsCommaDecimalPoint) {
return number_format($numberFloat, 2, '.', ',');
} elseif ($this->numberFormat === NumberFormat::ThousandsSpaceDecimalComma) {
return number_format($numberFloat, 2, ',', ' ');
} elseif ($this->numberFormat === NumberFormat::ThousandsApostropheDecimalPoint) {
return number_format($numberFloat, 2, '.', '\'');
}
}
public function formatNumberWithoutTrailingZeros(BigDecimal|float $number): string
{
$number = $this->formatNumber($number);
$number = rtrim($number, '0');
$number = rtrim($number, '.');
$number = rtrim($number, ',');
return $number;
}
public function formatInterval(CarbonInterval $interval): string
{
if ($this->intervalFormat === IntervalFormat::Decimal) {
$interval->cascade();
return $this->formatNumber($interval->totalHours);
} elseif ($this->intervalFormat === IntervalFormat::HoursMinutes) {
$interval->cascade();
return ((int) floor($interval->totalHours)).'h '.$interval->format('%I').'m';
} elseif ($this->intervalFormat === IntervalFormat::HoursMinutesColonSeperated) {
$interval->cascade();
return ((int) floor($interval->totalHours)).':'.$interval->format('%I');
} elseif ($this->intervalFormat === IntervalFormat::HoursMinutesSecondsColonSeperated) {
$interval->cascade();
return ((int) floor($interval->totalHours)).':'.$interval->format('%I:%S');
}
}
public function formatCurrency(Money $money): string
{
$currencyService = app(CurrencyService::class);
if ($this->currencyFormat === CurrencyFormat::ISOCodeAfterWithSpace) {
return $this->formatNumber($money->getAmount()).' '.$money->getCurrency()->getCurrencyCode();
} elseif ($this->currencyFormat === CurrencyFormat::ISOCodeBeforeWithSpace) {
return $money->getCurrency()->getCurrencyCode().' '.$this->formatNumber($money->getAmount());
} elseif ($this->currencyFormat === CurrencyFormat::SymbolAfter) {
return $this->formatNumber($money->getAmount()).$currencyService->getCurrencySymbolForMoney($money);
} elseif ($this->currencyFormat === CurrencyFormat::SymbolBefore) {
return $currencyService->getCurrencySymbolForMoney($money).$this->formatNumber($money->getAmount());
} elseif ($this->currencyFormat === CurrencyFormat::SymbolBeforeWithSpace) {
return $currencyService->getCurrencySymbolForMoney($money).' '.$this->formatNumber($money->getAmount());
} elseif ($this->currencyFormat === CurrencyFormat::SymbolAfterWithSpace) {
return $this->formatNumber($money->getAmount()).' '.$currencyService->getCurrencySymbolForMoney($money);
}
}
public function formatTime(CarbonInterface $time): string
{
if ($this->timeFormat === TimeFormat::TwelveHours) {
return $time->format('h:i a'); // Examples: "11:01 am", "1:02 am"
} elseif ($this->timeFormat === TimeFormat::TwentyFourHours) {
return $time->format('H:i'); // Examples: "23:01", "01:02"
}
}
public function formatDate(CarbonInterface $date): string
{
return $date->format($this->dateFormat->toCarbonFormat());
}
public function setDateFormat(DateFormat $dateFormat): void
{
$this->dateFormat = $dateFormat;
}
public function setCurrencyFormat(CurrencyFormat $currencyFormat): void
{
$this->currencyFormat = $currencyFormat;
}
public function setIntervalFormat(IntervalFormat $intervalFormat): void
{
$this->intervalFormat = $intervalFormat;
}
public function setTimeFormat(TimeFormat $timeFormat): void
{
$this->timeFormat = $timeFormat;
}
public function setNumberFormat(NumberFormat $numberFormat): void
{
$this->numberFormat = $numberFormat;
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\Role;
use App\Enums\TimeFormat;
use App\Models\Organization;
use App\Models\User;
class OrganizationService
{
public function createOrganization(
string $name,
User $owner,
bool $personalOrganization,
?string $currency = null,
?NumberFormat $numberFormat = null,
?CurrencyFormat $currencyFormat = null,
?DateFormat $dateFormat = null,
?IntervalFormat $intervalFormat = null,
?TimeFormat $timeFormat = null,
): Organization {
$organization = new Organization;
$organization->name = $name;
$organization->personal_team = $personalOrganization;
if ($currency === null) {
$currency = config('app.localization.default_currency');
}
$organization->currency = $currency;
if ($numberFormat === null) {
$numberFormat = NumberFormat::from(config('app.localization.default_number_format'));
}
$organization->number_format = $numberFormat;
if ($currencyFormat === null) {
$currencyFormat = CurrencyFormat::from(config('app.localization.default_currency_format'));
}
$organization->currency_format = $currencyFormat;
if ($dateFormat === null) {
$dateFormat = DateFormat::from(config('app.localization.default_date_format'));
}
$organization->date_format = $dateFormat;
if ($intervalFormat === null) {
$intervalFormat = IntervalFormat::from(config('app.localization.default_interval_format'));
}
$organization->interval_format = $intervalFormat;
if ($timeFormat === null) {
$timeFormat = TimeFormat::from(config('app.localization.default_time_format'));
}
$organization->time_format = $timeFormat;
$organization->owner()->associate($owner);
$organization->save();
$organization->users()->attach(
$owner, [
'role' => Role::Owner->value,
]
);
return $organization;
}
}

View File

@@ -6,7 +6,7 @@ namespace App\Service\ReportExport;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Service\IntervalService; use App\Service\LocalizationService;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use LogicException; use LogicException;
use Maatwebsite\Excel\Concerns\Exportable; use Maatwebsite\Excel\Concerns\Exportable;
@@ -37,14 +37,17 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
private string $timezone; private string $timezone;
private LocalizationService $localizationService;
/** /**
* @param Builder<TimeEntry> $builder * @param Builder<TimeEntry> $builder
*/ */
public function __construct(Builder $builder, ExportFormat $exportFormat, string $timezone) public function __construct(Builder $builder, ExportFormat $exportFormat, string $timezone, LocalizationService $localizationService)
{ {
$this->builder = $builder; $this->builder = $builder;
$this->exportFormat = $exportFormat; $this->exportFormat = $exportFormat;
$this->timezone = $timezone; $this->timezone = $timezone;
$this->localizationService = $localizationService;
} }
/** /**
@@ -113,7 +116,6 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
*/ */
public function map($model): array public function map($model): array
{ {
$interval = app(IntervalService::class);
$duration = $model->getDuration(); $duration = $model->getDuration();
if ($this->exportFormat === ExportFormat::XLSX) { if ($this->exportFormat === ExportFormat::XLSX) {
@@ -125,7 +127,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
$model->user->name, $model->user->name,
Date::dateTimeToExcel($model->start->timezone($this->timezone)), Date::dateTimeToExcel($model->start->timezone($this->timezone)),
$model->end !== null ? Date::dateTimeToExcel($model->end->timezone($this->timezone)) : null, $model->end !== null ? Date::dateTimeToExcel($model->end->timezone($this->timezone)) : null,
$duration !== null ? $interval->format($duration) : null, $duration !== null ? $this->localizationService->formatInterval($duration) : null,
$duration?->totalHours, $duration?->totalHours,
$model->billable ? 'Yes' : 'No', $model->billable ? 'Yes' : 'No',
$model->tagsRelation->pluck('name')->implode(', '), $model->tagsRelation->pluck('name')->implode(', '),
@@ -139,7 +141,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
$model->user->name, $model->user->name,
$model->start->timezone($this->timezone)->format('Y-m-d H:i:s'), $model->start->timezone($this->timezone)->format('Y-m-d H:i:s'),
$model->end?->timezone($this->timezone)?->format('Y-m-d H:i:s'), $model->end?->timezone($this->timezone)?->format('Y-m-d H:i:s'),
$duration !== null ? (int) floor($duration->totalHours).':'.$duration->format('%I:%S') : null, $duration !== null ? $this->localizationService->formatInterval($duration) : null,
$duration?->totalHours, $duration?->totalHours,
$model->billable ? 'Yes' : 'No', $model->billable ? 'Yes' : 'No',
$model->tagsRelation->pluck('name')->implode(', '), $model->tagsRelation->pluck('name')->implode(', '),

View File

@@ -280,6 +280,20 @@ class TimeEntryAggregationService
'color' => null, 'color' => null,
]; ];
} }
} elseif ($type === TimeEntryAggregationType::Description) {
foreach ($keys as $key) {
$descriptorMap[$key] = [
'description' => $key,
'color' => null,
];
}
} elseif ($type === TimeEntryAggregationType::Billable) {
foreach ($keys as $key) {
$descriptorMap[$key] = [
'description' => $key === '0' ? 'Non-billable' : 'Billable',
'color' => null,
];
}
} }
return $descriptorMap; return $descriptorMap;

View File

@@ -4,7 +4,12 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\Role; use App\Enums\Role;
use App\Enums\TimeFormat;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Events\AfterCreateOrganization; use App\Events\AfterCreateOrganization;
use App\Models\Member; use App\Models\Member;
@@ -17,8 +22,20 @@ use Illuminate\Support\Facades\Hash;
class UserService class UserService
{ {
public function createUser(string $name, string $email, string $password, string $timezone, Weekday $weekStart, string $currency, bool $verifyEmail = false): User public function createUser(
{ string $name,
string $email,
string $password,
string $timezone,
Weekday $weekStart,
?string $currency,
?NumberFormat $numberFormat = null,
?CurrencyFormat $currencyFormat = null,
?DateFormat $dateFormat = null,
?IntervalFormat $intervalFormat = null,
?TimeFormat $timeFormat = null,
bool $verifyEmail = false
): User {
$user = new User; $user = new User;
$user->name = $name; $user->name = $name;
$user->email = $email; $user->email = $email;
@@ -30,17 +47,16 @@ class UserService
} }
$user->save(); $user->save();
$organization = new Organization; $organization = app(OrganizationService::class)->createOrganization(
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization"; $this->getOrganizationNameForUserName($user->name),
$organization->personal_team = true; $user,
$organization->currency = $currency; true,
$organization->owner()->associate($user); $currency,
$organization->save(); $numberFormat,
$currencyFormat,
$organization->users()->attach( $dateFormat,
$user, [ $intervalFormat,
'role' => Role::Owner->value, $timeFormat,
]
); );
$user->ownedTeams()->save($organization); $user->ownedTeams()->save($organization);
@@ -78,14 +94,11 @@ class UserService
} }
// Create a new organization // Create a new organization
$organization = new Organization; $organization = app(OrganizationService::class)->createOrganization(
$organization->name = $user->name."'s Organization"; $this->getOrganizationNameForUserName($user->name),
$organization->personal_team = true; $user,
$organization->user_id = $user->id; true
$organization->save(); );
// Attach the user to the organization
$organization->users()->attach($user, ['role' => Role::Owner->value]);
// Set the organization as the user's current organization // Set the organization as the user's current organization
$user->currentOrganization()->associate($organization); $user->currentOrganization()->associate($organization);
@@ -94,6 +107,11 @@ class UserService
AfterCreateOrganization::dispatch($organization); AfterCreateOrganization::dispatch($organization);
} }
public function getOrganizationNameForUserName(string $username): string
{
return explode(' ', $username, 2)[0]."'s Organization";
}
public function makeSureUserHasCurrentOrganization(User $user): void public function makeSureUserHasCurrentOrganization(User $user): void
{ {
if ($user->currentOrganization !== null) { if ($user->currentOrganization !== null) {

20
components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "resources/css/app.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"composables": "@/composables",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}

View File

@@ -16,6 +16,7 @@
"guzzlehttp/guzzle": "^7.2", "guzzlehttp/guzzle": "^7.2",
"inertiajs/inertia-laravel": "^1.0", "inertiajs/inertia-laravel": "^1.0",
"korridor/laravel-computed-attributes": "^3.1", "korridor/laravel-computed-attributes": "^3.1",
"korridor/laravel-has-many-sync": "^3.1",
"korridor/laravel-model-validation-rules": "^3.0", "korridor/laravel-model-validation-rules": "^3.0",
"laravel/framework": "^11.16.0", "laravel/framework": "^11.16.0",
"laravel/jetstream": "^5.0", "laravel/jetstream": "^5.0",
@@ -24,6 +25,7 @@
"laravel/tinker": "^2.8", "laravel/tinker": "^2.8",
"league/csv": "^9.16.0", "league/csv": "^9.16.0",
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
"league/iso3166": "^4.3",
"maatwebsite/excel": "^3.1", "maatwebsite/excel": "^3.1",
"novadaemon/filament-pretty-json": "^2.2", "novadaemon/filament-pretty-json": "^2.2",
"nwidart/laravel-modules": "^11.0.11", "nwidart/laravel-modules": "^11.0.11",

1811
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,11 @@
declare(strict_types=1); declare(strict_types=1);
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use Illuminate\Support\Facades\Facade; use Illuminate\Support\Facades\Facade;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@@ -138,6 +143,15 @@ return [
'cipher' => 'AES-256-CBC', 'cipher' => 'AES-256-CBC',
'localization' => [
'default_currency' => env('LOCALIZATION_DEFAULT_CURRENCY', 'EUR'),
'default_number_format' => env('LOCALIZATION_DEFAULT_NUMBER_FORMAT', NumberFormat::ThousandsPointDecimalComma->value),
'default_currency_format' => env('LOCALIZATION_DEFAULT_CURRENCY_FORMAT', CurrencyFormat::ISOCodeAfterWithSpace->value),
'default_date_format' => env('LOCALIZATION_DEFAULT_DATE_FORMAT', DateFormat::HyphenSeperatedYYYYMMDD->value),
'default_time_format' => env('LOCALIZATION_DEFAULT_TIME_FORMAT', TimeFormat::TwentyFourHours->value),
'default_interval_format' => env('LOCALIZATION_DEFAULT_INTERVAL_FORMAT', IntervalFormat::HoursMinutes->value),
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Maintenance Mode Driver | Maintenance Mode Driver

View File

@@ -4,6 +4,11 @@ declare(strict_types=1);
namespace Database\Factories; namespace Database\Factories;
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
@@ -27,6 +32,11 @@ class OrganizationFactory extends Factory
'user_id' => User::factory(), 'user_id' => User::factory(),
'personal_team' => true, 'personal_team' => true,
'employees_can_see_billable_rates' => false, 'employees_can_see_billable_rates' => false,
'number_format' => $this->faker->randomElement(NumberFormat::values()),
'currency_format' => $this->faker->randomElement(CurrencyFormat::values()),
'date_format' => $this->faker->randomElement(DateFormat::values()),
'interval_format' => $this->faker->randomElement(IntervalFormat::values()),
'time_format' => $this->faker->randomElement(TimeFormat::values()),
]; ];
} }

View File

@@ -0,0 +1,46 @@
<?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::table('organizations', function (Blueprint $table): void {
$table->string('number_format')->default(config('app.localization.default_number_format'))->nullable(false);
$table->string('currency_format')->default(config('app.localization.default_currency_format'))->nullable(false);
$table->string('date_format')->default(config('app.localization.default_date_format'))->nullable(false);
$table->string('interval_format')->default(config('app.localization.default_interval_format'))->nullable(false);
$table->string('time_format')->default(config('app.localization.default_time_format'))->nullable(false);
});
Schema::table('organizations', function (Blueprint $table): void {
$table->string('number_format')->default(null)->nullable(false)->change();
$table->string('currency_format')->default(null)->nullable(false)->change();
$table->string('date_format')->default(null)->nullable(false)->change();
$table->string('interval_format')->default(null)->nullable(false)->change();
$table->string('time_format')->default(null)->nullable(false)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->dropColumn('number_format');
$table->dropColumn('currency_format');
$table->dropColumn('date_format');
$table->dropColumn('interval_format');
$table->dropColumn('time_format');
});
}
};

View File

@@ -0,0 +1,36 @@
<?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::table('projects', function (Blueprint $table): void {
$table->bigInteger('spent_time')->unsigned()->default(0)->change();
});
Schema::table('tasks', function (Blueprint $table): void {
$table->bigInteger('spent_time')->unsigned()->default(0)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('projects', function (Blueprint $table): void {
$table->integer('spent_time')->unsigned()->default(0)->change();
});
Schema::table('tasks', function (Blueprint $table): void {
$table->integer('spent_time')->unsigned()->default(0)->change();
});
}
};

View File

@@ -109,7 +109,7 @@ services:
- sail - sail
- reverse-proxy - reverse-proxy
playwright: playwright:
image: mcr.microsoft.com/playwright:v1.50.0-jammy image: mcr.microsoft.com/playwright:v1.51.1-jammy
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0'] command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
working_dir: /src working_dir: /src
extra_hosts: extra_hosts:

View File

@@ -36,7 +36,7 @@ test('can register and delete account', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await page.getByRole('button', { name: 'Delete Account' }).click(); await page.getByRole('button', { name: 'Delete Account' }).click();
await page.getByPlaceholder('Password').fill(password); await page.getByPlaceholder('Password').fill(password);
await page.getByRole('button', { name: 'Delete Account' }).nth(1).click(); await page.getByRole('button', { name: 'Delete Account' }).click();
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login'); await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
await page.goto(PLAYWRIGHT_BASE_URL + '/login'); await page.goto(PLAYWRIGHT_BASE_URL + '/login');
await page.getByLabel('Email').fill(email); await page.getByLabel('Email').fill(email);

View File

@@ -16,7 +16,7 @@ test('test that creating and deleting a new client via the modal works', async (
await page.getByRole('button', { name: 'Create Client' }).click(); await page.getByRole('button', { name: 'Create Client' }).click();
await page.getByPlaceholder('Client Name').fill(newClientName); await page.getByPlaceholder('Client Name').fill(newClientName);
await Promise.all([ await Promise.all([
page.getByRole('button', { name: 'Create Client' }).nth(1).click(), page.getByRole('button', { name: 'Create Client' }).click(),
page.waitForResponse( page.waitForResponse(
async (response) => async (response) =>
response.url().includes('/clients') && response.url().includes('/clients') &&
@@ -56,12 +56,12 @@ test('test that archiving and unarchiving clients works', async ({ page }) => {
await page.getByRole('button', { name: 'Create Client' }).click(); await page.getByRole('button', { name: 'Create Client' }).click();
await page.getByLabel('Client Name').fill(newClientName); await page.getByLabel('Client Name').fill(newClientName);
await page.getByRole('button', { name: 'Create Client' }).nth(1).click(); await page.getByRole('button', { name: 'Create Client' }).click();
await expect(page.getByText(newClientName)).toBeVisible(); await expect(page.getByText(newClientName)).toBeVisible();
await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('row').first().getByRole('button').click();
await Promise.all([ await Promise.all([
page.getByRole('button').getByText('Archive').first().click(), page.getByRole('menuitem').getByText('Archive').click(),
expect(page.getByText(newClientName)).not.toBeVisible(), expect(page.getByText(newClientName)).not.toBeVisible(),
]); ]);
await Promise.all([ await Promise.all([
@@ -71,7 +71,7 @@ test('test that archiving and unarchiving clients works', async ({ page }) => {
await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('row').first().getByRole('button').click();
await Promise.all([ await Promise.all([
page.getByRole('button').getByText('Unarchive').first().click(), page.getByRole('menuitem').getByText('Unarchive').click(),
expect(page.getByText(newClientName)).not.toBeVisible(), expect(page.getByText(newClientName)).not.toBeVisible(),
]); ]);
await Promise.all([ await Promise.all([

View File

@@ -82,7 +82,7 @@ test('test that organization billable rate can be updated with all existing time
await goToMembersPage(page); await goToMembersPage(page);
const newBillableRate = Math.round(Math.random() * 10000); const newBillableRate = Math.round(Math.random() * 10000);
await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('button').getByText('Edit').first().click(); await page.getByRole('menuitem').getByText('Edit').click();
await page.getByText('Organization Default Rate').click(); await page.getByText('Organization Default Rate').click();
await page.getByText('Custom Rate').click(); await page.getByText('Custom Rate').click();
await page await page

View File

@@ -17,7 +17,7 @@ test('test that updating project member billable rate works for existing time en
await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName); await page.getByLabel('Project Name').fill(newProjectName);
await page.getByRole('button', { name: 'Create Project' }).nth(1).click(); await page.getByRole('button', { name: 'Create Project' }).click();
await expect(page.getByText(newProjectName)).toBeVisible(); await expect(page.getByText(newProjectName)).toBeVisible();
await page.getByText(newProjectName).click(); await page.getByText(newProjectName).click();
@@ -35,8 +35,7 @@ test('test that updating project member billable rate works for existing time en
.getByRole('button') .getByRole('button')
.click(); .click();
await page await page
.getByRole('button', { name: 'Edit Project Member' }) .getByRole('menuitem', { name: 'Edit Project Member' })
.first()
.click(); .click();
await page.getByLabel('Billable Rate').fill(newBillableRate.toString()); await page.getByLabel('Billable Rate').fill(newBillableRate.toString());
await page.getByRole('button', { name: 'Update Project Member' }).click(); await page.getByRole('button', { name: 'Update Project Member' }).click();

View File

@@ -17,7 +17,7 @@ test('test that creating and deleting a new project via the modal works', async
await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName); await page.getByLabel('Project Name').fill(newProjectName);
await Promise.all([ await Promise.all([
page.getByRole('button', { name: 'Create Project' }).nth(1).click(), page.getByRole('button', { name: 'Create Project' }).click(),
page.waitForResponse( page.waitForResponse(
async (response) => async (response) =>
response.url().includes('/projects') && response.url().includes('/projects') &&
@@ -62,12 +62,12 @@ test('test that archiving and unarchiving projects works', async ({ page }) => {
await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName); await page.getByLabel('Project Name').fill(newProjectName);
await page.getByRole('button', { name: 'Create Project' }).nth(1).click(); await page.getByRole('button', { name: 'Create Project' }).click();
await expect(page.getByText(newProjectName)).toBeVisible(); await expect(page.getByText(newProjectName)).toBeVisible();
await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('row').first().getByRole('button').click();
await Promise.all([ await Promise.all([
page.getByRole('button').getByText('Archive').first().click(), page.getByRole('menuitem').getByText('Archive').first().click(),
expect(page.getByText(newProjectName)).not.toBeVisible(), expect(page.getByText(newProjectName)).not.toBeVisible(),
]); ]);
await Promise.all([ await Promise.all([
@@ -77,7 +77,7 @@ test('test that archiving and unarchiving projects works', async ({ page }) => {
await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('row').first().getByRole('button').click();
await Promise.all([ await Promise.all([
page.getByRole('button').getByText('Unarchive').first().click(), page.getByRole('menuitem').getByText('Unarchive').first().click(),
expect(page.getByText(newProjectName)).not.toBeVisible(), expect(page.getByText(newProjectName)).not.toBeVisible(),
]); ]);
await Promise.all([ await Promise.all([
@@ -96,11 +96,11 @@ test('test that updating billable rate works with existing time entries', async
await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName); await page.getByLabel('Project Name').fill(newProjectName);
await page.getByRole('button', { name: 'Create Project' }).nth(1).click(); await page.getByRole('button', { name: 'Create Project' }).click();
await expect(page.getByText(newProjectName)).toBeVisible(); await expect(page.getByText(newProjectName)).toBeVisible();
await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('button').getByText('Edit').first().click(), await page.getByRole('menuitem').getByText('Edit').first().click();
await page.getByText('Non-Billable').click(); await page.getByText('Non-Billable').click();
await page.getByText('Custom Rate').click(); await page.getByText('Custom Rate').click();
await page await page

View File

@@ -15,7 +15,7 @@ test('test that creating and deleting a new client via the modal works', async (
await page.getByRole('button', { name: 'Create Tag' }).click(); await page.getByRole('button', { name: 'Create Tag' }).click();
await page.getByPlaceholder('Tag Name').fill(newTagName); await page.getByPlaceholder('Tag Name').fill(newTagName);
await Promise.all([ await Promise.all([
page.getByRole('button', { name: 'Create Tag' }).nth(1).click(), page.getByRole('button', { name: 'Create Tag' }).click(),
page.waitForResponse( page.waitForResponse(
async (response) => async (response) =>
response.url().includes('/tags') && response.url().includes('/tags') &&

View File

@@ -16,7 +16,7 @@ test('test that creating and deleting a new tag in a new project works', async (
await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName); await page.getByLabel('Project Name').fill(newProjectName);
await Promise.all([ await Promise.all([
page.getByRole('button', { name: 'Create Project' }).nth(1).click(), page.getByRole('button', { name: 'Create Project' }).click(),
page.waitForResponse( page.waitForResponse(
async (response) => async (response) =>
response.url().includes('/projects') && response.url().includes('/projects') &&
@@ -41,7 +41,7 @@ test('test that creating and deleting a new tag in a new project works', async (
await page.getByPlaceholder('Task Name').fill(newTaskName); await page.getByPlaceholder('Task Name').fill(newTaskName);
await Promise.all([ await Promise.all([
page.getByRole('button', { name: 'Create Task' }).nth(1).click(), page.getByRole('button', { name: 'Create Task' }).click(),
page.waitForResponse( page.waitForResponse(
async (response) => async (response) =>
response.url().includes('/tasks') && response.url().includes('/tasks') &&
@@ -107,20 +107,20 @@ test('test that archiving and unarchiving tasks works', async ({ page }) => {
await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName); await page.getByLabel('Project Name').fill(newProjectName);
await page.getByRole('button', { name: 'Create Project' }).nth(1).click(); await page.getByRole('button', { name: 'Create Project' }).click();
await expect(page.getByText(newProjectName)).toBeVisible(); await expect(page.getByText(newProjectName)).toBeVisible();
await page.getByText(newProjectName).click(); await page.getByText(newProjectName).click();
await page.getByRole('button', { name: 'Create Task' }).click(); await page.getByRole('button', { name: 'Create Task' }).click();
await page.getByPlaceholder('Task Name').fill(newTaskName); await page.getByPlaceholder('Task Name').fill(newTaskName);
await page.getByRole('button', { name: 'Create Task' }).nth(1).click(); await page.getByRole('button', { name: 'Create Task' }).click();
await expect(page.getByRole('table')).toContainText(newTaskName); await expect(page.getByRole('table')).toContainText(newTaskName);
await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('row').first().getByRole('button').click();
await Promise.all([ await Promise.all([
page.getByRole('button').getByText('Mark as done').first().click(), page.getByRole('menuitem').getByText('Mark as done').first().click(),
expect(page.getByText(newTaskName)).not.toBeVisible(), expect(page.getByText(newTaskName)).not.toBeVisible(),
]); ]);
await Promise.all([ await Promise.all([
@@ -130,7 +130,7 @@ test('test that archiving and unarchiving tasks works', async ({ page }) => {
await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('row').first().getByRole('button').click();
await Promise.all([ await Promise.all([
page.getByRole('button').getByText('Mark as active').first().click(), page.getByRole('menuitem').getByText('Mark as active').first().click(),
expect(page.getByText(newTaskName)).not.toBeVisible(), expect(page.getByText(newTaskName)).not.toBeVisible(),
]); ]);
await Promise.all([ await Promise.all([

View File

@@ -29,7 +29,12 @@ export default typescriptEslint.config(
"vue/multi-word-component-names": "off", "vue/multi-word-component-names": "off",
"@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "off",
"unused-imports/no-unused-imports": "error", "unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": "error", "unused-imports/no-unused-vars": ["error", {
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_",
}],
}, },
}, },
eslintConfigPrettier eslintConfigPrettier

View File

@@ -2,6 +2,11 @@
declare(strict_types=1); declare(strict_types=1);
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Enums\Weekday; use App\Enums\Weekday;
return [ return [
@@ -16,4 +21,42 @@ return [
Weekday::Sunday->value => 'Sunday', Weekday::Sunday->value => 'Sunday',
], ],
'number_format' => [
NumberFormat::ThousandsPointDecimalComma->value => '1.111,11',
NumberFormat::ThousandsCommaDecimalPoint->value => '1,111.11',
NumberFormat::ThousandsSpaceDecimalComma->value => '1 111,11',
NumberFormat::ThousandsSpaceDecimalPoint->value => '1 111.11',
NumberFormat::ThousandsApostropheDecimalPoint->value => '1\'111.11',
],
'date_format' => [
DateFormat::PointSeperatedDMYYYY->value => 'D.M.YYYY',
DateFormat::SlashSeperatedMMDDYYYY->value => 'MM/DD/YYYY',
DateFormat::SlashSeperatedDDMMYYYY->value => 'DD/MM/YYYY',
DateFormat::HyphenSeperatedDDMMYYY->value => 'DD-MM-YYYY',
DateFormat::HyphenSeperatedMMDDDYYYY->value => 'MM-DD-YYYY',
DateFormat::HyphenSeperatedYYYYMMDD->value => 'YYYY-MM-DD',
],
'time_format' => [
TimeFormat::TwelveHours->value => '12-hour clock',
TimeFormat::TwentyFourHours->value => '24-hour clock',
],
'interval_format' => [
IntervalFormat::Decimal->value => 'Decimal',
IntervalFormat::HoursMinutes->value => '12h 3m',
IntervalFormat::HoursMinutesColonSeperated->value => '12:03',
IntervalFormat::HoursMinutesSecondsColonSeperated->value => '12:03:45',
],
'currency_format' => [
CurrencyFormat::ISOCodeBeforeWithSpace->value => 'EUR 111',
CurrencyFormat::ISOCodeAfterWithSpace->value => '111 EUR',
CurrencyFormat::SymbolBefore->value => '€111',
CurrencyFormat::SymbolAfter->value => '111€',
CurrencyFormat::SymbolBeforeWithSpace->value => '€ 111',
CurrencyFormat::SymbolAfterWithSpace->value => '111 €',
],
]; ];

View File

@@ -202,7 +202,7 @@ return [
'currency' => 'The :attribute field must be a valid currency code (ISO 4217).', 'currency' => 'The :attribute field must be a valid currency code (ISO 4217).',
'organization' => 'The :attribute does not exist.', 'organization' => 'The :attribute does not exist.',
'task_belongs_to_project' => 'The :attribute is not part of the given project.', 'task_belongs_to_project' => 'The :attribute is not part of the given project.',
'project_name_already_exists' => 'A project with the same name already exists in the organization.', 'project_name_already_exists' => 'A project with the same name and client already exists in the organization.',
'tag_name_already_exists' => 'A tag with the same name already exists in the organization.', 'tag_name_already_exists' => 'A tag with the same name already exists in the organization.',
'client_name_already_exists' => 'A client with the same name already exists in the organization.', 'client_name_already_exists' => 'A client with the same name already exists in the organization.',
'task_name_already_exists' => 'A task with the same name already exists in the project.', 'task_name_already_exists' => 'A task with the same name already exists in the project.',

File diff suppressed because one or more lines are too long

1500
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,19 +40,26 @@
"@heroicons/vue": "^2.1.1", "@heroicons/vue": "^2.1.1",
"@rushstack/eslint-patch": "^1.10.5", "@rushstack/eslint-patch": "^1.10.5",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tanstack/vue-form": "^1.3.1",
"@tanstack/vue-query": "^5.56.2", "@tanstack/vue-query": "^5.56.2",
"@tanstack/vue-query-devtools": "^5.58.0", "@tanstack/vue-query-devtools": "^5.58.0",
"@tanstack/vue-table": "^8.21.2",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.3.0", "@vue/eslint-config-typescript": "^14.3.0",
"@vueuse/core": "^12.5.0", "@vueuse/core": "^12.5.0",
"@vueuse/integrations": "^12.5.0", "@vueuse/integrations": "^12.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"echarts": "^5.5.0", "echarts": "^5.5.0",
"focus-trap": "^7.6.0", "focus-trap": "^7.6.0",
"lucide-vue-next": "^0.487.0",
"parse-duration": "^2.0.1", "parse-duration": "^2.0.1",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"radix-vue": "^1.9.6", "radix-vue": "^1.9.6",
"tailwind-merge": "^2.2.1", "reka-ui": "^2.2.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"vue-echarts": "^7.0.3" "vue-echarts": "^7.0.3"
}, },
"overrides": { "overrides": {

View File

@@ -2,11 +2,11 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
:root.dark { :root.dark {
--color-bg-primary: #0f1011; --color-bg-primary: #101012;
--color-bg-secondary: #17181a; --color-bg-secondary: #17181B;
--color-bg-tertiary: #2A2C32; --color-bg-tertiary: #2A2C32;
--color-bg-quaternary: #141518; --color-bg-quaternary: #141518;
--color-bg-background: #080808; --color-bg-background: #090909;
--color-text-primary: #ffffff; --color-text-primary: #ffffff;
--color-text-secondary: #e3e4e6; --color-text-secondary: #e3e4e6;
--color-text-tertiary: #969799; --color-text-tertiary: #969799;
@@ -22,10 +22,10 @@
--theme-color-menu-active: var(--color-bg-secondary); --theme-color-menu-active: var(--color-bg-secondary);
--theme-color-card-background: var(--color-bg-secondary); --theme-color-card-background: var(--color-bg-secondary);
--theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 30%); --theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%);
--theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%); --theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%);
--theme-color-muted-text: var(--color-text-secondary); --theme-color-card-background-active: var(--color-bg-tertiary);
--theme-color-row-background: var(--color-bg-primary); --theme-color-row-background: var(--color-bg-primary);
--theme-color-row-heading-background: var(--theme-color-card-background); --theme-color-row-heading-background: var(--theme-color-card-background);
@@ -53,7 +53,7 @@
--color-bg-background: #ffffff; --color-bg-background: #ffffff;
--color-text-primary: #18181b; --color-text-primary: #18181b;
--color-text-secondary: #3f3f46; --color-text-secondary: #3f3f46;
--color-text-tertiary: #71717a; --color-text-tertiary: #57575C;
--color-text-quaternary: #a1a1aa; --color-text-quaternary: #a1a1aa;
--color-border-primary: #e7e7e7; --color-border-primary: #e7e7e7;
--color-border-secondary: #e5e5e5; --color-border-secondary: #e5e5e5;
@@ -70,8 +70,6 @@
--theme-shadow-card: 0 1px 2px 0 rgb(0 0 0 / 0.05); --theme-shadow-card: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); --theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--theme-color-muted-text: var(--color-text-secondary);
--theme-color-row-background: var(--theme-color-card-background); --theme-color-row-background: var(--theme-color-card-background);
--theme-color-row-heading-background: var(--color-bg-secondary); --theme-color-row-heading-background: var(--color-bg-secondary);
--theme-color-row-heading-border: var(--color-border-tertiary); --theme-color-row-heading-border: var(--color-border-tertiary);
@@ -119,6 +117,7 @@
--theme-button-secondary-background: var(--theme-color-card-background); --theme-button-secondary-background: var(--theme-color-card-background);
--theme-button-secondary-background-active: var(--theme-color-card-background-active); --theme-button-secondary-background-active: var(--theme-color-card-background-active);
--popover-border: var(--color-border-secondary);
} }
* { * {
@@ -180,3 +179,68 @@ body {
src: url('/fonts/Outfit-ExtraBold.ttf'); src: url('/fonts/Outfit-ExtraBold.ttf');
font-weight: 800; font-weight: 800;
} }
@layer base {
:root {
--background: var(--color-bg-background);
--foreground: var(--color-text-primary);
--card: var(--theme-color-card-background);
--card-foreground: var(--color-text-primary);
--popover: var(--theme-color-card-background);
--popover-foreground: var(--color-text-primary);
--primary: var(--theme-color-button-primary-background);
--primary-foreground: var(--theme-color-button-primary-text);
--secondary: var(--color-bg-secondary);
--secondary-foreground: var(--color-text-primary);
--muted: var(--color-bg-tertiary);
--muted-foreground: var(--color-text-tertiary);
--accent: var(--theme-color-button-primary-background);
--accent-foreground: var(--theme-color-button-primary-text);
--destructive: 0 84.2% 60.2%;
--destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary);
--input: var(--theme-color-input-background);
--ring: var(--theme-color-ring);
--chart-1: var(--color-accent-400);
--chart-2: var(--color-accent-500);
--chart-3: var(--color-accent-600);
--chart-4: var(--color-accent-700);
--chart-5: var(--color-accent-800);
--radius: 0.5rem;
}
.dark {
--background: var(--color-bg-background);
--foreground: var(--color-text-primary);
--card: var(--theme-color-card-background);
--card-foreground: var(--color-text-primary);
--popover: var(--theme-color-card-background);
--popover-foreground: var(--color-text-primary);
--primary: var(--theme-color-button-primary-background);
--primary-foreground: var(--theme-color-button-primary-text);
--secondary: var(--color-bg-secondary);
--secondary-foreground: var(--color-text-primary);
--muted: var(--color-bg-tertiary);
--muted-foreground: var(--color-text-tertiary);
--accent: var(--theme-color-button-primary-background);
--accent-foreground: var(--theme-color-button-primary-text);
--destructive: 0 62.8% 30.6%;
--destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary);
--input: var(--theme-color-input-background);
--ring: var(--theme-color-ring);
--chart-1: var(--color-accent-200);
--chart-2: var(--color-accent-300);
--chart-3: var(--color-accent-400);
--chart-4: var(--color-accent-500);
--chart-5: var(--color-accent-600);
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -10,7 +10,7 @@ defineProps({
leave-active-class="transition ease-in duration-1000" leave-active-class="transition ease-in duration-1000"
leave-from-class="opacity-100" leave-from-class="opacity-100"
leave-to-class="opacity-0"> leave-to-class="opacity-0">
<div v-show="on" class="text-sm text-muted"> <div v-show="on" class="text-sm text-text-secondary">
<slot /> <slot />
</div> </div>
</transition> </transition>

View File

@@ -1,13 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, watch } from "vue"; import { onMounted } from "vue";
import { theme } from "@/utils/theme.js"; import { useTheme } from "@/utils/theme.js";
onMounted(async () => { onMounted(async () => {
document.documentElement.classList.add(theme.value); useTheme()
watch(theme, (newTheme, oldTheme) => {
document.documentElement.classList.remove(oldTheme);
document.documentElement.classList.add(newTheme);
});
}); });
</script> </script>

View File

@@ -6,7 +6,12 @@ import {
} from '@heroicons/vue/20/solid'; } from '@heroicons/vue/20/solid';
import type { Client } from '@/packages/api/src'; import type { Client } from '@/packages/api/src';
import { canDeleteClients, canUpdateClients } from '@/utils/permissions'; import { canDeleteClients, canUpdateClients } from '@/utils/permissions';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue'; import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
const emit = defineEmits<{ const emit = defineEmits<{
delete: []; delete: [];
@@ -19,37 +24,54 @@ const props = defineProps<{
</script> </script>
<template> <template>
<MoreOptionsDropdown :label="'Actions for Client ' + props.client.name"> <DropdownMenu>
<div class="min-w-[150px]"> <DropdownMenuTrigger as-child>
<button <button
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-text-secondary"
:aria-label="'Actions for Client ' + props.client.name">
<svg
class="h-8 w-8 p-1 rounded-full"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-[150px]" align="end">
<DropdownMenuItem
v-if="canUpdateClients()" v-if="canUpdateClients()"
:aria-label="'Edit Client ' + props.client.name" :aria-label="'Edit Client ' + props.client.name"
data-testid="client_edit" 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-text-primary 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 cursor-pointer"
@click="emit('edit')"> @click="emit('edit')">
<PencilSquareIcon <PencilSquareIcon class="w-5 text-icon-active" />
class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span> <span>Edit</span>
</button> </DropdownMenuItem>
<button <DropdownMenuItem
v-if="canUpdateClients()" v-if="canUpdateClients()"
:aria-label="'Archive Client ' + props.client.name" :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-text-primary 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 cursor-pointer"
@click.prevent="emit('archive')"> @click.prevent="emit('archive')">
<ArchiveBoxIcon class="w-5 text-icon-active"></ArchiveBoxIcon> <ArchiveBoxIcon class="w-5 text-icon-active" />
<span>{{ client.is_archived ? 'Unarchive' : 'Archive' }}</span> <span>{{ client.is_archived ? 'Unarchive' : 'Archive' }}</span>
</button> </DropdownMenuItem>
<button <DropdownMenuItem
v-if="canDeleteClients()" v-if="canDeleteClients()"
:aria-label="'Delete Client ' + props.client.name" :aria-label="'Delete Client ' + props.client.name"
data-testid="client_delete" 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-text-primary 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 cursor-pointer text-destructive focus:text-destructive"
@click="emit('delete')"> @click="emit('delete')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon> <TrashIcon class="w-5" />
<span>Delete</span> <span>Delete</span>
</button> </DropdownMenuItem>
</div> </DropdownMenuContent>
</MoreOptionsDropdown> </DropdownMenu>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@@ -48,10 +48,10 @@ const showEditModal = ref(false);
</div> </div>
<div <div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"> class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span class="text-muted"> {{ projectCount }} Projects </span> <span class="text-text-secondary"> {{ projectCount }} Projects </span>
</div> </div>
<div <div
class="whitespace-nowrap px-3 py-4 text-sm text-muted flex space-x-1 items-center font-medium"> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
<CheckCircleIcon class="w-5"></CheckCircleIcon> <CheckCircleIcon class="w-5"></CheckCircleIcon>
<span>Active</span> <span>Active</span>
</div> </div>

View File

@@ -1,6 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { TrashIcon, ArrowPathIcon } from '@heroicons/vue/20/solid'; import { TrashIcon, ArrowPathIcon } from '@heroicons/vue/20/solid';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue'; import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
const emit = defineEmits<{ const emit = defineEmits<{
delete: []; delete: [];
resend: []; resend: [];
@@ -8,22 +14,42 @@ const emit = defineEmits<{
</script> </script>
<template> <template>
<MoreOptionsDropdown label="Actions for the invitation"> <DropdownMenu>
<button <DropdownMenuTrigger as-child>
data-testid="invitation_delete" <button
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out" class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-text-secondary"
@click="emit('resend')"> aria-label="Actions for the invitation">
<ArrowPathIcon class="w-5 text-icon-active"></ArrowPathIcon> <svg
<span>Resend Invitation</span> class="h-8 w-8 p-1 rounded-full"
</button> viewBox="0 0 24 24"
<button xmlns="http://www.w3.org/2000/svg">
data-testid="invitation_delete" <path
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out" fill="none"
@click="emit('delete')"> stroke="currentColor"
<TrashIcon class="w-5 text-icon-active"></TrashIcon> stroke-linecap="round"
<span>Delete</span> stroke-linejoin="round"
</button> stroke-width="1.5"
</MoreOptionsDropdown> d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-[150px]" align="end">
<DropdownMenuItem
data-testid="invitation_delete"
class="flex items-center space-x-3 cursor-pointer"
@click="emit('resend')">
<ArrowPathIcon class="w-5 text-icon-active" />
<span>Resend Invitation</span>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="invitation_delete"
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
@click="emit('delete')">
<TrashIcon class="w-5" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@@ -57,10 +57,10 @@ async function resendInvitation() {
<template> <template>
<TableRow> <TableRow>
<div <div
class="whitespace-nowrap px-3 py-4 text-sm text-muted pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
{{ invitation.email }} {{ invitation.email }}
</div> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted"> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ capitalizeFirstLetter(invitation.role) }} {{ capitalizeFirstLetter(invitation.role) }}
</div> </div>
<div <div

View File

@@ -49,7 +49,7 @@ function getNameForKey(key: BillableKey | undefined) {
<span> <span>
{{ getNameForKey(model) }} {{ getNameForKey(model) }}
</span> </span>
<ChevronDownIcon class="text-muted w-5"></ChevronDownIcon> <ChevronDownIcon class="text-text-secondary w-5"></ChevronDownIcon>
</Badge> </Badge>
</template> </template>
</SelectDropdown> </SelectDropdown>

View File

@@ -64,12 +64,12 @@ const currentValue = computed(() => {
<Badge <Badge
tag="button" tag="button"
class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary bg-input-background font-normal cursor py-1.5"> class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary bg-input-background font-normal cursor py-1.5">
<UserIcon class="relative z-10 w-4 text-muted"></UserIcon> <UserIcon class="relative z-10 w-4 text-text-secondary"></UserIcon>
<div v-if="currentValue" class="flex-1 truncate"> <div v-if="currentValue" class="flex-1 truncate">
{{ currentValue }} {{ currentValue }}
</div> </div>
<div v-else class="flex-1">Select a member...</div> <div v-else class="flex-1">Select a member...</div>
<ChevronDownIcon class="w-4 text-muted"></ChevronDownIcon> <ChevronDownIcon class="w-4 text-text-secondary"></ChevronDownIcon>
</Badge> </Badge>
</template> </template>
</SelectDropdown> </SelectDropdown>

View File

@@ -49,7 +49,10 @@ const showOwnershipTransferConfirmModal = ref(false);
function saveWithChecks() { function saveWithChecks() {
if (memberBody.value.billable_rate !== props.member.billable_rate) { if (memberBody.value.billable_rate !== props.member.billable_rate) {
showBillableRateModal.value = true; // make sure that the alert modal is not immediately submitted when user presses enter
setTimeout(() => {
showBillableRateModal.value = true;
}, 0);
show.value = false; show.value = false;
} else if ( } else if (
memberBody.value.role === 'owner' && memberBody.value.role === 'owner' &&

View File

@@ -194,7 +194,7 @@ useFocus(clientNameInput, { initialValue: true });
</div> </div>
<!-- Role Description --> <!-- Role Description -->
<div class="mt-2 text-xs text-muted text-start"> <div class="mt-2 text-xs text-text-secondary text-start">
{{ role.description }} {{ role.description }}
</div> </div>
</div> </div>

View File

@@ -76,7 +76,7 @@ async function submit() {
<div class="py-5 flex flex-col md:flex-row gap-6 items-center"> <div class="py-5 flex flex-col md:flex-row gap-6 items-center">
<div class="flex-1"> <div class="flex-1">
<Badge class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5"> <Badge class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5">
<UserIcon class="relative z-10 w-4 text-muted"></UserIcon> <UserIcon class="relative z-10 w-4 text-text-secondary"></UserIcon>
<div class="flex-1 font-medium truncate"> <div class="flex-1 font-medium truncate">
{{ member.name }} {{ member.name }}
</div> </div>

View File

@@ -2,7 +2,12 @@
import { TrashIcon, UserCircleIcon, PencilSquareIcon, ArrowDownOnSquareStackIcon } from '@heroicons/vue/20/solid'; import { TrashIcon, UserCircleIcon, PencilSquareIcon, ArrowDownOnSquareStackIcon } from '@heroicons/vue/20/solid';
import type { Member } from '@/packages/api/src'; import type { Member } from '@/packages/api/src';
import {canDeleteMembers, canMakeMembersPlaceholders, canMergeMembers, canUpdateMembers} from '@/utils/permissions'; import {canDeleteMembers, canMakeMembersPlaceholders, canMergeMembers, canUpdateMembers} from '@/utils/permissions';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue'; import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
const emit = defineEmits<{ const emit = defineEmits<{
delete: []; delete: [];
@@ -13,51 +18,65 @@ const emit = defineEmits<{
const props = defineProps<{ const props = defineProps<{
member: Member; member: Member;
}>(); }>();
</script> </script>
<template> <template>
<MoreOptionsDropdown <DropdownMenu v-if="canUpdateMembers() || canDeleteMembers()">
v-if="canUpdateMembers() || canDeleteMembers()" <DropdownMenuTrigger as-child>
:label="'Actions for Member ' + props.member.name">
<div class="min-w-[150px]">
<button <button
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-text-secondary"
:aria-label="'Actions for Member ' + props.member.name">
<svg
class="h-8 w-8 p-1 rounded-full"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-[150px]" align="end">
<DropdownMenuItem
v-if="canUpdateMembers()" v-if="canUpdateMembers()"
:aria-label="'Edit Member ' + props.member.name" :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-text-primary 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 cursor-pointer"
@click="emit('edit')"> @click="emit('edit')">
<PencilSquareIcon <PencilSquareIcon class="w-5 text-icon-active" />
class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span> <span>Edit</span>
</button> </DropdownMenuItem>
<button <DropdownMenuItem
v-if="canDeleteMembers()" v-if="canDeleteMembers()"
:aria-label="'Delete Member ' + props.member.name" :aria-label="'Delete Member ' + props.member.name"
data-testid="member_delete" 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-text-primary 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 cursor-pointer text-destructive focus:text-destructive"
@click="emit('delete')"> @click="emit('delete')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon> <TrashIcon class="w-5" />
<span>Delete</span> <span>Delete</span>
</button> </DropdownMenuItem>
<button <DropdownMenuItem
v-if="props.member.role === 'placeholder' && canMergeMembers()" v-if="props.member.role === 'placeholder' && canMergeMembers()"
:aria-label="'Merge Member ' + props.member.name" :aria-label="'Merge Member ' + props.member.name"
data-testid="member_merge" data-testid="member_merge"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary 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 cursor-pointer"
@click="emit('merge')"> @click="emit('merge')">
<ArrowDownOnSquareStackIcon class="w-5 text-icon-active"></ArrowDownOnSquareStackIcon> <ArrowDownOnSquareStackIcon class="w-5 text-icon-active" />
<span>Merge</span> <span>Merge</span>
</button> </DropdownMenuItem>
<button <DropdownMenuItem
v-if="props.member.role !== 'placeholder' && canMakeMembersPlaceholders()" v-if="props.member.role !== 'placeholder' && canMakeMembersPlaceholders()"
:aria-label="'Make Member ' + props.member.name + ' a placeholder'" :aria-label="'Make Member ' + props.member.name + ' a placeholder'"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary 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 cursor-pointer"
@click="emit('makePlaceholder')"> @click="emit('makePlaceholder')">
<UserCircleIcon class="w-5 text-icon-active"></UserCircleIcon> <UserCircleIcon class="w-5 text-icon-active" />
<span>Deactivate</span> <span>Deactivate</span>
</button> </DropdownMenuItem>
</div> </DropdownMenuContent>
</MoreOptionsDropdown> </DropdownMenu>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@@ -43,7 +43,7 @@ function getNameForKey(key: string | undefined) {
<span> <span>
{{ getNameForKey(model) }} {{ getNameForKey(model) }}
</span> </span>
<ChevronDownIcon class="text-muted w-5"></ChevronDownIcon> <ChevronDownIcon class="text-text-secondary w-5"></ChevronDownIcon>
</Badge> </Badge>
</template> </template>
</SelectDropdown> </SelectDropdown>

View File

@@ -64,13 +64,13 @@ const userHasValidMailAddress = computed(() => {
{{ member.name }} {{ member.name }}
</span> </span>
</div> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted"> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ member.email }} {{ member.email }}
</div> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted"> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ capitalizeFirstLetter(member.role) }} {{ capitalizeFirstLetter(member.role) }}
</div> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted"> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ {{
member.billable_rate member.billable_rate
? formatCents( ? formatCents(
@@ -81,7 +81,7 @@ const userHasValidMailAddress = computed(() => {
}} }}
</div> </div>
<div <div
class="whitespace-nowrap px-3 py-4 text-sm text-muted flex space-x-1 items-center font-medium"> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
<CheckCircleIcon <CheckCircleIcon
v-if="member.is_placeholder === false" v-if="member.is_placeholder === false"
class="w-5"></CheckCircleIcon> class="w-5"></CheckCircleIcon>

View File

@@ -27,14 +27,14 @@
<p class="text-sm font-medium text-text-primary"> <p class="text-sm font-medium text-text-primary">
{{ title }} {{ title }}
</p> </p>
<p v-if="message" class="mt-1 text-sm text-muted"> <p v-if="message" class="mt-1 text-sm text-text-secondary">
{{ message }} {{ message }}
</p> </p>
</div> </div>
<div class="ml-4 flex flex-shrink-0"> <div class="ml-4 flex flex-shrink-0">
<button <button
type="button" type="button"
class="inline-flex rounded-md bg-card-background text-muted hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" class="inline-flex rounded-md bg-card-background text-text-secondary hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
@click="show = false"> @click="show = false">
<span class="sr-only">Close</span> <span class="sr-only">Close</span>
<XMarkIcon class="h-5 w-5" aria-hidden="true" /> <XMarkIcon class="h-5 w-5" aria-hidden="true" />

View File

@@ -109,7 +109,7 @@ function updateValue(project: Project) {
</script> </script>
<template> <template>
<Dropdown v-model="open" align="bottom-start" width="60"> <Dropdown v-model="open" align="start" width="60">
<template #trigger> <template #trigger>
<ProjectBadge <ProjectBadge
ref="projectDropdownTrigger" ref="projectDropdownTrigger"

View File

@@ -48,7 +48,10 @@ const project = ref<CreateProjectBody>({
async function submit() { async function submit() {
if (props.originalProject.billable_rate !== project.value.billable_rate) { if (props.originalProject.billable_rate !== project.value.billable_rate) {
showBillableRateModal.value = true; //
setTimeout(() => {
showBillableRateModal.value = true;
}, 0);
return; return;
} }
await updateProject(props.originalProject.id, project.value); await updateProject(props.originalProject.id, project.value);

View File

@@ -6,7 +6,13 @@ import {
} from '@heroicons/vue/20/solid'; } from '@heroicons/vue/20/solid';
import type { Project } from '@/packages/api/src'; import type { Project } from '@/packages/api/src';
import { canDeleteProjects, canUpdateProjects } from '@/utils/permissions'; import { canDeleteProjects, canUpdateProjects } from '@/utils/permissions';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue'; import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
const emit = defineEmits<{ const emit = defineEmits<{
delete: []; delete: [];
edit: []; edit: [];
@@ -18,37 +24,54 @@ const props = defineProps<{
</script> </script>
<template> <template>
<MoreOptionsDropdown :label="'Actions for Project ' + props.project.name"> <DropdownMenu>
<div class="min-w-[150px]"> <DropdownMenuTrigger as-child>
<button <button
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-text-secondary"
:aria-label="'Actions for Project ' + props.project.name">
<svg
class="h-8 w-8 p-1 rounded-full"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-[150px]" align="end">
<DropdownMenuItem
v-if="canUpdateProjects()" v-if="canUpdateProjects()"
:aria-label="'Edit Project ' + props.project.name" :aria-label="'Edit Project ' + props.project.name"
data-testid="project_edit" 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-text-primary 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 cursor-pointer"
@click.prevent="emit('edit')"> @click.prevent="emit('edit')">
<PencilSquareIcon <PencilSquareIcon class="w-5 text-icon-active" />
class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span> <span>Edit</span>
</button> </DropdownMenuItem>
<button <DropdownMenuItem
v-if="canUpdateProjects()" v-if="canUpdateProjects()"
:aria-label="'Archive Project ' + props.project.name" :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-text-primary 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 cursor-pointer"
@click.prevent="emit('archive')"> @click.prevent="emit('archive')">
<ArchiveBoxIcon class="w-5 text-icon-active"></ArchiveBoxIcon> <ArchiveBoxIcon class="w-5 text-icon-active" />
<span>{{ project.is_archived ? 'Unarchive' : 'Archive' }}</span> <span>{{ project.is_archived ? 'Unarchive' : 'Archive' }}</span>
</button> </DropdownMenuItem>
<button <DropdownMenuItem
v-if="canDeleteProjects()" v-if="canDeleteProjects()"
:aria-label="'Delete Project ' + props.project.name" :aria-label="'Delete Project ' + props.project.name"
data-testid="project_delete" data-testid="project_delete"
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-text-primary 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 cursor-pointer text-destructive focus:text-destructive"
@click.prevent="emit('delete')"> @click.prevent="emit('delete')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon> <TrashIcon class="w-5" />
<span>Delete</span> <span>Delete</span>
</button> </DropdownMenuItem>
</div> </DropdownMenuContent>
</MoreOptionsDropdown> </DropdownMenu>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@@ -79,9 +79,9 @@ const showEditProjectModal = ref(false);
<span class="overflow-ellipsis overflow-hidden"> <span class="overflow-ellipsis overflow-hidden">
{{ project.name }} {{ project.name }}
</span> </span>
<span class="text-muted"> {{ projectTasksCount }} Tasks </span> <span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
</div> </div>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-muted"> <div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary">
<div <div
v-if="project.client_id" v-if="project.client_id"
class="overflow-ellipsis overflow-hidden"> class="overflow-ellipsis overflow-hidden">
@@ -89,14 +89,14 @@ const showEditProjectModal = ref(false);
</div> </div>
<div v-else>No client</div> <div v-else>No client</div>
</div> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted"> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<div v-if="project.spent_time"> <div v-if="project.spent_time">
{{ formatHumanReadableDuration(project.spent_time) }} {{ formatHumanReadableDuration(project.spent_time) }}
</div> </div>
<div v-else>--</div> <div v-else>--</div>
</div> </div>
<div <div
class="whitespace-nowrap px-3 flex items-center text-sm text-muted"> class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
<UpgradeBadge <UpgradeBadge
v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge> v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
<EstimatedTimeProgress <EstimatedTimeProgress
@@ -107,11 +107,11 @@ const showEditProjectModal = ref(false);
</div> </div>
<div <div
v-if="showBillableRate" v-if="showBillableRate"
class="whitespace-nowrap px-3 py-4 text-sm text-muted"> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ billableRateInfo }} {{ billableRateInfo }}
</div> </div>
<div <div
class="whitespace-nowrap px-3 py-4 text-sm text-muted flex space-x-1 items-center font-medium"> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
<CheckCircleIcon class="w-5"></CheckCircleIcon> <CheckCircleIcon class="w-5"></CheckCircleIcon>
<span>Active</span> <span>Active</span>
</div> </div>

View File

@@ -33,7 +33,10 @@ async function submit() {
props.projectMember.billable_rate !== props.projectMember.billable_rate !==
projectMemberBody.value.billable_rate projectMemberBody.value.billable_rate
) { ) {
showBillableRateModal.value = true; // make sure that the alert modal is not immediately submitted when user presses enter
setTimeout(() => {
showBillableRateModal.value = true;
}, 0);
return; return;
} }
await updateProjectMember(props.projectMember.id, projectMemberBody.value); await updateProjectMember(props.projectMember.id, projectMemberBody.value);
@@ -83,7 +86,7 @@ useFocus(projectNameInput, { initialValue: true });
<div class="grid grid-cols-3 items-center space-x-4"> <div class="grid grid-cols-3 items-center space-x-4">
<div <div
class="col-span-3 sm:col-span-2 space-x-2 flex items-center"> class="col-span-3 sm:col-span-2 space-x-2 flex items-center">
<UserIcon class="w-4 text-muted"></UserIcon> <UserIcon class="w-4 text-text-secondary"></UserIcon>
<span>{{ props.name }}</span> <span>{{ props.name }}</span>
</div> </div>
<div class="col-span-3 sm:col-span-1 flex-1"> <div class="col-span-3 sm:col-span-1 flex-1">

View File

@@ -4,7 +4,12 @@ import type { ProjectMember } from '@/packages/api/src';
import { useMembersStore } from '@/utils/useMembers'; import { useMembersStore } from '@/utils/useMembers';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed } from 'vue'; import { computed } from 'vue';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue'; import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
const emit = defineEmits<{ const emit = defineEmits<{
delete: []; delete: [];
@@ -24,24 +29,43 @@ const currentMember = computed(() => {
</script> </script>
<template> <template>
<MoreOptionsDropdown <DropdownMenu>
:label="'Actions for Project Member ' + currentMember?.name"> <DropdownMenuTrigger as-child>
<button <button
:aria-label="'Edit Project Member ' + currentMember?.name" class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-text-secondary"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out" :aria-label="'Actions for Project Member ' + currentMember?.name">
@click.prevent="emit('edit')"> <svg
<PencilSquareIcon class="w-5 text-icon-active"></PencilSquareIcon> class="h-8 w-8 p-1 rounded-full"
<span>Edit</span> viewBox="0 0 24 24"
</button> xmlns="http://www.w3.org/2000/svg">
<button <path
:aria-label="'Delete Project Member ' + currentMember?.name" fill="none"
data-testid="project_delete" stroke="currentColor"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out" stroke-linecap="round"
@click.prevent="emit('delete')"> stroke-linejoin="round"
<TrashIcon class="w-5 text-icon-active"></TrashIcon> stroke-width="1.5"
<span>Remove from Team</span> d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</button> </svg>
</MoreOptionsDropdown> </button>
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-[150px]" align="end">
<DropdownMenuItem
:aria-label="'Edit Project Member ' + currentMember?.name"
class="flex items-center space-x-3 cursor-pointer"
@click.prevent="emit('edit')">
<PencilSquareIcon class="w-5 text-icon-active" />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
:aria-label="'Delete Project Member ' + currentMember?.name"
data-testid="project_delete"
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
@click.prevent="emit('delete')">
<TrashIcon class="w-5" />
<span>Remove from Team</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@@ -46,7 +46,7 @@ const showEditModal = ref(false);
{{ member?.name }} {{ member?.name }}
</span> </span>
</div> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted"> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ {{
projectMember.billable_rate projectMember.billable_rate
? formatCents( ? formatCents(
@@ -56,7 +56,7 @@ const showEditModal = ref(false);
: '--' : '--'
}} }}
</div> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted"> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ capitalizeFirstLetter(member?.role ?? '') }} {{ capitalizeFirstLetter(member?.role ?? '') }}
</div> </div>
<div <div

View File

@@ -1,8 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid'; import { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';
import type { Report } from '@/packages/api/src'; import type { Report } from '@/packages/api/src';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue'; import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
import { canDeleteReport, canUpdateReport } from '@/utils/permissions'; import { canDeleteReport, canUpdateReport } from '@/utils/permissions';
const emit = defineEmits<{ const emit = defineEmits<{
delete: []; delete: [];
edit: []; edit: [];
@@ -14,27 +20,44 @@ const props = defineProps<{
</script> </script>
<template> <template>
<MoreOptionsDropdown :label="'Actions for Project ' + props.report.name"> <DropdownMenu>
<div class="min-w-[150px]"> <DropdownMenuTrigger as-child>
<button <button
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-text-secondary"
:aria-label="'Actions for Project ' + props.report.name">
<svg
class="h-8 w-8 p-1 rounded-full"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-[150px]" align="end">
<DropdownMenuItem
v-if="canUpdateReport()" v-if="canUpdateReport()"
:aria-label="'Edit Report ' + props.report.name" :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-text-primary 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 cursor-pointer"
@click.prevent="emit('edit')"> @click.prevent="emit('edit')">
<PencilSquareIcon <PencilSquareIcon class="w-5 text-icon-active" />
class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span> <span>Edit</span>
</button> </DropdownMenuItem>
<button <DropdownMenuItem
v-if="canDeleteReport()" v-if="canDeleteReport()"
:aria-label="'Delete Report ' + props.report.name" :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-text-primary 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 cursor-pointer text-destructive focus:text-destructive"
@click.prevent="emit('delete')"> @click.prevent="emit('delete')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon> <TrashIcon class="w-5" />
<span>Delete</span> <span>Delete</span>
</button> </DropdownMenuItem>
</div> </DropdownMenuContent>
</MoreOptionsDropdown> </DropdownMenu>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@@ -35,12 +35,12 @@ const gridTemplate = computed(() => {
No shared reports found No shared reports found
</h3> </h3>
<p v-if="canCreateProjects()" class="pb-5"> <p v-if="canCreateProjects()" class="pb-5">
Create your first project now! Go to the overview to create a report
</p> </p>
<SecondaryButton <SecondaryButton
:icon="PlusIcon" :icon="PlusIcon"
@click="router.visit(route('reporting'))" @click="router.visit(route('reporting'))"
>Go to the overview to create a report >Go to overview
</SecondaryButton> </SecondaryButton>
</div> </div>
<template v-for="report in reports" :key="report.id"> <template v-for="report in reports" :key="report.id">

View File

@@ -67,16 +67,16 @@ async function deleteReport() {
{{ report.name }} {{ report.name }}
</span> </span>
</div> </div>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-muted"> <div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary">
<span class="overflow-ellipsis overflow-hidden"> <span class="overflow-ellipsis overflow-hidden">
{{ report.description }} {{ report.description }}
</span> </span>
</div> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted"> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ report.is_public ? 'Public' : 'Private' }} {{ report.is_public ? 'Public' : 'Private' }}
</div> </div>
<div <div
class="whitespace-nowrap px-3 flex items-center text-sm text-muted"> class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
<div <div
v-if="report.shareable_link" v-if="report.shareable_link"
class="space-x-2 flex items-center"> class="space-x-2 flex items-center">

View File

@@ -25,7 +25,7 @@ function triggerDownload(format: ExportFormat) {
</script> </script>
<template> <template>
<Dropdown align="bottom-end"> <Dropdown align="end">
<template #trigger> <template #trigger>
<SecondaryButton :icon="ArrowDownTrayIcon" :loading> <SecondaryButton :icon="ArrowDownTrayIcon" :loading>
Export Export

View File

@@ -26,8 +26,9 @@ const title = computed(() => {
<template #trigger> <template #trigger>
<Badge <Badge
size="large" size="large"
tag="button"
class="cursor-pointer hover:bg-card-background transition space-x-5 flex"> class="cursor-pointer hover:bg-card-background transition space-x-5 flex">
<component :is="icon" class="h-4 text-muted"></component> <component :is="icon" class="h-4 text-text-secondary"></component>
<span> {{ title }} </span> <span> {{ title }} </span>
</Badge> </Badge>
</template> </template>

View File

@@ -12,20 +12,22 @@ const showSharedReports = computed(() => canViewReport());
</script> </script>
<template> <template>
<TabBar> <TabBar
:model-value="active"
>
<TabBarItem <TabBarItem
:active="active === 'reporting'" value="reporting"
@click="router.visit(route('reporting'))" @click="router.visit(route('reporting'))"
>Overview</TabBarItem >Overview</TabBarItem
> >
<TabBarItem <TabBarItem
:active="active === 'detailed'" value="detailed"
@click="router.visit(route('reporting.detailed'))" @click="router.visit(route('reporting.detailed'))"
>Detailed</TabBarItem >Detailed</TabBarItem
> >
<TabBarItem <TabBarItem
v-if="showSharedReports" v-if="showSharedReports"
:active="active === 'shared'" value="shared"
@click="router.visit(route('reporting.shared'))" @click="router.visit(route('reporting.shared'))"
>Shared</TabBarItem >Shared</TabBarItem
> >

View File

@@ -8,7 +8,7 @@ defineProps<{
<template> <template>
<div <div
class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5"> class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
<dt class="font-semibold text-sm text-muted">{{ title }}</dt> <dt class="font-semibold text-sm text-text-secondary">{{ title }}</dt>
<dd class="text-2xl text-text-primary pt-1 font-semibold"> <dd class="text-2xl text-text-primary pt-1 font-semibold">
{{ value }} {{ value }}
</dd> </dd>

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