mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
12 Commits
feature/po
...
feature/nu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b82bf660b | ||
|
|
a0a8a7f772 | ||
|
|
5c63a94857 | ||
|
|
e377e58c98 | ||
|
|
08e0118181 | ||
|
|
730604987f | ||
|
|
80523cba3a | ||
|
|
af374c9c4d | ||
|
|
48be348c4c | ||
|
|
7e2d1ccc3d | ||
|
|
132b6cbe8f | ||
|
|
4605aa75ff |
21
.github/workflows/build-private.yml
vendored
21
.github/workflows/build-private.yml
vendored
@@ -107,6 +107,24 @@ jobs:
|
||||
- name: "Install npm dependencies in services extension"
|
||||
run: cd extensions/Services && npm ci
|
||||
|
||||
- name: "Checkout services 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
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -64,8 +64,8 @@ class UserCreateCommand extends Command
|
||||
$password,
|
||||
'UTC',
|
||||
Weekday::Monday,
|
||||
'EUR',
|
||||
$verifyEmail
|
||||
null,
|
||||
verifyEmail: $verifyEmail
|
||||
);
|
||||
});
|
||||
/** @var Organization|null $organization */
|
||||
|
||||
36
app/Enums/CurrencyFormat.php
Normal file
36
app/Enums/CurrencyFormat.php
Normal 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
48
app/Enums/DateFormat.php
Normal 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;
|
||||
}
|
||||
}
|
||||
32
app/Enums/IntervalFormat.php
Normal file
32
app/Enums/IntervalFormat.php
Normal 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;
|
||||
}
|
||||
}
|
||||
37
app/Enums/NumberFormat.php
Normal file
37
app/Enums/NumberFormat.php
Normal 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
28
app/Enums/TimeFormat.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -136,6 +136,8 @@ class MemberController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge one member into another
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
|
||||
* @throws \Throwable
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -423,6 +427,7 @@ class TimeEntryController extends Controller
|
||||
'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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', [
|
||||
|
||||
377
app/Service/CurrencyService.php
Normal file
377
app/Service/CurrencyService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
155
app/Service/LocalizationService.php
Normal file
155
app/Service/LocalizationService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
68
app/Service/OrganizationService.php
Normal file
68
app/Service/OrganizationService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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(', '),
|
||||
|
||||
@@ -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
20
components.json
Normal 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"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
136
composer.lock
generated
136
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "e02eaa279f99a886be314748daa0b234",
|
||||
"content-hash": "33dc60657e6702ebd5e19f19d86a64b4",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
@@ -3780,6 +3780,73 @@
|
||||
},
|
||||
"time": "2024-03-01T14:15:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "korridor/laravel-has-many-sync",
|
||||
"version": "3.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/korridor/laravel-has-many-sync.git",
|
||||
"reference": "32344956730d306d9753f5d3c455650ed828fd4e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/korridor/laravel-has-many-sync/zipball/32344956730d306d9753f5d3c455650ed828fd4e",
|
||||
"reference": "32344956730d306d9753f5d3c455650ed828fd4e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/database": "^10|^11|^12",
|
||||
"illuminate/support": "^10|^11|^12",
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3",
|
||||
"larastan/larastan": "^2|^3.0",
|
||||
"orchestra/testbench": "^8|^9|^10",
|
||||
"phpunit/phpunit": "^10.0|^11.5",
|
||||
"squizlabs/php_codesniffer": "^3.5"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Korridor\\LaravelHasManySync\\ServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Korridor\\LaravelHasManySync\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "korridor",
|
||||
"email": "26689068+korridor@users.noreply.github.com"
|
||||
},
|
||||
{
|
||||
"name": "Alfa Adhitya",
|
||||
"email": "alfa2159@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Laravel has many sync",
|
||||
"homepage": "https://github.com/korridor/laravel-has-many-sync",
|
||||
"keywords": [
|
||||
"eloquent",
|
||||
"has-many",
|
||||
"laravel",
|
||||
"relations",
|
||||
"sync"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/korridor/laravel-has-many-sync/tree/3.1.0"
|
||||
},
|
||||
"time": "2025-03-03T20:58:17+00:00"
|
||||
},
|
||||
{
|
||||
"name": "korridor/laravel-model-validation-rules",
|
||||
"version": "3.2.0",
|
||||
@@ -5415,6 +5482,73 @@
|
||||
},
|
||||
"time": "2024-08-09T21:24:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/iso3166",
|
||||
"version": "4.3.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/alcohol/iso3166.git",
|
||||
"reference": "5133fed7d54728222f4058702487dccedda20472"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/alcohol/iso3166/zipball/5133fed7d54728222f4058702487dccedda20472",
|
||||
"reference": "5133fed7d54728222f4058702487dccedda20472",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1.12.6",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.2.1",
|
||||
"phpstan/phpstan-strict-rules": "^1.6.1",
|
||||
"phpunit/phpunit": "^9.6.21"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "4.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\ISO3166\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Rob Bast",
|
||||
"email": "rob.bast@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "ISO 3166-1 PHP Library",
|
||||
"homepage": "https://github.com/alcohol/iso3166",
|
||||
"keywords": [
|
||||
"3166",
|
||||
"3166-1",
|
||||
"ISO 3166",
|
||||
"countries",
|
||||
"iso",
|
||||
"library"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/alcohol/iso3166/issues",
|
||||
"source": "https://github.com/alcohol/iso3166"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/alcohol",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-10-10T07:39:24+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/mime-type-detection",
|
||||
"version": "1.16.0",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') &&
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 €',
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
915
openapi.json
915
openapi.json
File diff suppressed because one or more lines are too long
1500
package-lock.json
generated
1500
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||
|
||||
@@ -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,9 @@
|
||||
|
||||
--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);
|
||||
@@ -71,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);
|
||||
@@ -120,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);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -181,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -25,7 +25,7 @@ function triggerDownload(format: ExportFormat) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown align="bottom-end">
|
||||
<Dropdown align="end">
|
||||
<template #trigger>
|
||||
<SecondaryButton :icon="ArrowDownTrayIcon" :loading>
|
||||
Export
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,7 +6,13 @@ import {
|
||||
} from '@heroicons/vue/20/solid';
|
||||
import type { Task } from '@/packages/api/src';
|
||||
import { canDeleteTasks, canUpdateTasks } 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,38 +24,55 @@ const props = defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MoreOptionsDropdown :label="'Actions for Task ' + props.task.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 Task ' + props.task.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="canUpdateTasks()"
|
||||
:aria-label="'Edit Task ' + props.task.name"
|
||||
data-testid="task_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="canUpdateTasks()"
|
||||
:aria-label="'Mark Task ' + props.task.name + ' as done'"
|
||||
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('done')">
|
||||
<CheckCircleIcon class="w-5 text-icon-active"></CheckCircleIcon>
|
||||
<CheckCircleIcon class="w-5 text-icon-active" />
|
||||
<span v-if="props.task.is_done">Mark as active</span>
|
||||
<span v-else>Mark as done</span>
|
||||
</button>
|
||||
<button
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
v-if="canDeleteTasks()"
|
||||
:aria-label="'Delete Task ' + props.task.name"
|
||||
data-testid="task_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>
|
||||
|
||||
@@ -39,14 +39,14 @@ const showTaskEditModal = ref(false);
|
||||
</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">
|
||||
<span v-if="task.spent_time">
|
||||
{{ formatHumanReadableDuration(task.spent_time) }}
|
||||
</span>
|
||||
<span v-else> -- </span>
|
||||
</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
|
||||
@@ -56,7 +56,7 @@ const showTaskEditModal = ref(false);
|
||||
<span v-else> -- </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">
|
||||
<template v-if="task.is_done">
|
||||
<CheckCircleIcon class="w-5"></CheckCircleIcon>
|
||||
<span>Done</span>
|
||||
|
||||
@@ -52,7 +52,7 @@ const close = () => {
|
||||
<slot name="title" />
|
||||
</h3>
|
||||
|
||||
<div class="mt-4 text-sm text-muted">
|
||||
<div class="mt-4 text-sm text-text-secondary">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ const isRunningInDifferentOrganization = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted font-extrabold text-xs">Current Timer</div>
|
||||
<div class="text-text-secondary font-extrabold text-xs">Current Timer</div>
|
||||
<div class="text-text-primary font-medium text-lg">
|
||||
{{ currentTime }}
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
<DayOverviewCardChart :history="history"></DayOverviewCardChart>
|
||||
</div>
|
||||
<div
|
||||
class="flex text-sm items-center justify-center text-muted min-w-[65px] font-semibold">
|
||||
class="flex text-sm items-center justify-center text-text-secondary min-w-[65px] font-semibold">
|
||||
{{ formatHumanReadableDuration(duration) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,7 @@ async function startTaskTimer() {
|
||||
</span>
|
||||
<ChevronRightIcon
|
||||
v-if="task"
|
||||
class="w-4 text-muted shrink-0"></ChevronRightIcon>
|
||||
class="w-4 text-text-secondary shrink-0"></ChevronRightIcon>
|
||||
<div
|
||||
v-if="task"
|
||||
class="min-w-0 shrink truncate">
|
||||
|
||||
@@ -30,7 +30,7 @@ defineProps<{
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="text-muted text-sm font-medium text-ellipsis whitespace-nowrap max-w-full overflow-hidden">
|
||||
class="text-text-secondary text-sm font-medium text-ellipsis whitespace-nowrap max-w-full overflow-hidden">
|
||||
{{ description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ const open = useSessionStorage('nav-collapse-state-' + props.title, true);
|
||||
<CollapsibleRoot v-else v-model:open="open"
|
||||
><CollapsibleTrigger class="w-full group py-0.5">
|
||||
<div
|
||||
class="text-muted group-hover:text-text-primary group-hover:bg-menu-active group flex gap-x-2 rounded-md transition leading-6 py-1 px-2 font-medium text-sm items-center justify-between">
|
||||
class="text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active group flex gap-x-2 rounded-md transition leading-6 py-1 px-2 font-medium text-sm items-center justify-between">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<component
|
||||
:is="icon"
|
||||
|
||||
@@ -15,7 +15,7 @@ defineProps<{
|
||||
:class="[
|
||||
current
|
||||
? 'bg-menu-active text-text-primary'
|
||||
: 'text-muted group-hover:text-text-primary group-hover:bg-menu-active ',
|
||||
: 'text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active ',
|
||||
'group flex gap-x-2 rounded-md transition leading-6 py-1 px-2 font-medium text-sm items-center',
|
||||
]">
|
||||
<component
|
||||
|
||||
@@ -30,7 +30,7 @@ const switchToTeam = (organization: Organization) => {
|
||||
<template>
|
||||
<Dropdown
|
||||
v-if="page.props.jetstream.hasTeamFeatures"
|
||||
align="bottom"
|
||||
align="center"
|
||||
width="60">
|
||||
<template #trigger>
|
||||
<div
|
||||
@@ -63,7 +63,7 @@ const switchToTeam = (organization: Organization) => {
|
||||
<template #content>
|
||||
<div class="w-60">
|
||||
<!-- Organization Management -->
|
||||
<div class="block px-4 py-2 text-xs text-muted">
|
||||
<div class="block px-4 py-2 text-xs text-text-secondary">
|
||||
Manage Organization
|
||||
</div>
|
||||
|
||||
@@ -94,7 +94,7 @@ const switchToTeam = (organization: Organization) => {
|
||||
<template v-if="page.props.auth.user.all_teams.length > 1">
|
||||
<div class="border-t border-card-background-separator" />
|
||||
|
||||
<div class="block px-4 py-2 text-xs text-muted">
|
||||
<div class="block px-4 py-2 text-xs text-text-secondary">
|
||||
Switch Organizations
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const props = defineProps<{
|
||||
const classes = computed(() => {
|
||||
return props.active
|
||||
? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 text-start text-base font-medium text-indigo-700 bg-indigo-50 focus:outline-none focus:text-indigo-800 focus:bg-indigo-100 focus:border-indigo-700 transition duration-150 ease-in-out'
|
||||
: 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-muted hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out';
|
||||
: 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-text-secondary hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out';
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<slot name="title" />
|
||||
</h3>
|
||||
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
<slot name="description" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ const logout = () => {
|
||||
</script>
|
||||
<template>
|
||||
<div class="ms-3 relative">
|
||||
<Dropdown align="top" width="48">
|
||||
<Dropdown align="center" width="48">
|
||||
<template #trigger>
|
||||
<button
|
||||
v-if="page.props.jetstream.managesProfilePhotos"
|
||||
|
||||
19
resources/js/Components/ui/accordion/Accordion.vue
Normal file
19
resources/js/Components/ui/accordion/Accordion.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AccordionRoot,
|
||||
type AccordionRootEmits,
|
||||
type AccordionRootProps,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
|
||||
const props = defineProps<AccordionRootProps>()
|
||||
const emits = defineEmits<AccordionRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</AccordionRoot>
|
||||
</template>
|
||||
24
resources/js/Components/ui/accordion/AccordionContent.vue
Normal file
24
resources/js/Components/ui/accordion/AccordionContent.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AccordionContent, type AccordionContentProps } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionContent
|
||||
v-bind="delegatedProps"
|
||||
class="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down px-0.5"
|
||||
>
|
||||
<div :class="cn('pb-4 pt-0', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</template>
|
||||
24
resources/js/Components/ui/accordion/AccordionItem.vue
Normal file
24
resources/js/Components/ui/accordion/AccordionItem.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AccordionItem, type AccordionItemProps, useForwardProps } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionItem
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('border-b', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AccordionItem>
|
||||
</template>
|
||||
39
resources/js/Components/ui/accordion/AccordionTrigger.vue
Normal file
39
resources/js/Components/ui/accordion/AccordionTrigger.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import {
|
||||
AccordionHeader,
|
||||
AccordionTrigger,
|
||||
type AccordionTriggerProps,
|
||||
} from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionHeader class="flex">
|
||||
<AccordionTrigger
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<slot name="icon">
|
||||
<ChevronDown
|
||||
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200"
|
||||
/>
|
||||
</slot>
|
||||
</AccordionTrigger>
|
||||
</AccordionHeader>
|
||||
</template>
|
||||
4
resources/js/Components/ui/accordion/index.ts
Normal file
4
resources/js/Components/ui/accordion/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as Accordion } from './Accordion.vue'
|
||||
export { default as AccordionContent } from './AccordionContent.vue'
|
||||
export { default as AccordionItem } from './AccordionItem.vue'
|
||||
export { default as AccordionTrigger } from './AccordionTrigger.vue'
|
||||
14
resources/js/Components/ui/alert-dialog/AlertDialog.vue
Normal file
14
resources/js/Components/ui/alert-dialog/AlertDialog.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { type AlertDialogEmits, type AlertDialogProps, AlertDialogRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<AlertDialogProps>()
|
||||
const emits = defineEmits<AlertDialogEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { buttonVariants } from '@/Components/ui/button'
|
||||
import { AlertDialogAction, type AlertDialogActionProps } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogAction v-bind="delegatedProps" :class="twMerge(buttonVariants(), props.class)">
|
||||
<slot />
|
||||
</AlertDialogAction>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { buttonVariants } from '@/Components/ui/button'
|
||||
import { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogCancel
|
||||
v-bind="delegatedProps"
|
||||
:class="twMerge(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogCancel>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AlertDialogContent,
|
||||
type AlertDialogContentEmits,
|
||||
type AlertDialogContentProps,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<AlertDialogContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay
|
||||
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<AlertDialogContent
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user