Compare commits

..

28 Commits

Author SHA1 Message Date
Constantin Graf
aee766e462 Add more validation for clockify importer 2025-04-29 16:29:57 +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
294 changed files with 8772 additions and 2265 deletions

View File

@@ -107,6 +107,24 @@ jobs:
- name: "Install npm dependencies in services extension"
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"
uses: shivammathur/setup-php@v2
with:
@@ -127,6 +145,9 @@ jobs:
- name: "Activate services extension"
run: php artisan module:enable Services
- name: "Activate invoicing extension"
run: php artisan module:enable Invoicing
- name: "Install npm dependencies"
run: npm ci

View File

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

View File

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

View File

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

View File

@@ -64,8 +64,8 @@ class UserCreateCommand extends Command
$password,
'UTC',
Weekday::Monday,
'EUR',
$verifyEmail
null,
verifyEmail: $verifyEmail
);
});
/** @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\Table;
use Illuminate\Support\Str;
use Novadaemon\FilamentPrettyJson\PrettyJson;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
class AuditResource extends Resource
{
@@ -38,8 +38,8 @@ class AuditResource extends Resource
->maxLength(255),
Forms\Components\TextInput::make('auditable_id')
->required(),
PrettyJson::make('old_values'),
PrettyJson::make('new_values'),
PrettyJsonField::make('old_values'),
PrettyJsonField::make('new_values'),
Forms\Components\Textarea::make('url'),
Forms\Components\TextInput::make('ip_address'),
Forms\Components\TextInput::make('user_agent')

View File

@@ -20,7 +20,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan;
use Novadaemon\FilamentPrettyJson\PrettyJson;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
/**
* @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:
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);
}

View File

@@ -4,6 +4,11 @@ declare(strict_types=1);
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\RelationManagers\InvitationsRelationManager;
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
@@ -56,6 +61,21 @@ class OrganizationResource extends Resource
->searchable(['name', 'email'])
->disabledOn(['edit'])
->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')
->label('Currency')
->options(function (): array {

View File

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

View File

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

View File

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

View File

@@ -40,15 +40,35 @@ class OrganizationController extends Controller
{
$this->checkPermission($organization, 'organizations:update');
$organization->name = $request->input('name');
$oldBillableRate = $organization->billable_rate;
if ($request->has('employees_can_see_billable_rates')) {
$organization->employees_can_see_billable_rates = $request->validated('employees_can_see_billable_rates');
if ($request->getName() !== null) {
$organization->name = $request->getName();
}
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();
if ($oldBillableRate !== $request->getBillableRate()) {
if ($hasBillableRate && $oldBillableRate !== $request->getBillableRate()) {
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
}

View File

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

View File

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

View File

@@ -4,9 +4,14 @@ declare(strict_types=1);
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 Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* @property Organization $organization Organization from model binding
@@ -16,13 +21,12 @@ class OrganizationUpdateRequest extends FormRequest
/**
* 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
{
return [
'name' => [
'required',
'string',
'max:255',
],
@@ -35,13 +39,63 @@ class OrganizationUpdateRequest extends FormRequest
'employees_can_see_billable_rates' => [
'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
{
$input = $this->input('billable_rate');
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\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -27,6 +28,7 @@ class ProjectStoreRequest extends FormRequest
public function rules(): array
{
return [
// Name of the project, the name needs to be unique per client and organization
'name' => [
'required',
'string',
@@ -34,7 +36,13 @@ class ProjectStoreRequest extends FormRequest
'max:255',
UniqueEloquent::make(Project::class, 'name', function (Builder $builder): 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'),
],
'color' => [
@@ -55,6 +63,7 @@ class ProjectStoreRequest extends FormRequest
],
// ID of the client
'client_id' => [
'present',
'nullable',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */

View File

@@ -11,6 +11,7 @@ use App\Rules\ColorRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -34,7 +35,13 @@ class ProjectUpdateRequest extends FormRequest
'max:255',
UniqueEloquent::make(Project::class, 'name', function (Builder $builder): 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'),
],
'color' => [
@@ -54,6 +61,7 @@ class ProjectUpdateRequest extends FormRequest
'boolean',
],
'client_id' => [
'present',
'nullable',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */

View File

@@ -40,7 +40,7 @@ class ReportStoreRequest extends FormRequest
'required',
'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' => [
'nullable',
'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'));
})->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' => [
'required',
'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' => [
'nullable',
'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'));
})->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' => [
'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' => [
'nullable',
'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
{
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,
/** @var string $currency Currency code (ISO 4217) */
'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 */
'is_public' => $this->resource->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 */
'shareable_link' => $this->resource->getShareableLink(),
'properties' => [
@@ -41,9 +41,9 @@ class DetailedReportResource extends BaseResource
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value,
/** @var string $start Start date of the report */
'start' => $this->resource->properties->start->toIso8601ZuluString(),
'start' => $this->formatDateTime($this->resource->properties->start),
/** @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 */
'active' => $this->resource->properties->active,
/** @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(),
],
/** @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 */
'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 */
'description' => $this->resource->description,
/** @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) */
'currency' => $this->resource->organization->currency,
'properties' => [
@@ -81,9 +81,9 @@ class DetailedWithDataReportResource extends BaseResource
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value,
/** @var string $start Start date of the report */
'start' => $this->resource->properties->start->toIso8601ZuluString(),
'start' => $this->formatDateTime($this->resource->properties->start),
/** @var string $end End date of the report */
'end' => $this->resource->properties->end->toIso8601ZuluString(),
'end' => $this->formatDateTime($this->resource->properties->end),
],
/** @var array{
* 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 */
'is_public' => $this->resource->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 */
'shareable_link' => $this->resource->getShareableLink(),
/** @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 */
'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;
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\HasUuids;
use Database\Factories\OrganizationFactory;
@@ -18,7 +23,6 @@ use Illuminate\Support\Str;
use Laravel\Jetstream\Events\TeamCreated;
use Laravel\Jetstream\Events\TeamDeleted;
use Laravel\Jetstream\Events\TeamUpdated;
use Laravel\Jetstream\Jetstream;
use Laravel\Jetstream\Team as JetstreamTeam;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
@@ -37,6 +41,11 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Collection<int, User> $realUsers
* @property-read Collection<int, OrganizationInvitation> $teamInvitations
* @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 static OrganizationFactory factory()
@@ -60,6 +69,11 @@ class Organization extends JetstreamTeam implements AuditableContract
'personal_team' => 'boolean',
'currency' => 'string',
'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>
*/
protected $attributes = [
'currency' => 'EUR',
];
/**

View File

@@ -133,6 +133,13 @@ class JetstreamServiceProvider extends ServiceProvider
'reports:create',
'reports:update',
'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.');
Jetstream::role(Role::Admin->value, 'Administrator', [
@@ -185,6 +192,13 @@ class JetstreamServiceProvider extends ServiceProvider
'reports:create',
'reports:update',
'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.');
Jetstream::role(Role::Manager->value, 'Manager', [
@@ -227,6 +241,13 @@ class JetstreamServiceProvider extends ServiceProvider
'reports:create',
'reports:update',
'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.).');
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'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id,
], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(),
'is_billable' => $record['Billability'] === 'Yes',
'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'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id,
], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(),
'is_billable' => false,
]);
@@ -124,34 +124,59 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$timeEntry->is_imported = true;
// Start
$start = null;
try {
if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['Start Time']) === 1) {
$start = Carbon::createFromFormat('m/d/Y h:i A', $record['Start Date'].' '.$record['Start Time'], $timezone);
} else {
$start = Carbon::createFromFormat('m/d/Y H:i:s A', $record['Start Date'].' '.$record['Start Time'], $timezone);
$startDateStr = $record['Start Date'];
$startTimeStr = $record['Start Time'];
$startStr = $startDateStr.' '.$startTimeStr;
$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) {
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) {
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();
// End
$end = null;
try {
if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['End Time']) === 1) {
$end = Carbon::createFromFormat('m/d/Y h:i A', $record['End Date'].' '.$record['End Time'], $timezone);
} else {
$end = Carbon::createFromFormat('m/d/Y H:i:s A', $record['End Date'].' '.$record['End Time'], $timezone);
$endDateStr = $record['End Date'];
$endTimeStr = $record['End Time'];
$endStr = $endDateStr.' '.$endTimeStr;
$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) {
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) {
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->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
$timeEntry,
$projectMember,

View File

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

View File

@@ -55,12 +55,12 @@ class GenericProjectsImporter extends DefaultImporter
}
$this->projectImportHelper->getKey([
'name' => $record['name'],
'client_id' => $clientId,
'organization_id' => $this->organization->id,
], [
'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,
'is_public' => isset($record['is_public']) && $record['is_public'] === 'true',
'client_id' => $clientId,
'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,
'archived_at' => $archivedAt,

View File

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

View File

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

View File

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

View File

@@ -176,12 +176,12 @@ class SolidtimeImporter extends DefaultImporter
$this->projectImportHelper->getKey([
'name' => $project['name'],
'client_id' => $clientId,
'organization_id' => $this->organization->getKey(),
], [
'color' => $project['color'],
'billable_rate' => $project['billable_rate'] === '' ? null : (int) $project['billable_rate'],
'is_public' => $project['is_public'] === 'true',
'client_id' => $clientId,
'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,
], $project['id']);

View File

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

View File

@@ -83,9 +83,9 @@ class TogglTimeEntriesImporter extends DefaultImporter
if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id,
], [
'client_id' => $clientId,
'is_billable' => false,
'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\Models\TimeEntry;
use App\Service\IntervalService;
use App\Service\LocalizationService;
use Illuminate\Database\Eloquent\Builder;
use LogicException;
use Maatwebsite\Excel\Concerns\Exportable;
@@ -37,14 +37,17 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
private string $timezone;
private LocalizationService $localizationService;
/**
* @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->exportFormat = $exportFormat;
$this->timezone = $timezone;
$this->localizationService = $localizationService;
}
/**
@@ -113,7 +116,6 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
*/
public function map($model): array
{
$interval = app(IntervalService::class);
$duration = $model->getDuration();
if ($this->exportFormat === ExportFormat::XLSX) {
@@ -125,7 +127,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
$model->user->name,
Date::dateTimeToExcel($model->start->timezone($this->timezone)),
$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,
$model->billable ? 'Yes' : 'No',
$model->tagsRelation->pluck('name')->implode(', '),
@@ -139,7 +141,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
$model->user->name,
$model->start->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,
$model->billable ? 'Yes' : 'No',
$model->tagsRelation->pluck('name')->implode(', '),

View File

@@ -4,7 +4,12 @@ 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\Enums\Weekday;
use App\Events\AfterCreateOrganization;
use App\Models\Member;
@@ -17,8 +22,20 @@ use Illuminate\Support\Facades\Hash;
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->name = $name;
$user->email = $email;
@@ -30,17 +47,16 @@ class UserService
}
$user->save();
$organization = new Organization;
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
$organization->personal_team = true;
$organization->currency = $currency;
$organization->owner()->associate($user);
$organization->save();
$organization->users()->attach(
$user, [
'role' => Role::Owner->value,
]
$organization = app(OrganizationService::class)->createOrganization(
$this->getOrganizationNameForUserName($user->name),
$user,
true,
$currency,
$numberFormat,
$currencyFormat,
$dateFormat,
$intervalFormat,
$timeFormat,
);
$user->ownedTeams()->save($organization);
@@ -78,14 +94,11 @@ class UserService
}
// Create a new organization
$organization = new Organization;
$organization->name = $user->name."'s Organization";
$organization->personal_team = true;
$organization->user_id = $user->id;
$organization->save();
// Attach the user to the organization
$organization->users()->attach($user, ['role' => Role::Owner->value]);
$organization = app(OrganizationService::class)->createOrganization(
$this->getOrganizationNameForUserName($user->name),
$user,
true
);
// Set the organization as the user's current organization
$user->currentOrganization()->associate($organization);
@@ -94,6 +107,11 @@ class UserService
AfterCreateOrganization::dispatch($organization);
}
public function getOrganizationNameForUserName(string $username): string
{
return explode(' ', $username, 2)[0]."'s Organization";
}
public function makeSureUserHasCurrentOrganization(User $user): void
{
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",
"inertiajs/inertia-laravel": "^1.0",
"korridor/laravel-computed-attributes": "^3.1",
"korridor/laravel-has-many-sync": "^3.1",
"korridor/laravel-model-validation-rules": "^3.0",
"laravel/framework": "^11.16.0",
"laravel/jetstream": "^5.0",
@@ -24,6 +25,7 @@
"laravel/tinker": "^2.8",
"league/csv": "^9.16.0",
"league/flysystem-aws-s3-v3": "^3.0",
"league/iso3166": "^4.3",
"maatwebsite/excel": "^3.1",
"novadaemon/filament-pretty-json": "^2.2",
"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);
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\ServiceProvider;
@@ -138,6 +143,15 @@ return [
'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

View File

@@ -4,6 +4,11 @@ declare(strict_types=1);
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\User;
use Illuminate\Database\Eloquent\Factories\Factory;
@@ -27,6 +32,11 @@ class OrganizationFactory extends Factory
'user_id' => User::factory(),
'personal_team' => true,
'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
- reverse-proxy
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']
working_dir: /src
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.getByRole('button', { name: 'Delete Account' }).click();
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.goto(PLAYWRIGHT_BASE_URL + '/login');
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.getByPlaceholder('Client Name').fill(newClientName);
await Promise.all([
page.getByRole('button', { name: 'Create Client' }).nth(1).click(),
page.getByRole('button', { name: 'Create Client' }).click(),
page.waitForResponse(
async (response) =>
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.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 page.getByRole('row').first().getByRole('button').click();
await Promise.all([
page.getByRole('button').getByText('Archive').first().click(),
page.getByRole('menuitem').getByText('Archive').click(),
expect(page.getByText(newClientName)).not.toBeVisible(),
]);
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 Promise.all([
page.getByRole('button').getByText('Unarchive').first().click(),
page.getByRole('menuitem').getByText('Unarchive').click(),
expect(page.getByText(newClientName)).not.toBeVisible(),
]);
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);
const newBillableRate = Math.round(Math.random() * 10000);
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('Custom Rate').click();
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.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 page.getByText(newProjectName).click();
@@ -35,8 +35,7 @@ test('test that updating project member billable rate works for existing time en
.getByRole('button')
.click();
await page
.getByRole('button', { name: 'Edit Project Member' })
.first()
.getByRole('menuitem', { name: 'Edit Project Member' })
.click();
await page.getByLabel('Billable Rate').fill(newBillableRate.toString());
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.getByLabel('Project Name').fill(newProjectName);
await Promise.all([
page.getByRole('button', { name: 'Create Project' }).nth(1).click(),
page.getByRole('button', { name: 'Create Project' }).click(),
page.waitForResponse(
async (response) =>
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.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 page.getByRole('row').first().getByRole('button').click();
await Promise.all([
page.getByRole('button').getByText('Archive').first().click(),
page.getByRole('menuitem').getByText('Archive').first().click(),
expect(page.getByText(newProjectName)).not.toBeVisible(),
]);
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 Promise.all([
page.getByRole('button').getByText('Unarchive').first().click(),
page.getByRole('menuitem').getByText('Unarchive').first().click(),
expect(page.getByText(newProjectName)).not.toBeVisible(),
]);
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.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 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('Custom Rate').click();
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.getByPlaceholder('Tag Name').fill(newTagName);
await Promise.all([
page.getByRole('button', { name: 'Create Tag' }).nth(1).click(),
page.getByRole('button', { name: 'Create Tag' }).click(),
page.waitForResponse(
async (response) =>
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.getByLabel('Project Name').fill(newProjectName);
await Promise.all([
page.getByRole('button', { name: 'Create Project' }).nth(1).click(),
page.getByRole('button', { name: 'Create Project' }).click(),
page.waitForResponse(
async (response) =>
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 Promise.all([
page.getByRole('button', { name: 'Create Task' }).nth(1).click(),
page.getByRole('button', { name: 'Create Task' }).click(),
page.waitForResponse(
async (response) =>
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.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 page.getByText(newProjectName).click();
await page.getByRole('button', { name: 'Create Task' }).click();
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 page.getByRole('row').first().getByRole('button').click();
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(),
]);
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 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(),
]);
await Promise.all([

View File

@@ -29,7 +29,12 @@ export default typescriptEslint.config(
"vue/multi-word-component-names": "off",
"@typescript-eslint/no-unused-vars": "off",
"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

View File

@@ -2,6 +2,11 @@
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;
return [
@@ -16,4 +21,42 @@ return [
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).',
'organization' => 'The :attribute does not exist.',
'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.',
'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.',

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",
"@rushstack/eslint-patch": "^1.10.5",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/vue-form": "^1.3.1",
"@tanstack/vue-query": "^5.56.2",
"@tanstack/vue-query-devtools": "^5.58.0",
"@tanstack/vue-table": "^8.21.2",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.3.0",
"@vueuse/core": "^12.5.0",
"@vueuse/integrations": "^12.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"echarts": "^5.5.0",
"focus-trap": "^7.6.0",
"lucide-vue-next": "^0.487.0",
"parse-duration": "^2.0.1",
"pinia": "^2.1.7",
"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"
},
"overrides": {

View File

@@ -2,11 +2,11 @@
@tailwind components;
@tailwind utilities;
:root.dark {
--color-bg-primary: #0f1011;
--color-bg-secondary: #17181a;
--color-bg-primary: #101012;
--color-bg-secondary: #17181B;
--color-bg-tertiary: #2A2C32;
--color-bg-quaternary: #141518;
--color-bg-background: #080808;
--color-bg-background: #090909;
--color-text-primary: #ffffff;
--color-text-secondary: #e3e4e6;
--color-text-tertiary: #969799;
@@ -22,10 +22,10 @@
--theme-color-menu-active: 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-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-heading-background: var(--theme-color-card-background);
@@ -53,7 +53,7 @@
--color-bg-background: #ffffff;
--color-text-primary: #18181b;
--color-text-secondary: #3f3f46;
--color-text-tertiary: #71717a;
--color-text-tertiary: #57575C;
--color-text-quaternary: #a1a1aa;
--color-border-primary: #e7e7e7;
--color-border-secondary: #e5e5e5;
@@ -70,8 +70,6 @@
--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-color-muted-text: var(--color-text-secondary);
--theme-color-row-background: var(--theme-color-card-background);
--theme-color-row-heading-background: var(--color-bg-secondary);
--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-active: var(--theme-color-card-background-active);
--popover-border: var(--color-border-secondary);
}
* {
@@ -180,3 +179,68 @@ body {
src: url('/fonts/Outfit-ExtraBold.ttf');
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-from-class="opacity-100"
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 />
</div>
</transition>

View File

@@ -6,7 +6,12 @@ import {
} from '@heroicons/vue/20/solid';
import type { Client } from '@/packages/api/src';
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<{
delete: [];
@@ -19,37 +24,54 @@ const props = defineProps<{
</script>
<template>
<MoreOptionsDropdown :label="'Actions for Client ' + props.client.name">
<div class="min-w-[150px]">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<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()"
:aria-label="'Edit Client ' + props.client.name"
data-testid="client_edit"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-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')">
<PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon>
<PencilSquareIcon class="w-5 text-icon-active" />
<span>Edit</span>
</button>
<button
</DropdownMenuItem>
<DropdownMenuItem
v-if="canUpdateClients()"
:aria-label="'Archive Client ' + props.client.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-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')">
<ArchiveBoxIcon class="w-5 text-icon-active"></ArchiveBoxIcon>
<ArchiveBoxIcon class="w-5 text-icon-active" />
<span>{{ client.is_archived ? 'Unarchive' : 'Archive' }}</span>
</button>
<button
</DropdownMenuItem>
<DropdownMenuItem
v-if="canDeleteClients()"
:aria-label="'Delete Client ' + props.client.name"
data-testid="client_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-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')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<TrashIcon class="w-5" />
<span>Delete</span>
</button>
</div>
</MoreOptionsDropdown>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<style scoped></style>

View File

@@ -48,10 +48,10 @@ const showEditModal = ref(false);
</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">
<span class="text-muted"> {{ projectCount }} Projects </span>
<span class="text-text-secondary"> {{ projectCount }} Projects </span>
</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>
<span>Active</span>
</div>

View File

@@ -1,6 +1,12 @@
<script setup lang="ts">
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<{
delete: [];
resend: [];
@@ -8,22 +14,42 @@ const emit = defineEmits<{
</script>
<template>
<MoreOptionsDropdown label="Actions for the invitation">
<button
data-testid="invitation_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('resend')">
<ArrowPathIcon class="w-5 text-icon-active"></ArrowPathIcon>
<span>Resend Invitation</span>
</button>
<button
data-testid="invitation_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('delete')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>
</MoreOptionsDropdown>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<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 the invitation">
<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
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>
<style scoped></style>

View File

@@ -57,10 +57,10 @@ async function resendInvitation() {
<template>
<TableRow>
<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 }}
</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) }}
</div>
<div

View File

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

View File

@@ -64,12 +64,12 @@ const currentValue = computed(() => {
<Badge
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">
<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">
{{ currentValue }}
</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>
</template>
</SelectDropdown>

View File

@@ -49,7 +49,10 @@ const showOwnershipTransferConfirmModal = ref(false);
function saveWithChecks() {
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;
} else if (
memberBody.value.role === 'owner' &&

View File

@@ -194,7 +194,7 @@ useFocus(clientNameInput, { initialValue: true });
</div>
<!-- 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 }}
</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="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">
<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">
{{ member.name }}
</div>

View File

@@ -2,7 +2,12 @@
import { TrashIcon, UserCircleIcon, PencilSquareIcon, ArrowDownOnSquareStackIcon } from '@heroicons/vue/20/solid';
import type { Member } from '@/packages/api/src';
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<{
delete: [];
@@ -13,51 +18,65 @@ const emit = defineEmits<{
const props = defineProps<{
member: Member;
}>();
</script>
<template>
<MoreOptionsDropdown
v-if="canUpdateMembers() || canDeleteMembers()"
:label="'Actions for Member ' + props.member.name">
<div class="min-w-[150px]">
<DropdownMenu v-if="canUpdateMembers() || canDeleteMembers()">
<DropdownMenuTrigger as-child>
<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()"
: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')">
<PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon>
<PencilSquareIcon class="w-5 text-icon-active" />
<span>Edit</span>
</button>
<button
</DropdownMenuItem>
<DropdownMenuItem
v-if="canDeleteMembers()"
:aria-label="'Delete Member ' + props.member.name"
data-testid="member_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-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')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<TrashIcon class="w-5" />
<span>Delete</span>
</button>
<button
</DropdownMenuItem>
<DropdownMenuItem
v-if="props.member.role === 'placeholder' && canMergeMembers()"
:aria-label="'Merge Member ' + props.member.name"
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')">
<ArrowDownOnSquareStackIcon class="w-5 text-icon-active"></ArrowDownOnSquareStackIcon>
<ArrowDownOnSquareStackIcon class="w-5 text-icon-active" />
<span>Merge</span>
</button>
<button
</DropdownMenuItem>
<DropdownMenuItem
v-if="props.member.role !== 'placeholder' && canMakeMembersPlaceholders()"
: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')">
<UserCircleIcon class="w-5 text-icon-active"></UserCircleIcon>
<UserCircleIcon class="w-5 text-icon-active" />
<span>Deactivate</span>
</button>
</div>
</MoreOptionsDropdown>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<style scoped></style>

View File

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

View File

@@ -64,13 +64,13 @@ const userHasValidMailAddress = computed(() => {
{{ member.name }}
</span>
</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 }}
</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) }}
</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
? formatCents(
@@ -81,7 +81,7 @@ const userHasValidMailAddress = computed(() => {
}}
</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
v-if="member.is_placeholder === false"
class="w-5"></CheckCircleIcon>

View File

@@ -27,14 +27,14 @@
<p class="text-sm font-medium text-text-primary">
{{ title }}
</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 }}
</p>
</div>
<div class="ml-4 flex flex-shrink-0">
<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">
<span class="sr-only">Close</span>
<XMarkIcon class="h-5 w-5" aria-hidden="true" />

View File

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

View File

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

View File

@@ -6,7 +6,13 @@ import {
} from '@heroicons/vue/20/solid';
import type { Project } from '@/packages/api/src';
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<{
delete: [];
edit: [];
@@ -18,37 +24,54 @@ const props = defineProps<{
</script>
<template>
<MoreOptionsDropdown :label="'Actions for Project ' + props.project.name">
<div class="min-w-[150px]">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<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()"
:aria-label="'Edit Project ' + props.project.name"
data-testid="project_edit"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-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')">
<PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon>
<PencilSquareIcon class="w-5 text-icon-active" />
<span>Edit</span>
</button>
<button
</DropdownMenuItem>
<DropdownMenuItem
v-if="canUpdateProjects()"
:aria-label="'Archive Project ' + props.project.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-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')">
<ArchiveBoxIcon class="w-5 text-icon-active"></ArchiveBoxIcon>
<ArchiveBoxIcon class="w-5 text-icon-active" />
<span>{{ project.is_archived ? 'Unarchive' : 'Archive' }}</span>
</button>
<button
</DropdownMenuItem>
<DropdownMenuItem
v-if="canDeleteProjects()"
:aria-label="'Delete Project ' + props.project.name"
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')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<TrashIcon class="w-5" />
<span>Delete</span>
</button>
</div>
</MoreOptionsDropdown>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<style scoped></style>

View File

@@ -79,9 +79,9 @@ const showEditProjectModal = ref(false);
<span class="overflow-ellipsis overflow-hidden">
{{ project.name }}
</span>
<span class="text-muted"> {{ projectTasksCount }} Tasks </span>
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
</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
v-if="project.client_id"
class="overflow-ellipsis overflow-hidden">
@@ -89,14 +89,14 @@ const showEditProjectModal = ref(false);
</div>
<div v-else>No client</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">
{{ formatHumanReadableDuration(project.spent_time) }}
</div>
<div v-else>--</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
v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
<EstimatedTimeProgress
@@ -107,11 +107,11 @@ const showEditProjectModal = ref(false);
</div>
<div
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 }}
</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>
<span>Active</span>
</div>

View File

@@ -33,7 +33,10 @@ async function submit() {
props.projectMember.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;
}
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="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>
</div>
<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 { storeToRefs } from 'pinia';
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<{
delete: [];
@@ -24,24 +29,43 @@ const currentMember = computed(() => {
</script>
<template>
<MoreOptionsDropdown
:label="'Actions for Project Member ' + currentMember?.name">
<button
:aria-label="'Edit Project Member ' + currentMember?.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click.prevent="emit('edit')">
<PencilSquareIcon class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span>
</button>
<button
:aria-label="'Delete Project Member ' + currentMember?.name"
data-testid="project_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click.prevent="emit('delete')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Remove from Team</span>
</button>
</MoreOptionsDropdown>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<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 Member ' + currentMember?.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
: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>
<style scoped></style>

View File

@@ -46,7 +46,7 @@ const showEditModal = ref(false);
{{ member?.name }}
</span>
</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
? formatCents(
@@ -56,7 +56,7 @@ const showEditModal = ref(false);
: '--'
}}
</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 ?? '') }}
</div>
<div

View File

@@ -1,8 +1,14 @@
<script setup lang="ts">
import { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';
import type { Report } from '@/packages/api/src';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
import { canDeleteReport, canUpdateReport } from '@/utils/permissions';
const emit = defineEmits<{
delete: [];
edit: [];
@@ -14,27 +20,44 @@ const props = defineProps<{
</script>
<template>
<MoreOptionsDropdown :label="'Actions for Project ' + props.report.name">
<div class="min-w-[150px]">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<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()"
: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')">
<PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon>
<PencilSquareIcon class="w-5 text-icon-active" />
<span>Edit</span>
</button>
<button
</DropdownMenuItem>
<DropdownMenuItem
v-if="canDeleteReport()"
:aria-label="'Delete Report ' + props.report.name"
class="border-b border-card-background-separator flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-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')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<TrashIcon class="w-5" />
<span>Delete</span>
</button>
</div>
</MoreOptionsDropdown>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<style scoped></style>

View File

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

View File

@@ -67,16 +67,16 @@ async function deleteReport() {
{{ report.name }}
</span>
</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">
{{ report.description }}
</span>
</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' }}
</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
v-if="report.shareable_link"
class="space-x-2 flex items-center">

View File

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

View File

@@ -26,8 +26,9 @@ const title = computed(() => {
<template #trigger>
<Badge
size="large"
tag="button"
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>
</Badge>
</template>

View File

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

View File

@@ -8,7 +8,7 @@ defineProps<{
<template>
<div
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">
{{ value }}
</dd>

View File

@@ -1,9 +1,17 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { Tabs, TabsList } from '@/Components/ui/tabs'
defineProps<{
defaultValue?: string
}>()
</script>
<template>
<div class="flex items-center space-x-0.5 sm:space-x-1">
<slot></slot>
</div>
<Tabs :default-value="defaultValue" class="w-full">
<TabsList class="flex items-center space-x-0.5 sm:space-x-1">
<slot></slot>
</TabsList>
</Tabs>
</template>
<style scoped></style>

View File

@@ -1,30 +1,22 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
import { computed } from 'vue';
import { TabsTrigger } from '@/Components/ui/tabs'
import { twMerge } from 'tailwind-merge'
import type { Component } from 'vue'
const props = defineProps<{
active?: boolean;
}>();
const activeClass = computed(() => {
if (props.active) {
return 'bg-tab-background border border-tab-border text-text-primary font-semibold';
}
return '';
});
value: string
class?: string
icon?: Component
}>()
</script>
<template>
<button
role="tab"
:class="
twMerge(
'rounded-md px-2 sm:px-3 py-1 sm:py-1.5 text-xs sm:text-sm font-medium hover:text-text-primary focus-visible:outline-none',
activeClass
)
">
<TabsTrigger
:value="value"
:icon="icon"
:class="twMerge('rounded-md px-2 sm:px-3 py-1 border sm:py-1.5 text-xs sm:text-sm font-medium text-text-tertiary hover:text-text-primary focus-visible:outline-none data-[state=active]:bg-tab-background data-[state=active]:border-input-border data-[state=active]:text-text-primary border-tab-border', props.class)">
<slot></slot>
</button>
</TabsTrigger>
</template>
<style scoped></style>

View File

@@ -1,7 +1,12 @@
<script setup lang="ts">
import { TrashIcon } from '@heroicons/vue/20/solid';
import type { Tag } from '@/packages/api/src';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
const emit = defineEmits<{
delete: [];
@@ -12,16 +17,36 @@ const props = defineProps<{
</script>
<template>
<MoreOptionsDropdown :label="'Actions for Tag ' + props.tag.name">
<button
:aria-label="'Delete Tag ' + props.tag.name"
data-testid="tag_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"
@click="emit('delete')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>
</MoreOptionsDropdown>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<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 Tag ' + props.tag.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
:aria-label="'Delete Tag ' + props.tag.name"
data-testid="tag_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>
<style scoped></style>

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