Compare commits

...

12 Commits

Author SHA1 Message Date
Gregor Vostrak
7b82bf660b prevent billable rate change modals from immediately sumbitting when pressing enter on the previous form 2025-04-23 14:24:36 +02:00
Gregor Vostrak
a0a8a7f772 fix escape handling in tagdropdown and timetrackerprojecttaskdropdown after changing to radix dropdowns 2025-04-23 14:11:15 +02:00
Constantin Graf
5c63a94857 Add composer dependency “league/iso3166” 2025-04-23 12:37:20 +02:00
Gregor Vostrak
e377e58c98 add invoicing extension to private build action 2025-04-22 20:29:30 +02:00
Gregor Vostrak
08e0118181 add accordion component and countries api route 2025-04-22 17:32:32 +02:00
Gregor Vostrak
730604987f fix timeentry checkboxes 2025-04-22 17:11:41 +02:00
Gregor Vostrak
80523cba3a update api client, and report empty state improvement 2025-04-16 17:18:51 +02:00
Gregor Vostrak
af374c9c4d fix tests, add autofocus disable option for dropdown 2025-04-15 15:18:02 +02:00
Constantin Graf
48be348c4c Add composer package korridor/laravel-has-many-sync 2025-04-14 16:03:12 +02:00
Constantin Graf
7e2d1ccc3d Fixes for invoice feature 2025-04-13 23:37:57 +02:00
Gregor Vostrak
132b6cbe8f refactor to shadcn components, dynamically load extension frontend
add jetstream permissions, add dynamic inertia module loading, add shadcn components, change modals and dropdowns to shadcn dismissable layer,
2025-04-13 23:06:58 +02:00
Constantin Graf
4605aa75ff Add localization settings 2025-04-13 16:26:31 +02:00
259 changed files with 7524 additions and 1410 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

20
components.json Normal file
View File

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

View File

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

136
composer.lock generated
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->string('number_format')->default(config('app.localization.default_number_format'))->nullable(false);
$table->string('currency_format')->default(config('app.localization.default_currency_format'))->nullable(false);
$table->string('date_format')->default(config('app.localization.default_date_format'))->nullable(false);
$table->string('interval_format')->default(config('app.localization.default_interval_format'))->nullable(false);
$table->string('time_format')->default(config('app.localization.default_time_format'))->nullable(false);
});
Schema::table('organizations', function (Blueprint $table): void {
$table->string('number_format')->default(null)->nullable(false)->change();
$table->string('currency_format')->default(null)->nullable(false)->change();
$table->string('date_format')->default(null)->nullable(false)->change();
$table->string('interval_format')->default(null)->nullable(false)->change();
$table->string('time_format')->default(null)->nullable(false)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->dropColumn('number_format');
$table->dropColumn('currency_format');
$table->dropColumn('date_format');
$table->dropColumn('interval_format');
$table->dropColumn('time_format');
});
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

1500
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -2,11 +2,11 @@
@tailwind components;
@tailwind utilities;
:root.dark {
--color-bg-primary: #0f1011;
--color-bg-secondary: #17181a;
--color-bg-primary: #101012;
--color-bg-secondary: #17181B;
--color-bg-tertiary: #2A2C32;
--color-bg-quaternary: #141518;
--color-bg-background: #080808;
--color-bg-background: #090909;
--color-text-primary: #ffffff;
--color-text-secondary: #e3e4e6;
--color-text-tertiary: #969799;
@@ -22,10 +22,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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View 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'

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

View File

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

View File

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

View File

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