Compare commits

..

6 Commits

Author SHA1 Message Date
Constantin Graf
1ab271b38a Add basic e2e tests for admin panel 2026-09-22 13:52:15 +02:00
Constantin Graf
b98d6b7a53 Address review feedback on composer updates
- Default INERTIA_SSR_ENABLED to false to match .env.example, .env.ci and
  .env.production, so existing setups keep the previous behaviour.
- Stop tracking the published Filament assets under /public/fonts/filament and
  add them to .gitignore, as recommended by the Filament docs. They are
  regenerated by filament:assets via the filament:upgrade post-autoload-dump
  hook. /public/css and /public/js are already ignored.
- Drop the now dead theme.css filter from the Vite asset list in app.blade.php,
  since the custom Filament theme was removed along with viteTheme().
2026-09-22 13:29:10 +02:00
Constantin Graf
76c41e633f Add psysh to gitignore 2026-09-22 13:15:07 +02:00
Constantin Graf
0e7f4b9fcc Add additional tests for filament 2026-09-22 12:49:24 +02:00
Constantin Graf
7e506d9fab Add github action to prevent non-tag refs in manifest.json 2026-09-22 12:35:47 +02:00
Constantin Graf
9b123afee5 Updated compose dependencies 2026-09-22 12:12:02 +02:00
90 changed files with 1066 additions and 1676 deletions

View File

@@ -103,8 +103,6 @@ jobs:
{
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
echo "auditing_repository=$(jq -r '.Auditing.repository' extensions/manifest.json)"
echo "auditing_ref=$(jq -r '.Auditing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout invoicing extension"
@@ -121,26 +119,9 @@ jobs:
- name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci
- name: "Checkout auditing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.auditing_repository }}
ref: ${{ steps.extension-manifest.outputs.auditing_ref }}
path: extensions/Auditing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_AUDITING_EXTENSION }}
- name: "Install composer dependencies in auditing extension"
run: cd extensions/Auditing && composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
- name: "Install npm dependencies in auditing extension"
run: cd extensions/Auditing && npm ci
- name: "Activate invoicing extension"
run: php artisan module:enable Invoicing
- name: "Activate auditing extension"
run: php artisan module:enable Auditing
- name: "Install npm dependencies"
run: npm ci

View File

@@ -84,8 +84,6 @@ jobs:
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
echo "auditing_repository=$(jq -r '.Auditing.repository' extensions/manifest.json)"
echo "auditing_ref=$(jq -r '.Auditing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout billing extension"
@@ -147,25 +145,6 @@ jobs:
- name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci
- name: "Checkout auditing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.auditing_repository }}
ref: ${{ steps.extension-manifest.outputs.auditing_ref }}
path: extensions/Auditing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_AUDITING_EXTENSION }}
- name: "Install composer dependencies in auditing extension"
uses: php-actions/composer@v6
with:
working_dir: "extensions/Auditing"
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 auditing extension"
run: cd extensions/Auditing && npm ci
- name: "Setup PHP with PECL extension"
uses: shivammathur/setup-php@v2
with:
@@ -189,9 +168,6 @@ jobs:
- name: "Activate invoicing extension"
run: php artisan module:enable Invoicing
- name: "Activate auditing extension"
run: php artisan module:enable Auditing
- name: "Install npm dependencies"
run: npm ci

View File

@@ -1,8 +1,6 @@
name: NPM Format Check
on: [push]
permissions:
contents: read
jobs:
format-check:

View File

@@ -8,7 +8,7 @@ jobs:
timeout-minutes: 15
strategy:
matrix:
postgres_version: [ 15, 16, 17, 18 ]
postgres_version: [ 15, 16, 17 ]
services:
pgsql_test:

View File

@@ -8,7 +8,7 @@ jobs:
timeout-minutes: 10
strategy:
matrix:
postgres_version: [ 15, 16, 17, 18 ]
postgres_version: [ 15, 16, 17 ]
services:
pgsql_test:

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\AuditResource\Pages\CreateAudit;
use App\Filament\Resources\AuditResource\Pages\ListAudits;
use App\Filament\Resources\AuditResource\Pages\ViewAudit;
use App\Models\Audit;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
class AuditResource extends Resource
{
protected static ?string $model = Audit::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-archive-box';
protected static string|\UnitEnum|null $navigationGroup = 'System';
public static function form(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('user_type')
->maxLength(255),
TextInput::make('user_id'),
TextInput::make('event')
->required()
->maxLength(255),
TextInput::make('auditable_type')
->required()
->maxLength(255),
TextInput::make('auditable_id')
->required(),
PrettyJsonField::make('old_values'),
PrettyJsonField::make('new_values'),
Textarea::make('url'),
TextInput::make('ip_address'),
TextInput::make('user_agent')
->maxLength(1023),
TextInput::make('tags')
->maxLength(255),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('user.name'),
TextColumn::make('event'),
TextColumn::make('auditable_type'),
TextColumn::make('auditable_id'),
IconColumn::make('was_command')
->getStateUsing(fn (Audit $record) => Str::startsWith($record->url, 'artisan '))
->boolean(),
TextColumn::make('created_at')
->sortable()
->dateTime(),
TextColumn::make('updated_at')
->sortable()
->dateTime(),
])
->filters([
//
])
->recordActions([
ViewAction::make(),
])
->toolbarActions([
])
->defaultSort('created_at', 'desc');
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => ListAudits::route('/'),
'create' => CreateAudit::route('/create'),
'view' => ViewAudit::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\CreateRecord;
class CreateAudit extends CreateRecord
{
protected static string $resource = AuditResource::class;
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\ListRecords;
class ListAudits extends ListRecords
{
protected static string $resource = AuditResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\ViewRecord;
class ViewAudit extends ViewRecord
{
protected static string $resource = AuditResource::class;
}

View File

@@ -23,7 +23,7 @@ use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\ReportDto;
use App\Service\Import\ImportService;
use App\Service\TimezoneService;
use Brick\Money\IsoCurrencyProvider;
use Brick\Money\ISOCurrencyProvider;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
@@ -89,7 +89,7 @@ class OrganizationResource extends Resource
Select::make('currency')
->label('Currency')
->options(function (): array {
$currencies = IsoCurrencyProvider::getInstance()->getAvailableCurrencies();
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
$select = [];
foreach ($currencies as $currency) {
$select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')';

View File

@@ -98,10 +98,9 @@ class UsersRelationManager extends RelationManager
app(BillableRateService::class)->updateTimeEntriesBillableRateForMember($member);
}
$newRole = $data['role'] instanceof Role ? $data['role'] : Role::from($data['role']);
if ($newRole->value !== $member->role) {
if ($data['role'] !== $member->role) {
try {
app(MemberService::class)->changeRole($member, $organization, $newRole, true);
app(MemberService::class)->changeRole($member, $organization, ($data['role'] instanceof Role ? $data['role'] : Role::from($data['role'])), true);
} catch (ApiException $exception) {
Notification::make()
->danger()

View File

@@ -16,7 +16,7 @@ use App\Models\User;
use App\Service\DeletionService;
use App\Service\TimezoneService;
use App\Service\UserService;
use Brick\Money\IsoCurrencyProvider;
use Brick\Money\ISOCurrencyProvider;
use Exception;
use Filament\Actions\BulkAction;
use Filament\Actions\DeleteAction;
@@ -115,7 +115,7 @@ class UserResource extends Resource
Select::make('currency')
->label('Currency (Personal Organization)')
->options(function (): array {
$currencies = IsoCurrencyProvider::getInstance()->getAvailableCurrencies();
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
$select = [];
foreach ($currencies as $currency) {
$select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')';

View File

@@ -59,10 +59,9 @@ class OrganizationsRelationManager extends RelationManager
/** @var Member $member */
$member = $record->getRelation('membership');
$newRole = $data['role'] instanceof Role ? $data['role'] : Role::from($data['role']);
if ($newRole->value !== $member->role) {
if ($data['role'] !== $member->role) {
try {
app(MemberService::class)->changeRole($member, $record, $newRole, true);
app(MemberService::class)->changeRole($member, $record, ($data['role'] instanceof Role ? $data['role'] : Role::from($data['role'])), true);
} catch (ApiException $exception) {
Notification::make()
->danger()

View File

@@ -7,7 +7,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Service\CurrencyService;
use Brick\Money\Currency;
use Brick\Money\IsoCurrencyProvider;
use Brick\Money\ISOCurrencyProvider;
use Illuminate\Http\JsonResponse;
class CurrencyController extends Controller
@@ -29,7 +29,7 @@ class CurrencyController extends Controller
'name' => $currency->getName(),
'symbol' => $currencyService->getCurrencySymbol($currency->getCurrencyCode()),
],
IsoCurrencyProvider::getInstance()->getAvailableCurrencies()
ISOCurrencyProvider::getInstance()->getAvailableCurrencies()
));
return response()->json($currencies);

View File

@@ -191,9 +191,8 @@ class TimeEntryController extends Controller
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
if ($roundingType !== null && $roundingMinutes !== null) {
$select = array_diff($select, ['start', 'end']);
// These SQL fragments are generated from a rounding enum and validated minute count.
$select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes).' as start'); // @phpstan-ignore argument.type
$select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes).' as end'); // @phpstan-ignore argument.type
$select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes).' as start');
$select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes).' as end');
}
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization')

View File

@@ -6,7 +6,7 @@ namespace App\Http\Controllers\Web;
use App\Models\Organization;
use Brick\Money\Currency;
use Brick\Money\IsoCurrencyProvider;
use Brick\Money\ISOCurrencyProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
@@ -53,7 +53,7 @@ class OrganizationController extends Controller
],
'currencies' => array_map(function (Currency $currency): string {
return $currency->getName();
}, IsoCurrencyProvider::getInstance()->getAvailableCurrencies()),
}, ISOCurrencyProvider::getInstance()->getAvailableCurrencies()),
'permissions' => [
'canDeleteTeam' => $this->hasPermission($organization, 'organizations:delete'),
'canUpdateTeam' => $this->hasPermission($organization, 'organizations:update'),

View File

@@ -11,7 +11,6 @@ use App\Http\Middleware\EnsureEmailIsVerified;
use App\Http\Middleware\ForceHttps;
use App\Http\Middleware\ForceJsonResponse;
use App\Http\Middleware\HandleInertiaRequests;
use App\Http\Middleware\PreventRequestForgery;
use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\ShareInertiaData;
@@ -19,6 +18,7 @@ use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustHosts;
use App\Http\Middleware\TrustProxies;
use App\Http\Middleware\ValidateSignature;
use App\Http\Middleware\VerifyCsrfToken;
use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
use Illuminate\Auth\Middleware\Authorize;
use Illuminate\Auth\Middleware\RequirePassword;
@@ -68,7 +68,7 @@ class Kernel extends HttpKernel
AddQueuedCookiesToResponse::class,
StartSession::class,
ShareErrorsFromSession::class,
PreventRequestForgery::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
HandleInertiaRequests::class,
ShareInertiaData::class,

View File

@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Filament\Http\Middleware\Authenticate as Middleware;
use Illuminate\Http\Request;
class AuthenticateFilamentPanel extends Middleware
{
/**
* The admin panel has no login page of its own (it shares the app's
* session-based login), so Filament::getLoginUrl() always returns null.
* Fall back to the app's login route so guests are redirected instead
* of receiving an empty 401 response.
*/
protected function redirectTo($request): ?string
{
return parent::redirectTo($request) ?? ($request->expectsJson() ? null : route('login'));
}
}

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery as Middleware;
class PreventRequestForgery extends Middleware
{
/**
* The URIs that should be excluded.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

View File

@@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
@@ -99,7 +100,7 @@ class Organization extends Model implements AuditableContract
/**
* Get all the users that belong to the team.
*
* @return BelongsToMany<User, $this, Member, 'membership'>
* @return BelongsToMany<User, $this, Pivot, 'membership'>
*/
public function users(): BelongsToMany
{
@@ -132,7 +133,7 @@ class Organization extends Model implements AuditableContract
}
/**
* @return BelongsToMany<User, $this, Member, 'membership'>
* @return BelongsToMany<User, $this, Pivot, 'membership'>
*/
public function realUsers(): BelongsToMany
{

View File

@@ -20,6 +20,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
@@ -174,7 +175,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
}
/**
* @return BelongsToMany<Organization, $this, Member, 'membership'>
* @return BelongsToMany<Organization, $this, Pivot, 'membership'>
*/
public function organizations(): BelongsToMany
{
@@ -189,7 +190,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
}
/**
* @return BelongsToMany<Organization, $this, Member, 'membership'>
* @return BelongsToMany<Organization, $this, Pivot, 'membership'>
*/
public function ownedOrganizations(): BelongsToMany
{

View File

@@ -9,7 +9,7 @@ use App\Filament\Widgets\ServerOverview;
use App\Filament\Widgets\TimeEntriesCreated;
use App\Filament\Widgets\TimeEntriesImported;
use App\Filament\Widgets\UserRegistrations;
use App\Http\Middleware\AuthenticateFilamentPanel;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\NavigationGroup;
@@ -19,7 +19,7 @@ use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
@@ -71,9 +71,6 @@ class AdminPanelProvider extends PanelProvider
NavigationGroup::make()
->label('Auth')
->collapsed(),
NavigationGroup::make()
->label('Self Hosting')
->collapsed(),
])
->middleware([
EncryptCookies::class,
@@ -81,13 +78,13 @@ class AdminPanelProvider extends PanelProvider
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
PreventRequestForgery::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
AuthenticateFilamentPanel::class,
Authenticate::class,
]);
$modules = Module::allEnabled();

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Rules;
use Brick\Money\IsoCurrencyProvider;
use Brick\Money\ISOCurrencyProvider;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
@@ -24,7 +24,7 @@ class CurrencyRule implements ValidationRule
return;
}
$currencies = IsoCurrencyProvider::getInstance()->getAvailableCurrencies();
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
if (array_key_exists($value, $currencies)) {
return;
}

View File

@@ -15,18 +15,12 @@ use App\Models\User;
use Exception;
use Illuminate\Support\Facades\Http;
use Log;
use Nwidart\Modules\Facades\Module;
use Throwable;
class ApiService
{
private const string API_URL = 'https://app.solidtime.io/api/v1';
private function isPremium(): bool
{
return Module::has('Invoicing');
}
public function checkForUpdate(): ?string
{
try {
@@ -37,7 +31,6 @@ class ApiService
'version' => config('app.version'),
'build' => config('app.build'),
'url' => config('app.url'),
'is_premium' => $this->isPremium(),
]);
if ($response->status() === 200 && isset($response->json()['version']) && is_string($response->json()['version'])) {
@@ -69,7 +62,6 @@ class ApiService
'version' => config('app.version'),
'build' => config('app.build'),
'url' => config('app.url'),
'is_premium' => $this->isPremium(),
// telemetry data
'user_count' => User::count(),
'organization_count' => Organization::count(),

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Service;
use Brick\Money\IsoCurrencyProvider;
use Brick\Money\ISOCurrencyProvider;
use Brick\Money\Money;
class CurrencyService
@@ -378,7 +378,7 @@ class CurrencyService
public function getRandomCurrencyCode(): string
{
$currencies = IsoCurrencyProvider::getInstance()->getAvailableCurrencies();
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
$currencyCodes = array_keys($currencies);
return $currencyCodes[array_rand($currencyCodes)];

View File

@@ -152,13 +152,12 @@ class DashboardService
$possibleDays = $this->lastDays($days, $timezone);
// The SQL fragment contains only a timezone offset calculated as an integer.
$query = TimeEntry::query()
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) // @phpstan-ignore argument.type
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey())
->workTime()
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')')) // @phpstan-ignore argument.type
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
->orderBy('date');
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
@@ -195,13 +194,12 @@ class DashboardService
}
$possibleDays = $this->daysOfThisWeek($timezone, $user->week_start);
// The SQL fragment contains only a timezone offset calculated as an integer.
$query = TimeEntry::query()
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) // @phpstan-ignore argument.type
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey())
->workTime()
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')')) // @phpstan-ignore argument.type
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
->orderBy('date');
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);

View File

@@ -229,6 +229,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
'Project',
'Description',
'User',
'Group',
'Email',
'Tags',
'Start Date',

View File

@@ -75,9 +75,8 @@ class TimeEntryAggregationService
$startRawSelect = app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes);
$endRawSelect = app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes);
// SQL fragments come from enum cases, integer offsets, and formatted dates.
$timeEntriesQuery->selectRaw(
($group1Select !== null ? $group1Select.' as group_1,' : ''). // @phpstan-ignore argument.type
($group1Select !== null ? $group1Select.' as group_1,' : '').
($group2Select !== null ? $group2Select.' as group_2,' : '').
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
@@ -106,7 +105,7 @@ class TimeEntryAggregationService
$baseTotalsPerGroup1Query = $baseTotalsQuery->clone();
$baseTotalsPerGroup1 = $baseTotalsPerGroup1Query
->selectRaw(
$group1Select.' as group_1,'. // @phpstan-ignore argument.type
$group1Select.' as group_1,'.
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
)
@@ -171,7 +170,7 @@ class TimeEntryAggregationService
// Reset selects and ordering on the cloned base query
$baseTotals = $baseTotalsQuery
->selectRaw(
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'. // @phpstan-ignore argument.type
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
)
->first();

View File

@@ -7,22 +7,22 @@
"require": {
"php": "8.3.*",
"ext-zip": "*",
"brick/money": "^0.15.1",
"brick/money": "^0.11.2",
"datomatic/laravel-enum-helper": "^2.0.0",
"dedoc/scramble": "^0.13.43",
"filament/filament": "^5.0",
"flowframe/laravel-trend": "^0.5.0",
"gotenberg/gotenberg-php": "^2.8",
"guzzlehttp/guzzle": "^8.2",
"guzzlehttp/guzzle": "^7.2",
"inertiajs/inertia-laravel": "^3.3.4",
"korridor/laravel-computed-attributes": "^3.1",
"korridor/laravel-has-many-sync": "^3.1",
"korridor/laravel-model-validation-rules": "^3.0",
"laravel/fortify": "^1.37",
"laravel/framework": "^13.0",
"laravel/framework": "^12.19.3",
"laravel/octane": "^2.3",
"laravel/passport": "^13.0.5",
"laravel/tinker": "^3.0",
"laravel/tinker": "^2.8",
"league/csv": "^9.16.0",
"league/flysystem-aws-s3-v3": "^3.0",
"league/iso3166": "^4.3",

694
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,9 +24,9 @@ return [
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| This value is the name of your application. This value is used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
| any other location as required by the application or its packages.
|
*/
@@ -110,8 +110,8 @@ return [
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
| will be used by the PHP date and date-time functions. We have gone
| ahead and set this to a sensible default for you out of the box.
|
*/
@@ -123,8 +123,8 @@ return [
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
| by the translation service provider. You are free to set this value
| to any of the locales which will be supported by the application.
|
*/
@@ -178,21 +178,15 @@ return [
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
| This key is used by the Illuminate encrypter service and should be set
| to a random, 32 character string, otherwise these encrypted strings
| will not be safe. Please do this before deploying an application!
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
'cipher' => 'AES-256-CBC',
'localization' => [
'default_currency' => env('LOCALIZATION_DEFAULT_CURRENCY', 'EUR'),
@@ -212,13 +206,13 @@ return [
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache", "array"
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
'driver' => 'file',
// 'store' => 'redis',
],
/*

View File

@@ -10,8 +10,8 @@ return [
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| This option controls the default authentication "guard" and password
| reset options for your application. You may change these defaults
| as required, but they're a perfect start for most applications.
|
*/
@@ -28,11 +28,11 @@ return [
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
| here which uses session storage and the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| All authentication drivers have a user provider. This defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
| mechanisms used by this application to persist your user's data.
|
| Supported: "session"
|
@@ -55,12 +55,12 @@ return [
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| All authentication drivers have a user provider. This defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
| mechanisms used by this application to persist your user's data.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| sources which represent each model / table. These sources may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
@@ -80,9 +80,9 @@ return [
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
| You may specify multiple password reset configurations if you have more
| than one user table or model in the application and you want to have
| separate password reset settings based on the specific user types.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
@@ -108,13 +108,13 @@ return [
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| Here you may define the amount of seconds before a password confirmation
| times out and the user is prompted to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
'password_timeout' => 10800,
'super_admins' => ! is_string(env('SUPER_ADMINS', null)) ? [] : explode(',', env('SUPER_ADMINS')),

View File

@@ -11,9 +11,9 @@ return [
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
| This option controls the default cache connection that gets used while
| using this caching library. This connection is used when another is
| not explicitly specified when executing a given caching function.
|
*/
@@ -28,9 +28,8 @@ return [
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "storage", "octane",
| "session", "failover", "null"
| Supported drivers: "apc", "array", "database", "file",
| "memcached", "redis", "dynamodb", "octane", "null"
|
*/
@@ -47,10 +46,9 @@ return [
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
'table' => 'cache',
'connection' => null,
'lock_connection' => null,
],
'file' => [
@@ -59,12 +57,6 @@ return [
'lock_path' => storage_path('framework/cache/data'),
],
'storage' => [
'driver' => 'storage',
'disk' => env('CACHE_STORAGE_DISK'),
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
@@ -86,8 +78,8 @@ return [
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
'connection' => 'cache',
'lock_connection' => 'default',
],
'dynamodb' => [
@@ -103,14 +95,6 @@ return [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
@@ -118,24 +102,12 @@ return [
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| When utilizing the APC, database, memcached, Redis, or DynamoDB cache
| stores there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false,
];

View File

@@ -12,22 +12,26 @@ return [
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
| to use as your default connection for all database work. Of course
| you may use many connections at once using the Database library.
|
*/
'default' => env('DB_CONNECTION', 'pgsql'),
'default' => env('DB_CONNECTION', 'mysql'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
| Here are each of the database connections setup for your application.
| Of course, examples of configuring each database platform that is
| supported by Laravel is shown below to make development simple.
|
|
| All database work in Laravel is done through the PHP PDO facilities
| so make sure you have the driver for your particular database of
| choice installed on your machine before you begin development.
|
*/
@@ -35,34 +39,50 @@ return [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'url' => env('DATABASE_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
'sslmode' => 'prefer',
],
'pgsql_test' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'url' => env('DATABASE_URL'),
'host' => env('DB_TEST_HOST', '127.0.0.1'),
'port' => env('DB_TEST_PORT', '5432'),
'database' => env('DB_TEST_DATABASE', 'forge'),
@@ -72,18 +92,18 @@ return [
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
'sslmode' => 'prefer',
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
@@ -99,14 +119,11 @@ return [
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
| the migrations on disk haven't actually been run in the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => false,
],
'migrations' => 'migrations',
/*
|--------------------------------------------------------------------------
@@ -115,7 +132,7 @@ return [
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
| such as APC or Memcached. Laravel makes it easy to dig right in.
|
*/
@@ -126,7 +143,6 @@ return [
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
@@ -136,10 +152,6 @@ return [
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
@@ -149,10 +161,6 @@ return [
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],

View File

@@ -11,7 +11,7 @@ return [
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
| based disks are available to your application. Just store away!
|
*/
@@ -26,11 +26,11 @@ return [
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
| Here you may configure as many filesystem "disks" as you wish, and you
| may even configure multiple disks of the same driver. Defaults have
| been set up for each driver as an example of the required values.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
| Supported Drivers: "local", "ftp", "sftp", "s3"
|
*/
@@ -41,16 +41,14 @@ return [
'root' => storage_path('app'),
'serve' => true,
'throw' => true,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
'throw' => true,
'report' => false,
],
's3' => [
@@ -64,7 +62,6 @@ return [
'endpoint' => env('S3_ENDPOINT'),
'use_path_style_endpoint' => env('S3_USE_PATH_STYLE_ENDPOINT', false),
'throw' => true,
'report' => false,
],
'testfiles' => [

View File

@@ -14,9 +14,9 @@ return [
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
| This option defines the default log channel that gets used when writing
| messages to the logs. The name specified in this option should match
| one of the channels defined in the "channels" configuration array.
|
*/
@@ -43,20 +43,20 @@ return [
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
| Here you may configure the log channels for your application. Out of
| the box, Laravel uses the Monolog PHP logging library. This gives
| you a variety of powerful log handlers / formatters to utilize.
|
| Available drivers: "single", "daily", "monthly", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
| Available Drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog",
| "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'channels' => ['single'],
'ignore_exceptions' => false,
],
@@ -81,23 +81,15 @@ return [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'max_files' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'monthly' => [
'driver' => 'monthly',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'max_files' => 3,
'days' => 14,
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'username' => 'Laravel Log',
'emoji' => ':boom:',
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
@@ -124,17 +116,17 @@ return [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'formatter' => env('LOG_STDERR_FORMATTER'),
'with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'facility' => LOG_USER,
'replace_placeholders' => true,
],

View File

@@ -9,10 +9,9 @@ return [
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
| This option controls the default mailer that is used to send any email
| messages sent by your application. Alternative mailers may be setup
| and used as needed; however, this mailer will be used by default.
|
*/
@@ -27,28 +26,26 @@ return [
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
| Laravel supports a variety of mail "transport" drivers to be used while
| sending an e-mail. You will specify which one you are using for your
| mailers below. You are free to add additional mailers as required.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
| "postmark", "log", "array", "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
'port' => env('MAIL_PORT', 587),
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
'local_domain' => env('MAIL_EHLO_DOMAIN'),
],
'ses' => [
@@ -57,14 +54,17 @@ return [
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'message_stream_id' => null,
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
'mailgun' => [
'transport' => 'mailgun',
// 'client' => [
// 'timeout' => 5,
// ],
],
'sendmail' => [
@@ -87,7 +87,6 @@ return [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
@@ -96,9 +95,7 @@ return [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
@@ -106,15 +103,15 @@ return [
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
| You may wish for all e-mails sent by your application to be sent from
| the same address. Here, you may specify a name and address that is
| used globally for all e-mails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
'name' => env('MAIL_FROM_NAME', 'Example'),
],
'reply_to' => [

View File

@@ -9,9 +9,9 @@ return [
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
| Laravel's queue API supports an assortment of back-ends via a single
| API, giving you convenient access to each back-end using the same
| syntax for every one. Here you may define a default connection.
|
*/
@@ -22,12 +22,11 @@ return [
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
| Here you may configure the connection information for each server that
| is used by your application. A default configuration has been added
| for each back-end shipped with Laravel. You are free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null"
|
*/
@@ -39,18 +38,17 @@ return [
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'table' => 'jobs',
'queue' => 'default',
'retry_after' => 90,
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'host' => 'localhost',
'queue' => 'default',
'retry_after' => 90,
'block_for' => 0,
'after_commit' => false,
],
@@ -68,29 +66,13 @@ return [
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'retry_after' => 90,
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
@@ -105,7 +87,7 @@ return [
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'database' => env('DB_CONNECTION', 'mysql'),
'table' => 'job_batches',
],
@@ -115,16 +97,14 @@ return [
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
| can control which database and table are used to store the jobs that
| have failed. You may change them to any database / table you wish.
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'database' => env('DB_CONNECTION', 'mysql'),
'table' => 'failed_jobs',
],

View File

@@ -9,12 +9,12 @@ return [
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
| This option controls the default session "driver" that will be used on
| requests. By default, we will use the lightweight native driver but
| you may specify any of the other wonderful drivers provided here.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
| Supported: "file", "cookie", "database", "apc",
| "memcached", "redis", "dynamodb", "array"
|
*/
@@ -27,14 +27,13 @@ return [
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
| to immediately expire on the browser closing, set that option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'lifetime' => env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
'expire_on_close' => false,
/*
|--------------------------------------------------------------------------
@@ -80,24 +79,24 @@ return [
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
| When using the "database" session driver, you may specify the table we
| should use to manage the sessions. Of course, a sensible default is
| provided for you; however, you are free to change this as needed.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
'table' => 'sessions',
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
| While using one of the framework's cache driven session backends you may
| list a cache store that should be used for these sessions. This value
| must match with one of the application's configured cache "stores".
|
| Affects: "dynamodb", "memcached", "redis"
| Affects: "apc", "dynamodb", "memcached", "redis"
|
*/
@@ -121,9 +120,9 @@ return [
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
| Here you may change the name of the cookie used to identify a session
| instance by ID. The name specified here will get used every time a
| new session cookie is created by the framework for every driver.
|
*/
@@ -139,20 +138,20 @@ return [
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
| your application but you are free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
'path' => '/',
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed.
| Here you may change the domain of the cookie used to identify a session
| in your application. This will determine which domains the cookie is
| available to in your application. A sensible default has been set.
|
*/
@@ -178,11 +177,11 @@ return [
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
| the HTTP protocol. You are free to modify this option if needed.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
'http_only' => true,
/*
|--------------------------------------------------------------------------
@@ -191,15 +190,13 @@ return [
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
| will set this value to "lax" since this is a secure default value.
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
'same_site' => 'lax',
/*
|--------------------------------------------------------------------------
@@ -212,22 +209,6 @@ return [
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
/*
|--------------------------------------------------------------------------
| Session Serialization
|--------------------------------------------------------------------------
|
| This value controls the serialization strategy for session data, which
| is JSON by default. Setting this to "php" allows the storage of PHP
| objects in the session but can make an application vulnerable to
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
| Supported: "json", "php"
|
*/
'serialization' => 'php',
'partitioned' => false,
];

View File

@@ -19,6 +19,7 @@ const resourcePages = [
{ path: '/admin/project-members', heading: 'Project Members' },
{ path: '/admin/tokens', heading: 'Tokens' },
{ path: '/admin/failed-jobs', heading: 'Failed Jobs' },
{ path: '/admin/audits', heading: 'Audits' },
];
test.describe('Admin Panel Access', () => {

View File

@@ -252,19 +252,19 @@ test('test that calendar page loads and displays time entries', async ({ page, c
test('test that calendar navigation buttons work', async ({ page }) => {
await goToCalendar(page);
await expect(page.locator('.fc')).toBeVisible();
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
// Navigate between named periods.
// Click the "next" button to navigate forward
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.locator('.fc')).toBeVisible();
// Click the "prev" button to navigate back
await page.getByRole('button', { name: 'Previous' }).click();
await expect(page.getByTestId('calendar-title')).toContainText('Last Week');
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
await expect(page.locator('.fc')).toBeVisible();
// Navigate forward first, then use the range button to return to today.
// Navigate forward first, then click today
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByTestId('calendar-title')).toContainText('Next Week');
await page.getByRole('button', { name: 'today' }).click();
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
await expect(page.locator('.fc')).toBeVisible();
});
test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => {

View File

@@ -18,25 +18,6 @@ import {
// Each test registers a new user and creates test data via API
test.describe.configure({ timeout: 30000 });
test('date range picker labels and navigates reporting periods', async ({ page }) => {
await goToReporting(page);
const range = page.getByTestId('date_range_picker_display');
const previous = page.getByTestId('date_range_picker_previous');
const next = page.getByTestId('date_range_picker_next');
await expect(range).toContainText('Last 14 Days');
await expect(next).toBeDisabled();
await Promise.all([waitForReportingUpdate(page), previous.click()]);
await expect(range).not.toContainText('Last 14 Days');
// The initial reporting range is still fresh in Vue Query's cache, so returning to it
// does not necessarily trigger another aggregate request.
await next.click();
await expect(range).toContainText('Last 14 Days');
});
// ──────────────────────────────────────────────────
// Project Multiselect Dropdown Tests
// ──────────────────────────────────────────────────

View File

@@ -1579,57 +1579,6 @@ test('test that project selection works in create modal', async ({ page, ctx })
expect(createBody.data.project_id).not.toBeNull();
});
test('test that ctrl+enter submits the create modal after selecting a project via keyboard', async ({
page,
ctx,
}) => {
// Regression test for https://github.com/solidtime-io/solidtime/issues/1238
const projectName = 'Keyboard Submit Project ' + Math.floor(1 + Math.random() * 10000);
await createProjectViaApi(ctx, { name: projectName });
await goToTimeOverview(page);
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// The menu that opened the modal animates out and only then hands focus back to its
// own trigger.
await expect(page.locator('[role="menu"]')).toHaveCount(0);
// Fill the description, then move to the project dropdown and select a project purely via keyboard
const description = page.getByRole('dialog').getByRole('textbox', { name: 'Description' });
await description.fill('Keyboard submit test');
await description.press('Tab');
await expect(
page.getByRole('dialog').getByRole('button', { name: 'No Project' })
).toBeFocused();
await page.keyboard.press('Enter');
await page.getByTestId('client_dropdown_search').fill(projectName);
await expect(page.getByRole('option', { name: projectName })).toBeVisible();
await page.keyboard.press('Enter');
const projectTrigger = page.getByRole('dialog').getByRole('button', { name: projectName });
await expect(projectTrigger).toBeVisible();
// The trigger label updates on the next tick, but the dropdown keeps focus until its
// exit animation has finished and reka-ui hands focus back to the trigger.
await expect(projectTrigger).toBeFocused();
// Ctrl+Enter must submit even though focus is no longer on the description input
const [createResponse] = await Promise.all([
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
page.keyboard.press('Control+Enter'),
]);
const createBody = await createResponse.json();
expect(createBody.data.description).toBe('Keyboard submit test');
expect(createBody.data.project_id).not.toBeNull();
await expect(page.getByRole('dialog')).toBeHidden();
const newTimeEntry = page.locator('[data-testid="time_entry_row"]').first();
await expect(newTimeEntry.getByTestId('time_entry_description').first()).toHaveValue(
'Keyboard submit test'
);
});
test('test that tag selection works in create modal', async ({ page }) => {
await goToTimeOverview(page);

View File

@@ -409,20 +409,20 @@ test('navigating to previous week shows entries from that week', async ({ page,
test('can navigate forward and return to current week', async ({ page }) => {
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Should show "This Week"
await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week');
// Should show "This week"
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
// Go to next week — the text assertions below auto-retry until the
// header label flips.
await page.getByTestId('timesheet_next_week').click();
// Should no longer show "This Week"
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This Week');
// Should no longer show "This week"
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This week');
// Go back to this week
await page.getByTestId('timesheet_week_display').click();
await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week');
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
});
// ──────────────────────────────────────────────────

View File

@@ -68,7 +68,7 @@ export async function getPasswordResetUrl(
for (let attempt = 0; attempt < 5; attempt++) {
searchResult = await searchEmails(
request,
`to:${encodeURIComponent(recipientEmail)} subject:"Reset your password"`
`to:${encodeURIComponent(recipientEmail)} subject:"Reset Password"`
);
if (searchResult.messages.length > 0) break;
await new Promise((resolve) => setTimeout(resolve, 500));

View File

@@ -1,18 +1,14 @@
{
"Billing": {
"repository": "solidtime-io/extension-billing",
"ref": "v0.0.7"
"ref": "feature/composer-update"
},
"Services": {
"repository": "solidtime-io/extension-services",
"ref": "v0.0.3"
"ref": "feature/composer-update"
},
"Invoicing": {
"repository": "solidtime-io/extension-invoicing",
"ref": "v0.0.7"
},
"Auditing": {
"repository": "solidtime-io/extension-auditing",
"ref": "v0.0.2"
"ref": "feature/composer-update"
}
}

View File

@@ -34,7 +34,6 @@
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="DB_CONNECTION" value="pgsql_test"/>
<env name="FILESYSTEM_DISK" value="local"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>

View File

@@ -28,7 +28,7 @@ useFocus(clientNameInput, { initialValue: true });
</script>
<template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Create Client </span>

View File

@@ -30,7 +30,7 @@ useFocus(clientNameInput, { initialValue: true });
</script>
<template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Update Client </span>

View File

@@ -136,7 +136,7 @@ const roleDescription = computed(() => {
v-model:show="showOwnershipTransferConfirmModal"
:member-name="member.name"
@submit="submit"></MemberOwnershipTransferConfirmModal>
<DialogModal closeable :show="show" @close="show = false" @submit="saveWithChecks()">
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Update Member </span>

View File

@@ -78,7 +78,7 @@ useFocus(clientNameInput, { initialValue: true });
</script>
<template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Invite Member </span>

View File

@@ -79,7 +79,7 @@ async function submitBillableRate() {
</script>
<template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Edit Project {{ props.originalProject.name }} </span>

View File

@@ -38,7 +38,7 @@ useFocus(projectNameInput, { initialValue: true });
</script>
<template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span>Add Project Member</span>

View File

@@ -63,7 +63,7 @@ useFocus(projectNameInput, { initialValue: true });
</script>
<template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span>Edit Project Member</span>

View File

@@ -79,7 +79,7 @@ async function submit() {
</script>
<template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Create Report </span>

View File

@@ -96,7 +96,7 @@ async function submit() {
</script>
<template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Create Report </span>

View File

@@ -57,7 +57,7 @@ const { handleApiRequestNotifications } = useNotificationsStore();
const startDate = useSessionStorage<string>(
'reporting-start-date',
getLocalizedDayJs(getDayJsInstance()().format()).subtract(13, 'd').format()
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
);
const endDate = useSessionStorage<string>(
'reporting-end-date',

View File

@@ -36,7 +36,7 @@ useFocus(tagNameInput, { initialValue: true });
</script>
<template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Update Tag </span>

View File

@@ -50,7 +50,7 @@ useFocus(taskNameInput, { initialValue: true });
</script>
<template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Create Task </span>

View File

@@ -35,7 +35,7 @@ useFocus(taskNameInput, { initialValue: true });
</script>
<template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Update Task </span>

View File

@@ -179,7 +179,7 @@ async function submit() {
</script>
<template>
<DialogModal closeable :show="show" @close="emit('cancel')" @submit="submit">
<DialogModal closeable :show="show" @close="emit('cancel')">
<template #title>
<div class="flex items-center space-x-2">
<Coffee class="w-5 h-5 text-text-secondary" />

View File

@@ -1,9 +1,11 @@
<script setup lang="ts">
import DateRangeNavigator from '@/packages/ui/src/Input/DateRangeNavigator.vue';
import { Button } from '@/packages/ui/src/Buttons';
import { ChevronLeftIcon, ChevronRightIcon, CalendarIcon } from '@heroicons/vue/20/solid';
defineProps<{
isCurrentWeek: boolean;
weekNumber: number;
rangeLabel: string;
weekRangeDisplay: string;
weekTotalFormatted: string;
}>();
@@ -17,16 +19,33 @@ defineEmits<{
<template>
<div class="flex flex-wrap items-center justify-between gap-4 mb-4 px-2 sm:px-4 lg:px-6">
<!-- Left: Week navigation -->
<DateRangeNavigator
:label="rangeLabel"
:secondary-label="`W${weekNumber}`"
previous-test-id="timesheet_prev_week"
trigger-test-id="timesheet_week_display"
next-test-id="timesheet_next_week"
trigger-aria-label="Return to current week"
@previous="$emit('previous')"
@next="$emit('next')"
@select="$emit('current')" />
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
class="h-8 w-8"
data-testid="timesheet_prev_week"
@click="$emit('previous')">
<ChevronLeftIcon class="h-4 w-4" />
</Button>
<button
data-testid="timesheet_week_display"
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-text-primary hover:bg-card-background rounded-md transition"
@click="$emit('current')">
<CalendarIcon class="h-4 w-4 text-icon-default" />
<span v-if="isCurrentWeek">This week</span>
<span v-else>{{ weekRangeDisplay }}</span>
<span class="text-text-tertiary">&middot; W{{ weekNumber }}</span>
</button>
<Button
variant="outline"
size="icon"
class="h-8 w-8"
data-testid="timesheet_next_week"
@click="$emit('next')">
<ChevronRightIcon class="h-4 w-4" />
</Button>
</div>
<!-- Right: Week total -->
<div class="flex items-center gap-2.5">

View File

@@ -61,7 +61,7 @@ type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
const startDate = useSessionStorage<string>(
'reporting-start-date',
getLocalizedDayJs(getDayJsInstance()().format()).subtract(13, 'd').format()
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
);
const endDate = useSessionStorage<string>(
'reporting-end-date',

View File

@@ -30,7 +30,6 @@ import {
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { getBreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement';
import { getDateRangeLabel } from '@/packages/ui/src/utils/dateRange';
import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek';
import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations';
import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations';
@@ -45,6 +44,7 @@ const {
weekEnd,
weekDays,
weekNumber,
isCurrentWeek,
todayDate,
goToPreviousWeek,
goToNextWeek,
@@ -109,9 +109,13 @@ const weekTotalFormatted = computed(() =>
formatHumanReadableDuration(grandTotal.value, intervalFormat.value, numberFormat.value)
);
const weekRangeLabel = computed(() =>
getDateRangeLabel(weekStart.value, weekStart.value.add(6, 'day'))
);
const weekRangeDisplay = computed(() => {
const start = weekStart.value;
const end = start.add(6, 'day');
return start.month() === end.month()
? `${start.format('MMM D')} - ${end.format('D')}`
: `${start.format('MMM D')} - ${end.format('MMM D')}`;
});
// ── Cell / row mutation handlers ──────────────────────────────────
const {
@@ -204,8 +208,9 @@ async function createTag(name: string): Promise<Tag | undefined> {
<AppLayout title="Timesheet" data-testid="timesheet_view">
<div class="pt-5 lg:pt-8 pb-4 lg:pb-6">
<TimesheetHeader
:is-current-week="isCurrentWeek"
:week-number="weekNumber"
:range-label="weekRangeLabel"
:week-range-display="weekRangeDisplay"
:week-total-formatted="weekTotalFormatted"
@previous="goToPreviousWeek"
@next="goToNextWeek"

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import Modal from './Modal.vue';
const emit = defineEmits(['close', 'submit']);
const emit = defineEmits(['close']);
defineProps({
show: {
@@ -24,12 +24,7 @@ const close = () => {
</script>
<template>
<Modal
:show="show"
:max-width="maxWidth"
:closeable="closeable"
@close="close"
@submit="emit('submit')">
<Modal :show="show" :max-width="maxWidth" :closeable="closeable" @close="close">
<div class="px-4 lg:px-6 py-4">
<div class="text-lg font-medium text-text-primary" role="heading">
<slot name="title" />

View File

@@ -1,12 +1,13 @@
<script setup lang="ts">
import { Button } from '..';
import { ChevronLeft, ChevronRight } from '@lucide/vue';
import { Tabs, TabsList } from '../tabs';
import TabBarItem from '../TabBar/TabBarItem.vue';
import DateRangeNavigator from '../Input/DateRangeNavigator.vue';
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
import type { CalendarSettings } from './calendarSettings';
defineProps<{
rangeLabel: string;
viewTitle: string;
activeView: string;
settings: CalendarSettings;
}>();
@@ -22,13 +23,31 @@ const emit = defineEmits<{
<template>
<div class="flex items-center justify-between bg-default-background px-2 py-1.5">
<DateRangeNavigator
:label="rangeLabel"
trigger-test-id="calendar-title"
trigger-aria-label="today"
@previous="emit('prev')"
@next="emit('next')"
@select="emit('today')" />
<!-- Left: Navigation -->
<div class="flex items-center gap-1">
<Button
variant="outline"
size="sm"
class="h-8 w-8 p-0"
aria-label="Previous"
@click="emit('prev')">
<ChevronLeft class="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
class="h-8 w-8 p-0"
aria-label="Next"
@click="emit('next')">
<ChevronRight class="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" @click="emit('today')"> today </Button>
</div>
<!-- Center: Title -->
<span data-testid="calendar-title" class="text-base font-semibold text-foreground">{{
viewTitle
}}</span>
<!-- Right: View switcher + Settings -->
<div class="flex items-center gap-1">

View File

@@ -138,7 +138,7 @@ const {
const {
activeView,
viewDays,
rangeLabel,
viewTitle,
emitDatesChange,
handlePrev,
handleNext,
@@ -514,7 +514,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<template v-if="!loading">
<CalendarToolbar
:range-label="rangeLabel"
:view-title="viewTitle"
:active-view="activeView"
:settings="calendarSettings"
@prev="handlePrev"

View File

@@ -2,7 +2,6 @@ import { computed, ref } from 'vue';
import type { Dayjs } from 'dayjs';
import { getLocalizedDayJs } from '../utils/time';
import { getWeekStartDayNumber } from '../utils/settings';
import { getDateRangeLabel } from '../utils/dateRange';
export function useCalendarNavigation(callbacks: {
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
@@ -35,10 +34,24 @@ export function useCalendarNavigation(callbacks: {
return days;
});
const rangeLabel = computed(() => {
const viewTitle = computed(() => {
if (activeView.value === 'timeGridDay') {
return currentDate.value.format('MMMM YYYY');
}
const days = viewDays.value;
if (days.length === 0) return '';
return getDateRangeLabel(days[0]!, days[days.length - 1]!);
const first = days[0]!;
const last = days[days.length - 1]!;
if (first.year() !== last.year()) {
return `${first.format('MMM YYYY')} \u2013 ${last.format('MMM YYYY')}`;
}
if (first.month() !== last.month()) {
return `${first.format('MMM')} \u2013 ${last.format('MMM YYYY')}`;
}
return first.format('MMMM YYYY');
});
function emitDatesChange() {
@@ -86,7 +99,7 @@ export function useCalendarNavigation(callbacks: {
activeView,
currentDate,
viewDays,
rangeLabel,
viewTitle,
emitDatesChange,
handlePrev,
handleNext,

View File

@@ -1,81 +0,0 @@
<script setup lang="ts">
import { ChevronLeft, ChevronRight, CalendarIcon } from '@lucide/vue';
import { Button } from '../Buttons';
withDefaults(
defineProps<{
label: string;
secondaryLabel?: string;
previousDisabled?: boolean;
nextDisabled?: boolean;
previousTestId?: string;
triggerTestId?: string;
nextTestId?: string;
triggerAriaLabel?: string;
}>(),
{
secondaryLabel: undefined,
previousTestId: undefined,
triggerTestId: undefined,
nextTestId: undefined,
triggerAriaLabel: undefined,
}
);
const emit = defineEmits<{
previous: [];
next: [];
select: [];
}>();
const triggerClass =
'h-8 w-[13.5rem] justify-center rounded-none border-l border-r border-border-secondary px-2.5 text-text-primary tabular-nums hover:bg-card-background-active';
</script>
<template>
<div
class="tabular-nums inline-flex items-stretch overflow-hidden rounded-md border border-input bg-card-background shadow-xs dark:bg-transparent"
role="group"
aria-label="Date range navigation">
<Button
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0 rounded-none hover:bg-card-background-active"
aria-label="Previous"
:disabled="previousDisabled"
:data-testid="previousTestId"
@click="emit('previous')">
<ChevronLeft class="h-4 w-4" />
</Button>
<slot name="trigger" :trigger-class="triggerClass">
<Button
type="button"
variant="ghost"
size="sm"
:class="triggerClass"
:aria-label="triggerAriaLabel"
:data-testid="triggerTestId"
@click="emit('select')">
<CalendarIcon class="h-4 w-4 shrink-0 text-icon-default" />
<span class="min-w-0 truncate">{{ label }}</span>
<span v-if="secondaryLabel" class="shrink-0 text-text-tertiary">
&middot; {{ secondaryLabel }}
</span>
</Button>
</slot>
<Button
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0 rounded-none hover:bg-card-background-active"
aria-label="Next"
:disabled="nextDisabled"
:data-testid="nextTestId"
@click="emit('next')">
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</template>

View File

@@ -6,22 +6,22 @@ import { CalendarDate } from '@internationalized/date';
import { CalendarIcon } from '@lucide/vue';
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
import { twMerge } from 'tailwind-merge';
import DateRangeNavigator from './DateRangeNavigator.vue';
import { getLocalizedDayJs, firstDayIndex, type WeekStartDay } from '@/packages/ui/src/utils/time';
import { getDateRangeLabel, shiftDateRange, type DateRangeDirection } from '../utils/dateRange';
import {
getDayJsInstance,
getLocalizedDayJs,
firstDayIndex,
type WeekStartDay,
} from '@/packages/ui/src/utils/time';
import { type Organization } from '@/packages/api/src';
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
import { formatDate } from '@/packages/ui/src/utils/time';
const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);
const props = withDefaults(
defineProps<{
start: string;
end: string;
allowFuture?: boolean;
}>(),
{ allowFuture: false }
);
const props = defineProps<{
start: string;
end: string;
}>();
const emit = defineEmits<{
(e: 'update:start', value: string): void;
@@ -35,13 +35,10 @@ interface CalendarDateRange {
}
const today = computed(() => {
const now = getLocalizedDayJs();
const now = getDayJsInstance()();
return new CalendarDate(now.year(), now.month() + 1, now.date());
});
const startDay = computed(() => (props.start ? getLocalizedDayJs(props.start) : undefined));
const endDay = computed(() => (props.end ? getLocalizedDayJs(props.end) : undefined));
const modelValue = computed<CalendarDateRange>({
get: () => ({
start: props.start
@@ -94,7 +91,7 @@ function setLastWeek() {
}
function setLast14Days() {
emit('update:start', getLocalizedDayJs().subtract(13, 'days').startOf('day').format());
emit('update:start', getLocalizedDayJs().subtract(14, 'days').format());
emit('update:end', getLocalizedDayJs().format());
open.value = false;
}
@@ -112,14 +109,14 @@ function setLastMonth() {
}
function setLast30Days() {
emit('update:start', getLocalizedDayJs().subtract(29, 'days').startOf('day').format());
emit('update:start', getLocalizedDayJs().subtract(30, 'days').format());
emit('update:end', getLocalizedDayJs().format());
open.value = false;
}
function setLast90Days() {
emit('update:start', getLocalizedDayJs().subtract(89, 'days').startOf('day').format());
emit('update:end', getLocalizedDayJs().format());
emit('update:start', getDayJsInstance()().subtract(90, 'days').format());
emit('update:end', getDayJsInstance()().format());
open.value = false;
}
@@ -143,38 +140,6 @@ function setLastYear() {
const organization = inject<ComputedRef<Organization>>('organization');
const displayLabel = computed(() => {
if (!startDay.value) return 'Pick a date';
if (!endDay.value) {
return getDateRangeLabel(startDay.value, startDay.value, {
dateFormat: organization?.value?.date_format,
});
}
return getDateRangeLabel(startDay.value, endDay.value, {
dateFormat: organization?.value?.date_format,
});
});
const nextRange = computed(() => {
if (!startDay.value || !endDay.value) return undefined;
return shiftDateRange(startDay.value, endDay.value, 1);
});
const nextDisabled = computed(
() =>
!props.allowFuture &&
!!nextRange.value &&
nextRange.value.start.isAfter(getLocalizedDayJs().endOf('day'))
);
function navigate(direction: DateRangeDirection) {
if (!startDay.value || !endDay.value) return;
const shifted = shiftDateRange(startDay.value, endDay.value, direction);
emit('update:start', shifted.start.format());
emit('update:end', shifted.end.format());
emit('submit');
}
watch(open, (value) => {
if (value === false) {
emit('submit');
@@ -183,120 +148,77 @@ watch(open, (value) => {
</script>
<template>
<DateRangeNavigator
:label="displayLabel"
:next-disabled="nextDisabled"
previous-test-id="date_range_picker_previous"
trigger-test-id="date_range_picker_display"
next-test-id="date_range_picker_next"
@previous="navigate(-1)"
@next="navigate(1)">
<template #trigger="{ triggerClass }">
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
type="button"
variant="ghost"
size="sm"
:class="twMerge(triggerClass, !modelValue.start && 'text-muted-foreground')"
data-testid="date_range_picker_display">
<CalendarIcon class="text-text-quaternary h-4 w-4 shrink-0" />
<span class="min-w-0 truncate">{{ displayLabel }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<div class="flex divide-x divide-border-secondary">
<div
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setToday"
>Today</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setThisWeek"
>This Week</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLastWeek"
>Last Week</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLast14Days"
>Last 14 Days</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setThisMonth"
>This Month</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLastMonth"
>Last Month</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLast30Days"
>Last 30 Days</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLast90Days"
>Last 90 Days</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLast12Months"
>Last 12 Months</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setThisYear"
>This Year</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLastYear"
>Last Year</Button
>
</div>
<div class="pl-2">
<RangeCalendar
v-model="modelValue"
initial-focus
:number-of-months="2"
:max-value="allowFuture ? undefined : today"
:week-starts-on="weekStartsOn" />
</div>
</div>
</PopoverContent>
</Popover>
</template>
</DateRangeNavigator>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
variant="outline"
:class="
twMerge(
'flex w-full items-center justify-between whitespace-nowrap h-[34px] text-start',
!modelValue && 'text-muted-foreground'
)
">
<CalendarIcon class="-ml-0.5 text-text-quaternary h-4 w-4" />
<template v-if="modelValue.start">
<template v-if="modelValue.end">
{{ formatDate(modelValue.start.toString(), organization?.date_format) }}
-
{{ formatDate(modelValue.end.toString(), organization?.date_format) }}
</template>
<template v-else>
{{ formatDate(modelValue.start.toString(), organization?.date_format) }}
</template>
</template>
<template v-else> Pick a date </template>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<div class="flex divide-x divide-border-secondary">
<div
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
<Button variant="ghost" size="sm" class="justify-start" @click="setToday"
>Today</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setThisWeek"
>This Week</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLastWeek"
>Last Week</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast14Days"
>Last 14 days</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setThisMonth"
>This Month</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLastMonth"
>Last Month</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast30Days"
>Last 30 days</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast90Days"
>Last 90 days</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast12Months"
>Last 12 months</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setThisYear"
>This year</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLastYear"
>Last year</Button
>
</div>
<div class="pl-2">
<RangeCalendar
v-model="modelValue"
initial-focus
:number-of-months="2"
:max-value="today"
:week-starts-on="weekStartsOn" />
</div>
</div>
</PopoverContent>
</Popover>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Dialog, DialogContent, DialogFooter } from './dialog/index';
import { computed, nextTick } from 'vue';
import { computed } from 'vue';
const props = defineProps({
show: {
@@ -17,7 +17,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['close', 'submit']);
const emit = defineEmits(['close']);
const close = () => {
if (props.closeable) {
@@ -25,25 +25,6 @@ const close = () => {
}
};
// Ctrl+Enter (Cmd+Enter on macOS) submits the modal from any focused element inside it.
// Handled in the capture phase so child elements (buttons, dropdown triggers, inputs with
// their own Enter handlers) never see the keystroke and cannot open or double-submit.
async function onKeydownCapture(event: KeyboardEvent) {
if (event.key !== 'Enter' || !(event.ctrlKey || event.metaKey) || event.isComposing) {
return;
}
event.preventDefault();
event.stopPropagation();
// Inputs like the time and duration fields commit their value on blur, so blur first
// and let the resulting model updates settle before submitting.
const active = document.activeElement;
if (active instanceof HTMLElement) {
active.blur();
}
await nextTick();
emit('submit');
}
const maxWidthClass = computed(() => {
return {
sm: 'sm:max-w-sm',
@@ -58,7 +39,7 @@ const maxWidthClass = computed(() => {
<template>
<Dialog :open="show" @update:open="close">
<DialogContent :class="maxWidthClass">
<div class="min-w-0" @keydown.capture="onKeydownCapture">
<div class="min-w-0">
<slot />
</div>

View File

@@ -72,7 +72,7 @@ const currentClientName = computed(() => {
</script>
<template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Create Project </span>

View File

@@ -30,7 +30,7 @@ useFocus(tagNameInput, { initialValue: true });
</script>
<template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Create Tags </span>

View File

@@ -78,7 +78,7 @@ async function submit() {
</script>
<template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex items-center space-x-2 text-amber-600 dark:text-amber-400">
<Coffee class="w-5 h-5" />

View File

@@ -126,7 +126,7 @@ const billableProxy = computed({
</script>
<template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Create manual time entry </span>

View File

@@ -164,7 +164,7 @@ const typeProxy = computed({
</script>
<template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Edit time entry </span>

View File

@@ -16,7 +16,6 @@ import {
} from '@/packages/api/src';
import { Checkbox } from '@/packages/ui/src';
import { TagIcon, ExclamationTriangleIcon } from '@heroicons/vue/20/solid';
import { XMarkIcon } from '@heroicons/vue/16/solid';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import { Button } from '@/packages/ui/src/Buttons';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
@@ -61,13 +60,6 @@ const projectId = ref<string | null>(null);
const billable = ref<boolean | undefined>(undefined);
const selectedTags = ref<string[]>([]);
// Clearing the project puts both fields back to "leave unchanged", the same state the form
// returns to after a successful submit.
function resetProject() {
projectId.value = null;
taskId.value = undefined;
}
const timeEntryBillable = computed({
get: () => {
if (billable.value === undefined) {
@@ -155,7 +147,7 @@ const showBreakWarning = computed(
</script>
<template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Update {{ timeEntries.length }} time entries </span>
@@ -190,59 +182,42 @@ const showBreakWarning = computed(
</Field>
<Field>
<FieldLabel for="project">Project</FieldLabel>
<div class="flex items-center gap-1 min-w-0">
<!-- the dropdown declares its own `class` prop, which goes to the
trigger button, so the growing has to happen on a wrapper -->
<div class="flex-1 min-w-0">
<TimeTrackerProjectTaskDropdown
v-model:project="projectId"
v-model:task="taskId"
variant="input"
align="start"
size="default"
:clients
:create-project
:create-client
:currency="currency"
:organization-billable-rate="organizationBillableRate"
:can-create-project
empty-placeholder="Select project..."
:enable-estimated-time
:projects="projects"
:tasks="tasks"></TimeTrackerProjectTaskDropdown>
</div>
<button
v-if="projectId !== null"
type="button"
data-testid="project_reset_button"
class="p-1 rounded hover:bg-quaternary text-text-tertiary hover:text-text-primary"
@click="resetProject">
<XMarkIcon class="w-4 h-4" />
</button>
</div>
<TimeTrackerProjectTaskDropdown
v-model:project="projectId"
v-model:task="taskId"
variant="input"
align="start"
size="default"
:clients
:create-project
:create-client
:currency="currency"
:organization-billable-rate="organizationBillableRate"
:can-create-project
empty-placeholder="Select project..."
allow-reset
:enable-estimated-time
:projects="projects"
:tasks="tasks"></TimeTrackerProjectTaskDropdown>
</Field>
<Field>
<FieldLabel>Tag</FieldLabel>
<div class="flex items-center space-x-5">
<!-- the dropdown root is `min-w-0`, so as a flex item it would shrink
below its trigger and let the button overflow into the checkbox -->
<div class="shrink-0">
<TagDropdown
v-model="selectedTags"
:create-tag
:tags="tags"
:show-no-tag-option="false">
<template #trigger>
<Button variant="input" :disabled="removeAllTags">
<TagIcon class="h-4 text-icon-default" />
<span v-if="selectedTags.length > 0">
Set {{ selectedTags.length }} tags
</span>
<span v-else>Select Tags...</span>
</Button>
</template>
</TagDropdown>
</div>
<div class="flex space-x-5">
<TagDropdown
v-model="selectedTags"
:create-tag
:tags="tags"
:show-no-tag-option="false">
<template #trigger>
<Button variant="input" :disabled="removeAllTags">
<TagIcon class="h-4 text-icon-default" />
<span v-if="selectedTags.length > 0">
Set {{ selectedTags.length }} tags
</span>
<span v-else>Select Tags...</span>
</Button>
</template>
</TagDropdown>
<Field orientation="horizontal">
<Checkbox id="no_tags" v-model:checked="removeAllTags"></Checkbox>
<FieldLabel for="no_tags">Remove all tags</FieldLabel>

View File

@@ -12,7 +12,7 @@ import type {
Client,
} from '@/packages/api/src';
import { PlusCircleIcon, MinusIcon } from '@heroicons/vue/16/solid';
import { PlusCircleIcon, MinusIcon, XMarkIcon } from '@heroicons/vue/16/solid';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import { twMerge } from 'tailwind-merge';
import { Button } from '@/packages/ui/src/Buttons';
@@ -58,6 +58,7 @@ const props = withDefaults(
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
currency: string;
emptyPlaceholder?: string;
allowReset?: boolean;
noProjectValue?: string | null;
enableEstimatedTime: boolean;
organizationBillableRate: number | null;
@@ -69,6 +70,7 @@ const props = withDefaults(
}>(),
{
emptyPlaceholder: 'No Project',
allowReset: false,
noProjectValue: NO_PROJECT_ID,
variant: 'ghost',
align: 'center',
@@ -556,6 +558,12 @@ function selectProject(projectId: string) {
emit('changed', project.value, task.value);
}
function resetProject() {
project.value = null;
task.value = null;
emit('changed', project.value, task.value);
}
const showCreateProject = ref(false);
</script>
@@ -563,28 +571,40 @@ const showCreateProject = ref(false);
<Dropdown v-model="open" :close-on-content-click="false" :align="props.align">
<template #trigger>
<slot name="trigger">
<Button
:variant="props.variant"
:size="props.size"
:class="twMerge('w-full justify-start overflow-hidden', props.class)">
<div
class="w-2.5 h-2.5 rounded-full shrink-0"
:style="{ backgroundColor: selectedProjectColor }"></div>
<span class="truncate shrink-[1] text-text-primary">{{
selectedProjectName
}}</span>
<template v-if="currentTask">
<ChevronRightIcon class="!size-3 text-text-primary shrink-0 -mx-1" />
<span class="truncate shrink-[100]">{{ currentTask.name }}</span>
</template>
<template v-if="selectedClientName">
<span class="dark:text-text-tertiary text-text-quaternary shrink-0">•</span>
<span
class="truncate shrink-[200] dark:text-text-tertiary text-text-quaternary"
>{{ selectedClientName }}</span
>
</template>
</Button>
<div class="flex items-center gap-1">
<Button
:variant="props.variant"
:size="props.size"
:class="twMerge('w-full justify-start overflow-hidden', props.class)">
<div
class="w-2.5 h-2.5 rounded-full shrink-0"
:style="{ backgroundColor: selectedProjectColor }"></div>
<span class="truncate shrink-[1] text-text-primary">{{
selectedProjectName
}}</span>
<template v-if="currentTask">
<ChevronRightIcon class="!size-3 text-text-primary shrink-0 -mx-1" />
<span class="truncate shrink-[100]">{{ currentTask.name }}</span>
</template>
<template v-if="selectedClientName">
<span class="dark:text-text-tertiary text-text-quaternary shrink-0"
>•</span
>
<span
class="truncate shrink-[200] dark:text-text-tertiary text-text-quaternary"
>{{ selectedClientName }}</span
>
</template>
</Button>
<button
v-if="allowReset && project !== null"
type="button"
data-testid="project_reset_button"
class="p-1 rounded hover:bg-quaternary text-text-tertiary hover:text-text-primary"
@click.stop="resetProject">
<XMarkIcon class="w-4 h-4" />
</button>
</div>
</slot>
</template>
<template #content>

View File

@@ -132,7 +132,6 @@ import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
import DateRangePicker from './Input/DateRangePicker.vue';
import DateRangeNavigator from './Input/DateRangeNavigator.vue';
import { Label } from './label/index';
import {
NumberField,
@@ -212,7 +211,6 @@ export {
ContextMenuSubTrigger,
ContextMenuTrigger,
DateRangePicker,
DateRangeNavigator,
Dialog,
DialogClose,
DialogContent,

View File

@@ -1,124 +0,0 @@
import dayjs from 'dayjs';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { getDayJsInstance, getLocalizedDayJs } from './time';
import { getDateRangeLabel, shiftDateRange } from './dateRange';
const reference = dayjs('2026-09-21');
afterEach(() => {
vi.mocked(window.getTimezoneSetting).mockReturnValue('UTC');
});
describe('getDateRangeLabel', () => {
test('labels relative weeks', () => {
getDayJsInstance();
expect(
getDateRangeLabel(reference.startOf('week'), reference.endOf('week'), {
referenceDate: reference,
})
).toBe('This Week');
expect(
getDateRangeLabel(
reference.subtract(1, 'week').startOf('week'),
reference.subtract(1, 'week').endOf('week'),
{ referenceDate: reference }
)
).toBe('Last Week');
});
test('labels common rolling ranges', () => {
expect(
getDateRangeLabel(reference.subtract(13, 'day'), reference.endOf('day'), {
referenceDate: reference,
})
).toBe('Last 14 Days');
});
test('labels full calendar months and years', () => {
expect(
getDateRangeLabel(dayjs('2025-09-01'), dayjs('2025-09-30'), {
referenceDate: reference,
})
).toBe('September 2025');
expect(
getDateRangeLabel(dayjs('2024-01-01'), dayjs('2024-12-31'), {
referenceDate: reference,
})
).toBe('2024');
});
test('keeps relative labels for the current periods', () => {
expect(
getDateRangeLabel(reference.startOf('month'), reference.endOf('month'), {
referenceDate: reference,
})
).toBe('This Month');
expect(
getDateRangeLabel(reference.startOf('year'), reference.endOf('year'), {
referenceDate: reference,
})
).toBe('This Year');
});
test('uses the organization date format for custom ranges', () => {
expect(
getDateRangeLabel(dayjs('2026-08-03'), dayjs('2026-08-12'), {
dateFormat: 'slash-separated-dd-mm-yyyy',
referenceDate: reference,
})
).toBe('03/08/2026 – 12/08/2026');
});
test('renders a single historical day without duplicating it', () => {
expect(
getDateRangeLabel(dayjs('2026-08-03'), dayjs('2026-08-03'), {
referenceDate: reference,
})
).toBe('Aug 3, 2026');
});
});
describe('shiftDateRange', () => {
test('moves full weeks as calendar periods', () => {
getDayJsInstance();
const shifted = shiftDateRange(reference.startOf('week'), reference.endOf('week'), -1);
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-09-14');
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-09-20');
});
test('moves full months without truncating longer months', () => {
const shifted = shiftDateRange(dayjs('2026-02-01'), dayjs('2026-02-28'), 1);
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-03-01');
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-03-31');
});
test('moves custom ranges by their inclusive duration', () => {
const shifted = shiftDateRange(dayjs('2026-09-01'), dayjs('2026-09-14'), -1);
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-08-18');
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-08-31');
});
test('normalizes custom range offsets across the spring DST transition', () => {
vi.mocked(window.getTimezoneSetting).mockReturnValue('Europe/Vienna');
const shifted = shiftDateRange(
getLocalizedDayJs('2026-03-14T23:00:00Z'),
getLocalizedDayJs('2026-03-27T23:00:00Z'),
1
);
expect(shifted.start.format()).toBe('2026-03-29T00:00:00+01:00');
expect(shifted.end.format()).toBe('2026-04-11T23:59:59+02:00');
});
test('normalizes custom range offsets across the autumn DST transition', () => {
vi.mocked(window.getTimezoneSetting).mockReturnValue('Europe/Vienna');
const shifted = shiftDateRange(
getLocalizedDayJs('2026-10-17T22:00:00Z'),
getLocalizedDayJs('2026-10-30T23:00:00Z'),
1
);
expect(shifted.start.format()).toBe('2026-11-01T00:00:00+01:00');
expect(shifted.end.format()).toBe('2026-11-14T23:59:59+01:00');
});
});

View File

@@ -1,135 +0,0 @@
import type { Dayjs } from 'dayjs';
import { formatDate, getDayJsInstance, getLocalizedDayJs, type DateFormat } from './time';
export type DateRangeDirection = -1 | 1;
function isSameDay(left: Dayjs, right: Dayjs): boolean {
return left.isSame(right, 'day');
}
function isRange(start: Dayjs, end: Dayjs, expectedStart: Dayjs, expectedEnd: Dayjs): boolean {
return isSameDay(start, expectedStart) && isSameDay(end, expectedEnd);
}
function formatCompactRange(start: Dayjs, end: Dayjs): string {
if (start.year() !== end.year()) {
return `${start.format('MMM D, YYYY')} – ${end.format('MMM D, YYYY')}`;
}
if (start.month() !== end.month()) {
return `${start.format('MMM D')} – ${end.format('MMM D, YYYY')}`;
}
return `${start.format('MMM D')} – ${end.format('D, YYYY')}`;
}
/**
* Returns a human-friendly label for an inclusive date range. Relative labels
* are intentionally shared by reporting, the timesheet, and the calendar.
*/
export function getDateRangeLabel(
start: Dayjs,
end: Dayjs,
options: { dateFormat?: DateFormat; referenceDate?: Dayjs } = {}
): string {
// Ensure the configured first day of the week is applied before using startOf('week').
getDayJsInstance();
const reference = (options.referenceDate ?? getLocalizedDayJs()).startOf('day');
const rangeStart = start.startOf('day');
const rangeEnd = end.startOf('day');
const relativeDays: Array<[number, string]> = [
[0, 'Today'],
[-1, 'Yesterday'],
[1, 'Tomorrow'],
];
for (const [offset, label] of relativeDays) {
const day = reference.add(offset, 'day');
if (isRange(rangeStart, rangeEnd, day, day)) return label;
}
const relativePeriods: Array<['week' | 'month' | 'year', number, string]> = [
['week', 0, 'This Week'],
['week', -1, 'Last Week'],
['week', 1, 'Next Week'],
['month', 0, 'This Month'],
['month', -1, 'Last Month'],
['month', 1, 'Next Month'],
['year', 0, 'This Year'],
['year', -1, 'Last Year'],
['year', 1, 'Next Year'],
];
for (const [unit, offset, label] of relativePeriods) {
const period = reference.add(offset, unit);
if (isRange(rangeStart, rangeEnd, period.startOf(unit), period.endOf(unit))) return label;
}
// Named calendar periods outside the relative windows above, e.g. "May 2025"
// for a full month and "2025" for a full year.
const monthStart = rangeStart.startOf('month');
if (isRange(rangeStart, rangeEnd, monthStart, monthStart.endOf('month'))) {
return rangeStart.format('MMMM YYYY');
}
const yearStart = rangeStart.startOf('year');
if (isRange(rangeStart, rangeEnd, yearStart, yearStart.endOf('year'))) {
return rangeStart.format('YYYY');
}
const rollingRanges: Array<[number, string]> = [
[14, 'Last 14 Days'],
[30, 'Last 30 Days'],
[90, 'Last 90 Days'],
];
for (const [days, label] of rollingRanges) {
if (isRange(rangeStart, rangeEnd, reference.subtract(days - 1, 'day'), reference)) {
return label;
}
}
if (isRange(rangeStart, rangeEnd, reference.subtract(12, 'month'), reference)) {
return 'Last 12 Months';
}
if (isSameDay(rangeStart, rangeEnd)) {
return options.dateFormat
? formatDate(rangeStart.format('YYYY-MM-DD'), options.dateFormat)
: rangeStart.format('MMM D, YYYY');
}
if (options.dateFormat) {
return `${formatDate(rangeStart.format('YYYY-MM-DD'), options.dateFormat)} – ${formatDate(
rangeEnd.format('YYYY-MM-DD'),
options.dateFormat
)}`;
}
return formatCompactRange(rangeStart, rangeEnd);
}
/** Shift an inclusive range by one period, preserving full calendar periods. */
export function shiftDateRange(
start: Dayjs,
end: Dayjs,
direction: DateRangeDirection
): { start: Dayjs; end: Dayjs } {
getDayJsInstance();
const rangeStart = start.startOf('day');
const rangeEnd = end.endOf('day');
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('year'), rangeStart.endOf('year'))) {
const shiftedStart = rangeStart.add(direction, 'year').startOf('year');
return { start: shiftedStart, end: shiftedStart.endOf('year') };
}
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('month'), rangeStart.endOf('month'))) {
const shiftedStart = rangeStart.add(direction, 'month').startOf('month');
return { start: shiftedStart, end: shiftedStart.endOf('month') };
}
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('week'), rangeStart.endOf('week'))) {
const shiftedStart = rangeStart.add(direction, 'week').startOf('week');
return { start: shiftedStart, end: shiftedStart.endOf('week') };
}
const days = rangeEnd.startOf('day').diff(rangeStart, 'day') + 1;
return {
start: rangeStart.add(days * direction, 'day').startOf('day'),
end: rangeEnd.add(days * direction, 'day').endOf('day'),
};
}

View File

@@ -19,7 +19,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
const showActionBlockedModal = ref(false);
function addNotification(type: NotificationType, title: string, message?: string) {
const uuid = crypto.randomUUID();
const uuid = Math.random().toString(36).substring(7);
notifications.value.push({ title, message, type, uuid });
setTimeout(() => {

View File

@@ -9,7 +9,6 @@ use App\Service\ApiService;
use Cache;
use Illuminate\Console\Command;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -33,11 +32,6 @@ class SelfHostCheckForUpdateCommandTest extends TestCase
$this->assertSame(Command::SUCCESS, $exitCode);
$output = Artisan::output();
$this->assertSame('1.2.3', Cache::get('latest_version'));
Http::assertSent(function (Request $request): bool {
$data = $request->data();
return array_key_exists('is_premium', $data) && is_bool($data['is_premium']);
});
}
public function test_checks_for_update_fails_gracefully_if_response_has_error_status_code(): void

View File

@@ -8,7 +8,6 @@ use App\Console\Commands\SelfHost\SelfHostTelemetryCommand;
use App\Service\ApiService;
use Illuminate\Console\Command;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -32,11 +31,6 @@ class SelfHostTelemetryCommandTest extends TestCase
$this->assertSame(Command::SUCCESS, $exitCode);
$output = Artisan::output();
$this->assertSame('', $output);
Http::assertSent(function (Request $request): bool {
$data = $request->data();
return array_key_exists('is_premium', $data) && is_bool($data['is_premium']);
});
}
public function test_telemetry_sends_fails_gracefully_if_response_has_error_status_code(): void

View File

@@ -8,7 +8,6 @@ use Closure;
use DateTimeInterface;
use Illuminate\Support\Facades\Storage;
use Illuminate\Testing\TestResponse;
use Mockery;
use Tests\TestCaseWithDatabase;
class ApiEndpointTestAbstract extends TestCaseWithDatabase
@@ -22,23 +21,21 @@ class ApiEndpointTestAbstract extends TestCaseWithDatabase
}
/**
* Captures the options passed to temporaryUrl on the private disk.
* Replaces the temporary URL builder of the private disk to capture the options
* passed to temporaryUrl. Returns a closure that yields the captured options.
*
* @return Closure(): (array<string, mixed>|null)
*/
protected function captureTemporaryUrlOptions(): Closure
{
$captured = null;
$diskName = config('filesystems.private');
$disk = Mockery::mock(Storage::disk($diskName))->makePartial();
$disk->shouldReceive('temporaryUrl')->andReturnUsing(
Storage::disk(config('filesystems.private'))->buildTemporaryUrlsUsing(
function (string $path, DateTimeInterface $expiration, array $options) use (&$captured): string {
$captured = $options;
return 'https://storage.fake/'.$path;
}
);
Storage::set($diskName, $disk);
return function () use (&$captured): ?array {
return $captured;

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Filament\Resources;
use App\Filament\Resources\AuditResource;
use App\Models\Audit;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\UsesClass;
use Tests\Unit\Filament\FilamentTestCase;
#[UsesClass(AuditResource::class)]
class AuditResourceTest extends FilamentTestCase
{
protected function setUp(): void
{
parent::setUp();
Config::set('auth.super_admins', ['admin@example.com']);
$user = User::factory()->withPersonalOrganization()->create([
'email' => 'admin@example.com',
]);
$this->actingAs($user);
}
public function test_can_list_audits(): void
{
// Arrange
$user = $this->createUserWithPermission();
$timeEntry = TimeEntry::factory()->forMember($user->member)->create();
DB::table((new Audit)->getTable())->delete();
$audits = Audit::factory()->auditFor($timeEntry)->auditUser($user->user)->createMany(5);
// Act
$response = Livewire::test(AuditResource\Pages\ListAudits::class);
// Assert
$response->assertSuccessful();
$response->assertCanSeeTableRecords($audits);
}
public function test_can_see_view_page_of_audit(): void
{
// Arrange
DB::table((new Audit)->getTable())->delete();
$audit = Audit::factory()->create();
// Act
$response = Livewire::test(AuditResource\Pages\ViewAudit::class, ['record' => $audit->getKey()]);
// Assert
$response->assertSuccessful();
}
}

View File

@@ -544,68 +544,6 @@ class OrganizationResourceTest extends FilamentTestCase
$this->assertSame(Role::Owner->value, $ownerMember->refresh()->role);
}
public function test_edit_related_user_does_not_change_role_if_role_is_unchanged_for_owner(): void
{
// Arrange
$owner = User::factory()->create();
$organization = Organization::factory()->withOwner($owner)->create();
$ownerMember = Member::factory()->forOrganization($organization)->forUser($owner)->role(Role::Owner)->billableRate(1000)->create();
// Act
$response = Livewire::test(OrganizationResource\RelationManagers\UsersRelationManager::class, [
'ownerRecord' => $organization,
'pageClass' => OrganizationResource\Pages\EditOrganization::class,
])->callTableAction('edit', $owner, data: [
'role' => Role::Owner->value,
'billable_rate' => 2000,
]);
// Assert
$response->assertSuccessful();
$response->assertHasNoTableActionErrors();
$response->assertNotNotified(
Notification::make()
->danger()
->title('Update failed')
->body(__('exceptions.api.organization_needs_at_least_one_owner'))
->persistent()
);
$ownerMember->refresh();
$this->assertSame(Role::Owner->value, $ownerMember->role);
$this->assertSame(2000, $ownerMember->billable_rate);
}
public function test_edit_related_user_does_not_change_role_if_role_is_unchanged_for_placeholder(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->placeholder()->create();
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Placeholder)->billableRate(1000)->create();
// Act
$response = Livewire::test(OrganizationResource\RelationManagers\UsersRelationManager::class, [
'ownerRecord' => $organization,
'pageClass' => OrganizationResource\Pages\EditOrganization::class,
])->callTableAction('edit', $user, data: [
'role' => Role::Placeholder->value,
'billable_rate' => 2000,
]);
// Assert
$response->assertSuccessful();
$response->assertHasNoTableActionErrors();
$response->assertNotNotified(
Notification::make()
->danger()
->title('Update failed')
->body(__('exceptions.api.changing_role_of_placeholder_is_not_allowed'))
->persistent()
);
$member->refresh();
$this->assertSame(Role::Placeholder->value, $member->role);
$this->assertSame(2000, $member->billable_rate);
}
public function test_can_detach_related_user_from_organization(): void
{
// Arrange

View File

@@ -286,62 +286,6 @@ class UserResourceTest extends FilamentTestCase
$this->assertSame(Role::Owner->value, $ownerMember->refresh()->role);
}
public function test_edit_related_organization_does_not_change_role_if_role_is_unchanged_for_owner(): void
{
// Arrange
$owner = User::factory()->create();
$organization = Organization::factory()->withOwner($owner)->create();
$ownerMember = Member::factory()->forOrganization($organization)->forUser($owner)->role(Role::Owner)->create();
// Act
$response = Livewire::test(UserResource\RelationManagers\OrganizationsRelationManager::class, [
'ownerRecord' => $owner,
'pageClass' => UserResource\Pages\EditUser::class,
])->callTableAction('edit', $organization, data: [
'role' => Role::Owner->value,
]);
// Assert
$response->assertSuccessful();
$response->assertHasNoTableActionErrors();
$response->assertNotNotified(
Notification::make()
->danger()
->title('Update failed')
->body(__('exceptions.api.organization_needs_at_least_one_owner'))
->persistent()
);
$this->assertSame(Role::Owner->value, $ownerMember->refresh()->role);
}
public function test_edit_related_organization_does_not_change_role_if_role_is_unchanged_for_placeholder(): void
{
// Arrange
$user = User::factory()->placeholder()->create();
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Placeholder)->create();
// Act
$response = Livewire::test(UserResource\RelationManagers\OrganizationsRelationManager::class, [
'ownerRecord' => $user,
'pageClass' => UserResource\Pages\EditUser::class,
])->callTableAction('edit', $organization, data: [
'role' => Role::Placeholder->value,
]);
// Assert
$response->assertSuccessful();
$response->assertHasNoTableActionErrors();
$response->assertNotNotified(
Notification::make()
->danger()
->title('Update failed')
->body(__('exceptions.api.changing_role_of_placeholder_is_not_allowed'))
->persistent()
);
$this->assertSame(Role::Placeholder->value, $member->refresh()->role);
}
public function test_can_detach_related_organization_from_user(): void
{
// Arrange

View File

@@ -187,7 +187,7 @@ class LocalizationServiceTest extends TestCaseWithDatabase
// Arrange
$this->localizationService->setCurrencyFormat(CurrencyFormat::SymbolAfterWithSpace);
$this->localizationService->setNumberFormat(NumberFormat::ThousandsSpaceDecimalComma);
$money = Money::of('1234567.89', Currency::of('EUR'));
$money = Money::of(1234567.89, Currency::of('EUR'));
// Act
$formatted = $this->localizationService->formatCurrency($money);
@@ -201,7 +201,7 @@ class LocalizationServiceTest extends TestCaseWithDatabase
// Arrange
$this->localizationService->setCurrencyFormat(CurrencyFormat::SymbolBeforeWithSpace);
$this->localizationService->setNumberFormat(NumberFormat::ThousandsSpaceDecimalComma);
$money = Money::of('1234567.89', Currency::of('EUR'));
$money = Money::of(1234567.89, Currency::of('EUR'));
// Act
$formatted = $this->localizationService->formatCurrency($money);
@@ -215,7 +215,7 @@ class LocalizationServiceTest extends TestCaseWithDatabase
// Arrange
$this->localizationService->setCurrencyFormat(CurrencyFormat::SymbolBefore);
$this->localizationService->setNumberFormat(NumberFormat::ThousandsSpaceDecimalComma);
$money = Money::of('1234567.89', Currency::of('EUR'));
$money = Money::of(1234567.89, Currency::of('EUR'));
// Act
$formatted = $this->localizationService->formatCurrency($money);
@@ -229,7 +229,7 @@ class LocalizationServiceTest extends TestCaseWithDatabase
// Arrange
$this->localizationService->setCurrencyFormat(CurrencyFormat::SymbolAfter);
$this->localizationService->setNumberFormat(NumberFormat::ThousandsSpaceDecimalComma);
$money = Money::of('1234567.89', Currency::of('EUR'));
$money = Money::of(1234567.89, Currency::of('EUR'));
// Act
$formatted = $this->localizationService->formatCurrency($money);
@@ -243,7 +243,7 @@ class LocalizationServiceTest extends TestCaseWithDatabase
// Arrange
$this->localizationService->setCurrencyFormat(CurrencyFormat::ISOCodeAfterWithSpace);
$this->localizationService->setNumberFormat(NumberFormat::ThousandsSpaceDecimalComma);
$money = Money::of('1234567.89', Currency::of('EUR'));
$money = Money::of(1234567.89, Currency::of('EUR'));
// Act
$formatted = $this->localizationService->formatCurrency($money);
@@ -257,7 +257,7 @@ class LocalizationServiceTest extends TestCaseWithDatabase
// Arrange
$this->localizationService->setCurrencyFormat(CurrencyFormat::ISOCodeBeforeWithSpace);
$this->localizationService->setNumberFormat(NumberFormat::ThousandsSpaceDecimalComma);
$money = Money::of('1234567.89', Currency::of('EUR'));
$money = Money::of(1234567.89, Currency::of('EUR'));
// Act
$formatted = $this->localizationService->formatCurrency($money);