Compare commits

..

3 Commits

Author SHA1 Message Date
Constantin Graf
cdf783ce24 Updated the PR template 2025-07-31 13:56:00 +02:00
Gregor Vostrak
480c44771e add contributing notice that you need to run the format command 2025-07-31 13:56:00 +02:00
Gregor Vostrak
ed1ffaf0db add CONTRIBUTING.md 2025-07-31 13:56:00 +02:00
170 changed files with 2365 additions and 7671 deletions

View File

@@ -1,216 +0,0 @@
on:
push:
branches:
- main
- develop
tags:
- '*'
pull_request:
paths:
- '.github/workflows/build-onpremise.yml'
- 'docker/prod/**'
workflow_dispatch:
permissions:
packages: write
contents: read
attestations: write
id-token: write
env:
DOCKER_REPO: registry.on-premise.solidtime.io/solidtime/solidtime
name: Build - On Premise
jobs:
build:
strategy:
matrix:
include:
- runs-on: "ubuntu-24.04-arm"
platform: "linux/arm64"
- runs-on: "ubuntu-24.04"
platform: "linux/amd64"
runs-on: ${{ matrix.runs-on }}
timeout-minutes: 90
steps:
- name: "Check out code"
uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
- name: "Get build"
id: release-build
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
- name: "Get Previous tag (normal push)"
id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
prefix: "v"
- name: "Get version"
id: release-version
run: |
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
else
echo "ERROR: No previous tag found";
exit 1;
fi
else
version=$(echo "${{ github.ref }}" | cut -c 12-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
fi
- name: "Copy .env template for production"
run: |
cp .env.production .env
rm .env.production .env.ci .env.example
- name: "Add version to .env"
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.release-version.outputs.app_version }}/g' .env
- name: "Add build to .env"
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env
- name: "Output .env"
run: cat .env
- name: "Setup PHP with PECL extension"
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, dom, fileinfo, pgsql
- name: "Install dependencies"
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: "Checkout invoicing extension"
uses: actions/checkout@v4
with:
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
- name: "Install composer dependencies in invoicing extension"
run: cd extensions/Invoicing && composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
- name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci
- name: "Activate invoicing extension"
run: php artisan module:enable Invoicing
- name: "Install npm dependencies"
run: npm ci
- name: "Build"
run: npm run build
- name: "Prepare"
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: "Docker meta"
id: "meta"
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_REPO }}
- name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v3
with:
registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }}
password: ${{ secrets.ONPREMISE_TOKEN }}
- name: "Set up QEMU"
uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
- name: "Build and push by digest"
id: build
uses: docker/build-push-action@v6
with:
context: .
file: docker/prod/Dockerfile
build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.DOCKER_REPO }}",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha
cache-to: type=gha,mode=max
- name: "Export digest"
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: "Upload digest"
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
timeout-minutes: 90
needs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v3
with:
registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }}
password: ${{ secrets.ONPREMISE_TOKEN }}
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
- name: "Docker meta"
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_REPO }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: "Create manifest list and push"
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
- name: "Inspect image"
run: |
docker buildx imagetools inspect ${{ env.DOCKER_REPO }}:${{ steps.meta.outputs.version }}

View File

@@ -22,27 +22,13 @@ class Kernel extends ConsoleKernel
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
->everyTenMinutes();
if (config('app.key') && (config('scheduling.tasks.self_hosting_check_for_update') || config('scheduling.tasks.self_hosting_telemetry'))) {
// Convert string to a stable integer for seeding
/** @var int $seed Take the first 8 hex chars → 32-bit int */
$seed = hexdec(substr(hash('md5', config('app.key')), 0, 8));
$seed = abs($seed); // Ensure it's positive
mt_srand($seed);
$firstHour = mt_rand(0, 23);
$secondHour = ($firstHour + 12) % 24;
$minuteOffset = mt_rand(0, 59);
mt_srand(null); // Reset the random number generator
$schedule->command('self-host:check-for-update')
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
->twiceDaily();
if (config('scheduling.tasks.self_hosting_check_for_update')) {
$schedule->command('self-host:check-for-update')
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
}
if (config('scheduling.tasks.self_hosting_telemetry')) {
$schedule->command('self-host:telemetry')
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
}
}
$schedule->command('self-host:telemetry')
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
->twiceDaily();
$schedule->command('self-host:database-consistency')
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))

View File

@@ -20,7 +20,6 @@ enum TimeEntryAggregationType: string
case Client = 'client';
case Billable = 'billable';
case Description = 'description';
case Tag = 'tag';
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
{

View File

@@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class OverlappingTimeEntryApiException extends ApiException
{
public const string KEY = 'overlapping_time_entry';
}

View File

@@ -38,17 +38,11 @@ class ClientController extends Controller
public function index(Organization $organization, ClientIndexRequest $request): ClientCollection
{
$this->checkPermission($organization, 'clients:view');
$canViewAllClients = $this->hasPermission($organization, 'clients:view:all');
$user = $this->user();
$clientsQuery = Client::query()
->whereBelongsTo($organization, 'organization')
->orderBy('created_at', 'desc');
if (! $canViewAllClients) {
$clientsQuery->visibleByEmployee($user);
}
$filterArchived = $request->getFilterArchived();
if ($filterArchived === 'true') {
$clientsQuery->whereNotNull('archived_at');

View File

@@ -46,9 +46,6 @@ class OrganizationController extends Controller
if ($request->getEmployeesCanSeeBillableRates() !== null) {
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
}
if ($request->getEmployeesCanManageTasks() !== null) {
$organization->employees_can_manage_tasks = $request->getEmployeesCanManageTasks();
}
if ($request->getNumberFormat() !== null) {
$organization->number_format = $request->getNumberFormat();
}
@@ -64,9 +61,6 @@ class OrganizationController extends Controller
if ($request->getTimeFormat() !== null) {
$organization->time_format = $request->getTimeFormat();
}
if ($request->getPreventOverlappingTimeEntries() !== null) {
$organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries();
}
$hasBillableRate = $request->has('billable_rate');
if ($hasBillableRate) {
$oldBillableRate = $organization->billable_rate;

View File

@@ -11,7 +11,6 @@ use App\Http\Requests\V1\Task\TaskUpdateRequest;
use App\Http\Resources\V1\Task\TaskCollection;
use App\Http\Resources\V1\Task\TaskResource;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
@@ -28,26 +27,6 @@ class TaskController extends Controller
}
}
/**
* Check scoped permission and verify user has access to the project
*
* @throws AuthorizationException
*/
private function checkScopedPermissionForProject(Organization $organization, Project $project, string $permission): void
{
$this->checkPermission($organization, $permission);
$user = $this->user();
$hasAccess = Project::query()
->where('id', $project->id)
->visibleByEmployee($user)
->exists();
if (! $hasAccess) {
throw new AuthorizationException('You do not have permission to '.$permission.' in this project.');
}
}
/**
* Get tasks
*
@@ -96,15 +75,7 @@ class TaskController extends Controller
*/
public function store(Organization $organization, TaskStoreRequest $request): JsonResource
{
/** @var Project $project */
$project = Project::query()->findOrFail($request->input('project_id'));
if ($this->hasPermission($organization, 'tasks:create:all')) {
$this->checkPermission($organization, 'tasks:create:all');
} else {
$this->checkScopedPermissionForProject($organization, $project, 'tasks:create');
}
$this->checkPermission($organization, 'tasks:create');
$task = new Task;
$task->name = $request->input('name');
$task->project_id = $request->input('project_id');
@@ -126,17 +97,7 @@ class TaskController extends Controller
*/
public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource
{
// Check task belongs to organization
if ($task->organization_id !== $organization->id) {
throw new AuthorizationException('Task does not belong to organization');
}
if ($this->hasPermission($organization, 'tasks:update:all')) {
$this->checkPermission($organization, 'tasks:update:all');
} else {
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:update');
}
$this->checkPermission($organization, 'tasks:update', $task);
$task->name = $request->input('name');
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$task->estimated_time = $request->getEstimatedTime();
@@ -158,16 +119,7 @@ class TaskController extends Controller
*/
public function destroy(Organization $organization, Task $task): JsonResponse
{
// Check task belongs to organization
if ($task->organization_id !== $organization->id) {
throw new AuthorizationException('Task does not belong to organization');
}
if ($this->hasPermission($organization, 'tasks:delete:all')) {
$this->checkPermission($organization, 'tasks:delete:all');
} else {
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:delete');
}
$this->checkPermission($organization, 'tasks:delete', $task);
if ($task->timeEntries()->exists()) {
throw new EntityStillInUseApiException('task', 'time_entry');

View File

@@ -7,7 +7,6 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\OverlappingTimeEntryApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
@@ -46,7 +45,6 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\File;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
@@ -58,43 +56,6 @@ use Spatie\TemporaryDirectory\TemporaryDirectory;
class TimeEntryController extends Controller
{
private function assertNoOverlap(Organization $organization, Member $member, \Illuminate\Support\Carbon $start, ?\Illuminate\Support\Carbon $end, ?TimeEntry $exclude = null): void
{
if (! $organization->prevent_overlapping_time_entries) {
return;
}
$query = TimeEntry::query()
->where('organization_id', $organization->getKey())
->where('user_id', $member->user_id)
->when($exclude !== null, function (Builder $q) use ($exclude): void {
$q->where('id', '!=', $exclude->getKey());
})
->where(function (Builder $q) use ($start, $end): void {
$q->where(function (Builder $q2) use ($start): void {
$q2->where('end', '>', $start)
->where('start', '<', $start);
});
if ($end !== null) {
$q->orWhere(function (Builder $q4) use ($end): void {
$q4->where('start', '<', $end)
->where('end', '>', $end);
});
// Check if the new entry completely surrounds an existing entry
$q->orWhere(function (Builder $q6) use ($start, $end): void {
$q6->where('start', '>=', $start)
->where('end', '<=', $end);
});
}
});
if ($query->exists()) {
throw new OverlappingTimeEntryApiException;
}
}
protected function checkPermission(Organization $organization, string $permission, ?TimeEntry $timeEntry = null): void
{
parent::checkPermission($organization, $permission);
@@ -588,15 +549,17 @@ class TimeEntryController extends Controller
throw new TimeEntryStillRunningApiException;
}
// Overlap check for create
$start = Carbon::parse($request->input('start'));
$end = $request->input('end') !== null ? Carbon::parse($request->input('end')) : null;
$this->assertNoOverlap($organization, $member, $start, $end);
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
$client = $project?->client;
$task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;
if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
$timeEntry = new TimeEntry;
$timeEntry->fill($request->validated());
$timeEntry->client()->associate($client);
@@ -606,13 +569,6 @@ class TimeEntryController extends Controller
$timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->save();
if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
return new TimeEntryResource($timeEntry);
}
@@ -637,13 +593,6 @@ class TimeEntryController extends Controller
throw new TimeEntryCanNotBeRestartedApiException;
}
// Overlap check for update (exclude current)
/** @var Member $effectiveMember */
$effectiveMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : $timeEntry->member;
$effectiveStart = $request->has('start') ? Carbon::parse($request->input('start')) : $timeEntry->start;
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);
$oldProject = $timeEntry->project;
$oldTask = $timeEntry->task;

View File

@@ -41,7 +41,6 @@ class HandleInertiaRequests extends Middleware
{
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
$hasServices = Module::has('Services') && Module::isEnabled('Services');
/** @var BillingContract $billing */
$billing = app(BillingContract::class);
@@ -51,7 +50,6 @@ class HandleInertiaRequests extends Middleware
return array_merge(parent::share($request), [
'has_billing_extension' => $hasBilling,
'has_invoicing_extension' => $hasInvoicing,
'has_services_extension' => $hasServices,
'billing' => $currentOrganization !== null ? [
'has_subscription' => $billing->hasSubscription($currentOrganization),
'has_trial' => $billing->hasTrial($currentOrganization),

View File

@@ -39,12 +39,6 @@ class OrganizationUpdateRequest extends BaseFormRequest
'employees_can_see_billable_rates' => [
'boolean',
],
'employees_can_manage_tasks' => [
'boolean',
],
'prevent_overlapping_time_entries' => [
'boolean',
],
'number_format' => [
Rule::enum(NumberFormat::class),
],
@@ -104,14 +98,4 @@ class OrganizationUpdateRequest extends BaseFormRequest
{
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
}
public function getEmployeesCanManageTasks(): ?bool
{
return $this->has('employees_can_manage_tasks') ? $this->boolean('employees_can_manage_tasks') : null;
}
public function getPreventOverlappingTimeEntries(): ?bool
{
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
}
}

View File

@@ -10,10 +10,8 @@ use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -44,16 +42,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization');
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
$permissionStore = app(PermissionStore::class);
if (! $permissionStore->has($this->organization, 'time-entries:create:all')
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
$builder = $builder->visibleByEmployee(Auth::user());
}
return $builder;
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// ID of the task that the time entry should belong to
@@ -90,7 +79,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'description' => [
'nullable',
'string',
'max:5000',
'max:500',
],
// List of tag IDs
'tags' => [

View File

@@ -10,10 +10,8 @@ use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -56,16 +54,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization');
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
$permissionStore = app(PermissionStore::class);
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
$builder = $builder->visibleByEmployee(Auth::user());
}
return $builder;
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// ID of the task that the time entry should belong to
@@ -90,7 +79,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'changes.description' => [
'nullable',
'string',
'max:5000',
'max:500',
],
// List of tag IDs
'changes.tags' => [

View File

@@ -10,10 +10,8 @@ use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -44,16 +42,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization');
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
$permissionStore = app(PermissionStore::class);
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
$builder = $builder->visibleByEmployee(Auth::user());
}
return $builder;
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// ID of the task that the time entry should belong to
@@ -88,7 +77,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'description' => [
'nullable',
'string',
'max:5000',
'max:500',
],
// List of tag IDs
'tags' => [

View File

@@ -53,10 +53,6 @@ class OrganizationResource extends BaseResource
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
/** @var bool $employees_can_manage_tasks Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->currency,
/** @var string $currency_symbol Currency symbol */

View File

@@ -7,7 +7,6 @@ namespace App\Models;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use Database\Factories\ClientFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -63,18 +62,6 @@ class Client extends Model implements AuditableContract
return $this->hasMany(Project::class, 'client_id');
}
/**
* @param Builder<Client> $builder
* @return Builder<Client>
*/
public function scopeVisibleByEmployee(Builder $builder, User $user): Builder
{
return $builder->whereHas('projects', function (Builder $builder) use ($user): Builder {
/** @var Builder<Project> $builder */
return $builder->visibleByEmployee($user);
});
}
/**
* @return Attribute<bool, never>
*/

View File

@@ -35,7 +35,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property int|null $billable_rate
* @property string $user_id
* @property bool $employees_can_see_billable_rates
* @property bool $employees_can_manage_tasks
* @property User $owner
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
@@ -71,8 +70,6 @@ class Organization extends JetstreamTeam implements AuditableContract
'personal_team' => 'boolean',
'currency' => 'string',
'employees_can_see_billable_rates' => 'boolean',
'employees_can_manage_tasks' => 'boolean',
'prevent_overlapping_time_entries' => 'boolean',
'number_format' => NumberFormat::class,
'currency_format' => CurrencyFormat::class,
'date_format' => DateFormat::class,

View File

@@ -94,11 +94,8 @@ class JetstreamServiceProvider extends ServiceProvider
'tasks:view',
'tasks:view:all',
'tasks:create',
'tasks:create:all',
'tasks:update',
'tasks:update:all',
'tasks:delete',
'tasks:delete:all',
'time-entries:view:all',
'time-entries:create:all',
'time-entries:update:all',
@@ -112,7 +109,6 @@ class JetstreamServiceProvider extends ServiceProvider
'tags:update',
'tags:delete',
'clients:view',
'clients:view:all',
'clients:create',
'clients:update',
'clients:delete',
@@ -161,11 +157,8 @@ class JetstreamServiceProvider extends ServiceProvider
'tasks:view',
'tasks:view:all',
'tasks:create',
'tasks:create:all',
'tasks:update',
'tasks:update:all',
'tasks:delete',
'tasks:delete:all',
'time-entries:view:all',
'time-entries:create:all',
'time-entries:update:all',
@@ -179,7 +172,6 @@ class JetstreamServiceProvider extends ServiceProvider
'tags:update',
'tags:delete',
'clients:view',
'clients:view:all',
'clients:create',
'clients:update',
'clients:delete',
@@ -225,11 +217,8 @@ class JetstreamServiceProvider extends ServiceProvider
'tasks:view',
'tasks:view:all',
'tasks:create',
'tasks:create:all',
'tasks:update',
'tasks:update:all',
'tasks:delete',
'tasks:delete:all',
'time-entries:view:all',
'time-entries:create:all',
'time-entries:update:all',
@@ -243,7 +232,6 @@ class JetstreamServiceProvider extends ServiceProvider
'tags:update',
'tags:delete',
'clients:view',
'clients:view:all',
'clients:create',
'clients:update',
'clients:delete',
@@ -268,13 +256,12 @@ class JetstreamServiceProvider extends ServiceProvider
'projects:view',
'tags:view',
'tasks:view',
'clients:view',
'time-entries:view:own',
'time-entries:create:own',
'time-entries:update:own',
'time-entries:delete:own',
'organizations:view',
])->description('Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.');
])->description('Employees have the ability to read, create, and update their own time entries and they can see the projects that they are members of.');
Jetstream::role(Role::Placeholder->value, 'Placeholder', [
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');

View File

@@ -266,8 +266,7 @@ class DashboardService
) as aggregate'))
->where('billable', '=', true)
->whereNotNull('billable_rate')
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey());
->where('user_id', '=', $user->id);
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
/** @var Collection<int, object{aggregate: int}> $resultDb */

View File

@@ -167,7 +167,7 @@ class ExportService
$client->id,
$client->name,
$client->organization_id,
$client->archived_at?->toIso8601ZuluString() ?? '',
$client->archived_at ?? '',
$client->created_at?->toIso8601ZuluString() ?? '',
$client->updated_at?->toIso8601ZuluString() ?? '',
]);

View File

@@ -112,7 +112,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id;
if (strlen($record['Description']) > 5000) {
if (strlen($record['Description']) > 500) {
throw new ImportException('Time entry description is too long');
}
$timeEntry->description = $record['Description'];

View File

@@ -107,7 +107,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter
$timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id;
if (strlen($record['Notes']) > 5000) {
if (strlen($record['Notes']) > 500) {
throw new ImportException('Time entry note is too long');
}
$timeEntry->description = $record['Notes'];

View File

@@ -247,7 +247,7 @@ class SolidtimeImporter extends DefaultImporter
$timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id;
if (strlen($timeEntryRow['description']) > 5000) {
if (strlen($timeEntryRow['description']) > 500) {
throw new ImportException('Time entry description is too long');
}
$timeEntry->description = $timeEntryRow['description'];

View File

@@ -71,19 +71,7 @@ class PermissionStore
/** @var Role|null $roleObj */
$roleObj = Jetstream::findRole($role);
$permissions = $roleObj->permissions ?? [];
// If the organization allows employees to manage tasks and the user is an employee,
// add the task management permissions for accessible projects
if ($role === \App\Enums\Role::Employee->value && $organization->employees_can_manage_tasks) {
$permissions = array_merge($permissions, [
'tasks:create',
'tasks:update',
'tasks:delete',
]);
}
return $permissions;
return $roleObj->permissions ?? [];
}
/**

View File

@@ -10,7 +10,6 @@ use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
use App\Models\Client;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
@@ -18,7 +17,6 @@ use Carbon\CarbonTimeZone;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TimeEntryAggregationService
@@ -47,21 +45,9 @@ class TimeEntryAggregationService
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
{
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
/** @var Builder<TimeEntry> $baseTotalsQuery */
$baseTotalsQuery = $timeEntriesQuery->clone();
$group1Select = null;
$group2Select = null;
$groupBy = null;
// If any grouping is by tag, expand rows per tag and ensure a NULL row for entries without tags
if (($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag)) {
$timeEntriesQuery->crossJoin(DB::raw(
"LATERAL (\n".
" SELECT jsonb_array_elements_text(coalesce(tags, '[]'::jsonb)) AS tag\n".
" UNION ALL\n".
" SELECT ''::text AS tag WHERE coalesce(jsonb_array_length(tags), 0) = 0\n".
') AS tag(tag)'
));
}
if ($group1Type !== null) {
$group1Select = $this->getGroupByQuery($group1Type, $timezone, $startOfWeek);
$groupBy = ['group_1'];
@@ -98,26 +84,6 @@ class TimeEntryAggregationService
$group1Response = [];
$group1ResponseSum = 0;
$group1ResponseCost = 0;
// If Tag is subgroup, prepare base totals per primary group without tag expansion
$baseTotalsPerGroup1Map = [];
if ($group2Type === TimeEntryAggregationType::Tag) {
$baseTotalsPerGroup1Query = $baseTotalsQuery->clone();
$baseTotalsPerGroup1 = $baseTotalsPerGroup1Query
->selectRaw(
$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'
)
->groupBy('group_1')
->get();
foreach ($baseTotalsPerGroup1 as $row) {
/** @var object{group_1: mixed, aggregate: int|null, cost: int|null} $row */
$baseTotalsPerGroup1Map[(string) ($row->group_1 ?? '')] = [
'aggregate' => (int) ($row->aggregate ?? 0),
'cost' => (int) ($row->cost ?? 0),
];
}
}
foreach ($groupedAggregates as $group1 => $group1Aggregates) {
/** @var string|int $group1 */
$group2Response = [];
@@ -137,14 +103,6 @@ class TimeEntryAggregationService
$group2ResponseSum += (int) $aggregate->get(0)->aggregate;
$group2ResponseCost += (int) $aggregate->get(0)->cost;
}
// Override primary group totals when Tag is subgroup to avoid double counting
if ($group2Type === TimeEntryAggregationType::Tag) {
$keyForMap = (string) $group1;
if (array_key_exists($keyForMap, $baseTotalsPerGroup1Map)) {
$group2ResponseSum = $baseTotalsPerGroup1Map[$keyForMap]['aggregate'];
$group2ResponseCost = $baseTotalsPerGroup1Map[$keyForMap]['cost'];
}
}
} else {
/** @var Collection<int, object{aggregate: int, cost: int}> $group1Aggregates */
$group2ResponseSum = (int) $group1Aggregates->get(0)->aggregate;
@@ -163,23 +121,6 @@ class TimeEntryAggregationService
$group1ResponseCost += $group2ResponseCost;
}
// If Tag is selected in any grouping, compute overall totals from base (non-tag-expanded) query to avoid double counting
$hasTagGrouping = ($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag);
if ($hasTagGrouping) {
// Reset selects and ordering on the cloned base query
$baseTotals = $baseTotalsQuery
->selectRaw(
' 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();
if ($baseTotals !== null) {
/** @var object{aggregate: int|null, cost: int|null} $baseTotals */
$group1ResponseSum = (int) ($baseTotals->aggregate ?? 0);
$group1ResponseCost = (int) ($baseTotals->cost ?? 0);
}
}
if ($fillGapsInTimeGroupsIsPossible) {
$group1Response = $this->fillGapsInTimeGroups($group1Response, $group1Type, $group2Type, $timezone, $startOfWeek, $start, $end);
}
@@ -353,17 +294,6 @@ class TimeEntryAggregationService
'color' => null,
];
}
} elseif ($type === TimeEntryAggregationType::Tag) {
$tags = Tag::query()
->whereIn('id', $keys)
->select('id', 'name')
->get();
foreach ($tags as $tag) {
$descriptorMap[$tag->id] = [
'description' => $tag->name,
'color' => null,
];
}
}
return $descriptorMap;
@@ -506,8 +436,6 @@ class TimeEntryAggregationService
return 'billable';
} elseif ($group === TimeEntryAggregationType::Description) {
return 'description';
} elseif ($group === TimeEntryAggregationType::Tag) {
return 'tag';
}
}

View File

@@ -31,17 +31,12 @@ class TimeEntryService
throw new LogicException('Rounding minutes must be greater than 0');
}
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
$start = $this->getStartSelectRawForRounding($roundingType, $roundingMinutes);
if ($roundingType === TimeEntryRoundingType::Down) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.')';
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
} elseif ($roundingType === TimeEntryRoundingType::Up) {
// If end is already on a boundary, keep it; otherwise round up to next boundary
return 'CASE WHEN '.$end.' = date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.') '.
'THEN '.$end.' '.
'ELSE date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$start.') '.
'END';
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$start.')';
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
}
}
}

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->boolean('prevent_overlapping_time_entries')->default(false)->after('employees_can_see_billable_rates');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->dropColumn('prevent_overlapping_time_entries');
});
}
};

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('time_entries', function (Blueprint $table): void {
$table->string('description', 5000)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('time_entries', function (Blueprint $table): void {
$table->string('description', 500)->change();
});
}
};

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->boolean('employees_can_manage_tasks')->default(false)->after('employees_can_see_billable_rates');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->dropColumn('employees_can_manage_tasks');
});
}
};

View File

@@ -435,7 +435,7 @@ CREATE TABLE public.tasks (
CREATE TABLE public.time_entries (
id uuid NOT NULL,
description character varying(5000) NOT NULL,
description character varying(500) NOT NULL,
start timestamp(0) without time zone NOT NULL,
"end" timestamp(0) without time zone,
billable_rate integer,

View File

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

View File

@@ -2,7 +2,7 @@
# Source: https://helgesver.re/articles/laravel-sail-create-minio-bucket-automatically
/usr/bin/mc alias set local ${S3_ENDPOINT} ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY};
/usr/bin/mc config host add local ${S3_ENDPOINT} ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY};
/usr/bin/mc rm -r --force local/${S3_BUCKET};
/usr/bin/mc mb --ignore-existing local/${S3_BUCKET};
/usr/bin/mc anonymous set public local/${S3_BUCKET};

View File

@@ -16,7 +16,7 @@ RUN CGO_ENABLED=1 \
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
CGO_CFLAGS=$(php-config --includes) \
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
xcaddy build v2.10.0 \
xcaddy build \
--output /usr/local/bin/frankenphp \
--with github.com/dunglas/frankenphp=./ \
--with github.com/dunglas/frankenphp/caddy=./caddy/ \

View File

@@ -9,10 +9,7 @@ async function goToOrganizationSettings(page) {
async function createTimeEntry(page, duration: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
// Open the dropdown menu and click "Manual time entry"
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page.getByTestId('time_entry_description').fill('Test time entry');

View File

@@ -8,13 +8,6 @@ async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
}
// Helper to clear localStorage before tests that check persistence
async function clearProjectTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('project-table-state');
});
}
// Create new project via modal
test('test that creating and deleting a new project via the modal works', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
@@ -52,62 +45,34 @@ test('test that creating and deleting a new project via the modal works', async
await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
});
// Helper to select a status filter using the new dropdown UI
async function selectStatusFilter(page: Page, status: 'Active' | 'Archived') {
// Click the Filter button to open the dropdown
await page.getByRole('button', { name: 'Filter projects' }).click();
// Click on Status submenu
await page.getByRole('menuitem', { name: 'Status' }).click();
// Select the status option
await page.getByRole('menuitem', { name: status }).click();
}
// Helper to remove status filter by clicking the X on the badge
async function removeStatusFilter(page: Page) {
const statusBadge = page.getByTestId('status-filter-badge');
// Click the remove button (second button in the badge, contains XMarkIcon)
await statusBadge.locator('button').last().click();
}
test('test that archiving and unarchiving projects works', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
await page.getByRole('button', { name: 'Create Project' }).click();
await expect(page.getByText(newProjectName)).toBeVisible();
// Archive the project
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Archive').first().click();
await Promise.all([
page.getByRole('menuitem').getByText('Archive').first().click(),
expect(page.getByText(newProjectName)).not.toBeVisible(),
]);
await Promise.all([
page.getByRole('tab', { name: 'Archived' }).click(),
expect(page.getByText(newProjectName)).toBeVisible(),
]);
// Project should still be visible since default is "all" (no filter)
await expect(page.getByText(newProjectName)).toBeVisible();
// Apply Active filter - archived project should disappear
await selectStatusFilter(page, 'Active');
await expect(page.getByText(newProjectName)).not.toBeVisible();
// Remove Active filter and apply Archived filter
await removeStatusFilter(page);
await selectStatusFilter(page, 'Archived');
await expect(page.getByText(newProjectName)).toBeVisible();
// Unarchive the project
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Unarchive').first().click();
// Project should disappear from Archived view
await expect(page.getByText(newProjectName)).not.toBeVisible();
// Remove Archived filter and apply Active filter to see the project
await removeStatusFilter(page);
await selectStatusFilter(page, 'Active');
await expect(page.getByText(newProjectName)).toBeVisible();
await Promise.all([
page.getByRole('menuitem').getByText('Unarchive').first().click(),
expect(page.getByText(newProjectName)).not.toBeVisible(),
]);
await Promise.all([
page.getByRole('tab', { name: 'Active' }).click(),
expect(page.getByText(newProjectName)).toBeVisible(),
]);
});
test('test that updating billable rate works with existing time entries', async ({ page }) => {
@@ -151,147 +116,6 @@ test('test that updating billable rate works with existing time entries', async
).toBeVisible();
});
// Sorting tests
test('test that sorting projects by name works', async ({ page }) => {
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Wait for the table to load
await expect(page.getByTestId('project_table')).toBeVisible();
// Get initial project names
const getProjectNames = async () => {
const rows = page
.getByTestId('project_table')
.locator('[data-testid="project_table"] > div')
.filter({ hasNot: page.locator('.border-t') });
const names: string[] = [];
const count = await page.getByTestId('project_table').getByRole('row').count();
for (let i = 0; i < count; i++) {
const row = page.getByTestId('project_table').getByRole('row').nth(i);
const nameCell = row.locator('div').first();
const text = await nameCell.textContent();
if (text) {
names.push(text.trim());
}
}
return names;
};
// Click on Name header to sort ascending (default should already be ascending)
const nameHeader = page.getByText('Name').first();
await nameHeader.click();
// Wait for sort to apply
await page.waitForTimeout(100);
// Click again to sort descending
await nameHeader.click();
await page.waitForTimeout(100);
// Verify the sort indicator is showing descending
await expect(page.locator('svg').first()).toBeVisible();
});
test('test that sorting projects by status works', async ({ page }) => {
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Default is "all" so no filter needed - Wait for the table to load
await expect(page.getByTestId('project_table')).toBeVisible();
// Click on Status header to sort
const statusHeader = page.getByText('Status').first();
await statusHeader.click();
// Wait for sort to apply
await page.waitForTimeout(100);
// Sort indicator should be visible
await expect(statusHeader.locator('svg')).toBeVisible();
});
// Filter tests
test('test that filtering projects by status works', async ({ page }) => {
const newProjectName = 'Filter Test Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Create a new project
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
await page.getByRole('button', { name: 'Create Project' }).click();
await expect(page.getByText(newProjectName)).toBeVisible();
// Archive the project
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Archive').first().click();
// Project should still be visible (default is "all" - no filter)
await expect(page.getByText(newProjectName)).toBeVisible();
// Apply Active filter - archived project should disappear
await selectStatusFilter(page, 'Active');
await expect(page.getByText(newProjectName)).not.toBeVisible();
// Remove Active filter - project should reappear (back to "all")
await removeStatusFilter(page);
await expect(page.getByText(newProjectName)).toBeVisible();
// Apply Archived filter - project should still be visible
await selectStatusFilter(page, 'Archived');
await expect(page.getByText(newProjectName)).toBeVisible();
// Remove Archived filter and apply Active filter - project should not be visible
await removeStatusFilter(page);
await selectStatusFilter(page, 'Active');
await expect(page.getByText(newProjectName)).not.toBeVisible();
});
test('test that filter state persists after page reload', async ({ page }) => {
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Apply Active status filter
await selectStatusFilter(page, 'Active');
// Verify the filter badge is visible
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
// Wait for the state to be saved
await page.waitForTimeout(100);
// Reload the page
await page.reload();
// Verify the filter badge is still visible after reload
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
});
test('test that sort state persists after page reload', async ({ page }) => {
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Click on Name header twice to sort descending
const nameHeader = page.getByText('Name').first();
await nameHeader.click();
await nameHeader.click();
// Wait for the state to be saved
await page.waitForTimeout(100);
// Reload the page
await page.reload();
// Verify descending sort indicator is visible on Name column
await expect(page.getByTestId('project_table')).toBeVisible();
});
// Create new project with new Client
// Create new project with existing Client
@@ -300,6 +124,8 @@ test('test that sort state persists after page reload', async ({ page }) => {
// Test that project task count is displayed correctly
// Test that active / archive / all filter works (once implemented)
// Edit Project Modal Test
// Add Project with billable rate

View File

@@ -26,10 +26,7 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
// Then create the time entry
await goToTimeOverview(page);
// Open the dropdown menu and click "Manual time entry"
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page
@@ -55,10 +52,7 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
await goToTimeOverview(page);
// Open the dropdown menu and click "Manual time entry"
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page
@@ -87,10 +81,7 @@ async function createTimeEntryWithBillableStatus(
duration: string
) {
await goToTimeOverview(page);
// Open the dropdown menu and click "Manual time entry"
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page

View File

@@ -14,7 +14,6 @@ use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\OverlappingTimeEntryApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
@@ -48,7 +47,6 @@ return [
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
InvitationForTheEmailAlreadyExistsApiException::KEY => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
OverlappingTimeEntryApiException::KEY => 'Overlapping time entries are not allowed.',
],
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
];

View File

@@ -203,7 +203,6 @@ return [
'organization' => 'The :attribute does not exist.',
'task_belongs_to_project' => 'The :attribute is not part of the given project.',
'project_name_already_exists' => 'A project with the same name and client already exists in the organization.',
'overlapping_time_entry' => 'Overlapping time entries are not allowed.',
'tag_name_already_exists' => 'A tag with the same name already exists in the organization.',
'client_name_already_exists' => 'A client with the same name already exists in the organization.',
'task_name_already_exists' => 'A task with the same name already exists in the project.',

79
package-lock.json generated
View File

@@ -7,11 +7,6 @@
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/vue": "^1.0.6",
"@fullcalendar/core": "^6.1.18",
"@fullcalendar/daygrid": "^6.1.18",
"@fullcalendar/interaction": "^6.1.18",
"@fullcalendar/timegrid": "^6.1.18",
"@fullcalendar/vue3": "^6.1.18",
"@heroicons/vue": "^2.1.1",
"@rushstack/eslint-patch": "^1.10.5",
"@tailwindcss/container-queries": "^0.1.1",
@@ -23,7 +18,6 @@
"@vue/eslint-config-typescript": "^14.3.0",
"@vueuse/core": "^12.8.2",
"@vueuse/integrations": "^12.5.0",
"chroma-js": "3.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
@@ -45,7 +39,6 @@
"@playwright/test": "^1.41.1",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@types/chroma-js": "2.4.5",
"@types/node": "^22.10.10",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.5.1",
@@ -1034,55 +1027,6 @@
"vue-demi": ">=0.13.0"
}
},
"node_modules/@fullcalendar/core": {
"version": "6.1.18",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.18.tgz",
"integrity": "sha512-cD7XtZIZZ87Cg2+itnpsONCsZ89VIfLLDZ22pQX4IQVWlpYUB3bcCf878DhWkqyEen6dhi5ePtBoqYgm5K+0fQ==",
"license": "MIT",
"dependencies": {
"preact": "~10.12.1"
}
},
"node_modules/@fullcalendar/daygrid": {
"version": "6.1.18",
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.18.tgz",
"integrity": "sha512-s452Zle1SdMEzZDw+pDczm8m3JLIZzS9ANMThXTnqeqJewW1gqNFYas18aHypJSgF9Fh9rDJjTSUw04BpXB/Mg==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.18"
}
},
"node_modules/@fullcalendar/interaction": {
"version": "6.1.18",
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.18.tgz",
"integrity": "sha512-f/mD5RTjzw+Q6MGTMZrLCgIrQLIUUO9NV/58aM2J6ZBQZeRlNizDqmqldqyG+j49zj2vFhUfZibPrVKWm5yA4Q==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.18"
}
},
"node_modules/@fullcalendar/timegrid": {
"version": "6.1.18",
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.18.tgz",
"integrity": "sha512-T/ouhs+T1tM8JcW7Cjx+KiohL/qQWKqvRITwjol8ktJ1e1N/6noC40/obR1tyolqOxMRWHjJkYoj9fUqfoez9A==",
"license": "MIT",
"dependencies": {
"@fullcalendar/daygrid": "~6.1.18"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.18"
}
},
"node_modules/@fullcalendar/vue3": {
"version": "6.1.18",
"resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.18.tgz",
"integrity": "sha512-YMagwTumxsIx3GFYWLa9Yr73EMA+JuH6S3EeZGS+rEjvG5fDGdf+33rxGMzmw+LdO7SWi3ctbzRnJlv3fnm3RQ==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.18",
"vue": "^3.0.11"
}
},
"node_modules/@heroicons/vue": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
@@ -1898,13 +1842,6 @@
"vue": "^2.7.0 || ^3.0.0"
}
},
"node_modules/@types/chroma-js": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.4.5.tgz",
"integrity": "sha512-6ISjhzJViaPCy2q2e6PgK+8HcHQDQ0V2LDiKmYAh+jJlLqDa6HbwDh0wOevHY0kHHUx0iZwjSRbVD47WOUx5EQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
@@ -2940,12 +2877,6 @@
"node": ">= 6"
}
},
"node_modules/chroma-js": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.1.2.tgz",
"integrity": "sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg==",
"license": "(BSD-3-Clause AND Apache-2.0)"
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -5209,16 +5140,6 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/preact": {
"version": "10.12.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",

View File

@@ -19,7 +19,6 @@
"@playwright/test": "^1.41.1",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@types/chroma-js": "2.4.5",
"@types/node": "^22.10.10",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.5.1",
@@ -40,11 +39,6 @@
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/vue": "^1.0.6",
"@fullcalendar/core": "^6.1.18",
"@fullcalendar/daygrid": "^6.1.18",
"@fullcalendar/interaction": "^6.1.18",
"@fullcalendar/timegrid": "^6.1.18",
"@fullcalendar/vue3": "^6.1.18",
"@heroicons/vue": "^2.1.1",
"@rushstack/eslint-patch": "^1.10.5",
"@tailwindcss/container-queries": "^0.1.1",
@@ -56,7 +50,6 @@
"@vue/eslint-config-typescript": "^14.3.0",
"@vueuse/core": "^12.8.2",
"@vueuse/integrations": "^12.5.0",
"chroma-js": "3.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",

View File

@@ -1,14 +1,237 @@
/* Import shared solidtime styles from UI package */
@import '../js/packages/ui/styles.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
:root.dark {
--color-bg-primary: #101012;
--color-bg-secondary: #17181B;
--color-bg-tertiary: #2A2C32;
--color-bg-quaternary: #141518;
--color-bg-background: #090909;
--color-text-primary: #ffffff;
--color-text-secondary: #e3e4e6;
--color-text-tertiary: #969799;
--color-text-quaternary: #595a5c;
/* Main app specific styles - Inter font */
--color-border-primary: #191b1f;
--color-border-secondary: #23252a;
--color-border-tertiary: #2c2e33;
--color-border-quaternary: #393B42;
--color-input-border-active: rgba(255,255,255,0.3);
--theme-color-chart: var(--color-accent-200);
--theme-color-menu-active: var(--color-bg-secondary);
--theme-color-card-background: var(--color-bg-secondary);
--theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%);
--theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%);
--theme-color-card-background-active: var(--color-bg-tertiary);
--theme-color-row-background: var(--color-bg-primary);
--theme-color-row-heading-background: var(--theme-color-card-background);
--theme-color-row-heading-border: var(--theme-color-card-border);
--theme-color-icon-default: var(--color-text-tertiary);
--theme-color-ring: rgba(255,255,255,0.5);
--theme-color-button-primary-background: rgba(var(--color-accent-300), 0.1);
--theme-color-button-primary-background-hover: rgba(var(--color-accent-300), 0.2);
--theme-color-button-primary-border: rgba(var(--color-accent-300), 0.2);
--theme-color-button-primary-text: var(--color-text-primary);
--theme-color-input-background: var(--color-bg-secondary);
--theme-color-input-select-active: rgb(var(--color-accent-300));
--theme-color-input-select-active-hover: rgb(var(--color-accent-200));
--color-accent-default: rgba(var(--color-accent-300), 0.2);
--color-accent-foreground: rgb(var(--color-accent-100));
}
:root.light {
--color-bg-primary: #F5F5F5;
--color-bg-secondary: #f7f7f8;
--color-bg-tertiary: #e1e1e3;
--color-bg-quaternary: #ffffff;
--color-bg-background: #ffffff;
--color-text-primary: #18181b;
--color-text-secondary: #3f3f46;
--color-text-tertiary: #57575C;
--color-text-quaternary: #a1a1aa;
--color-border-primary: #e7e7e7;
--color-border-secondary: #e5e5e5;
--color-border-tertiary: #dfdfdf;
--color-border-quaternary: #d1d1d1;
--color-input-border-active: rgba(0,0,0,0.3);
--theme-color-menu-active: var(--color-bg-tertiary);
--theme-color-card-background: var(--color-bg-quaternary);
--theme-color-card-background-active: var(--color-bg-primary);
--theme-color-chart: var(--color-accent-400);
--theme-shadow-card: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--theme-color-row-background: var(--theme-color-card-background);
--theme-color-row-heading-background: var(--color-bg-secondary);
--theme-color-row-heading-border: var(--color-border-tertiary);
--theme-color-icon-default: var(--color-text-quaternary);
--theme-color-ring: rgba(0,0,0, 0.7);
--theme-color-button-primary-background: rgba(var(--color-accent-600), 0.9);
--theme-color-button-primary-background-hover: rgba(var(--color-accent-600), 1);
--theme-color-button-primary-border: rgba(var(--color-accent-600), 1);
--theme-color-button-primary-text: #FFFFFF;
--theme-color-input-background: var(--color-bg-quaternary);
--theme-color-input-select-active: rgb(var(--color-accent-400));
--theme-color-input-select-active-hover: rgb(var(--color-accent-500));
--color-accent-default: rgb(var(--color-accent-100));
--color-accent-foreground: rgb(var(--color-accent-800));
}
:root {
--theme-color-default-background: var(--color-bg-primary);
--theme-color-icon-active: rgb(var(--color-text-tertiary));
--theme-color-card-background-separator: var(--color-border-tertiary);
--theme-color-card-border: var(--color-border-secondary);
--theme-color-card-border-active: var(--color-border-tertiary);
--theme-color-default-background-separator: var(--color-border-primary);
--theme-color-primary-text: var(--color-text-primary);
--theme-color-input-border: var(--color-border-quaternary);
--theme-color-tab-background: var(--theme-color-card-background);
--theme-color-tab-background-active: var(--theme-color-card-background-active);
--theme-color-tab-border: var(--theme-color-card-border);
--theme-color-row-separator-background: var(--theme-color-default-background-separator);
--theme-color-row-border: var(--theme-color-card-border);
--color-accent-50: 240, 249, 255; /* sky-50 */
--color-accent-100: 224, 242, 254; /* sky-100 */
--color-accent-200: 186, 230, 253; /* sky-200 */
--color-accent-300: 125, 211, 252; /* sky-300 */
--color-accent-400: 56, 189, 248; /* sky-400 */
--color-accent-500: 14, 165, 233; /* sky-500 */
--color-accent-600: 2, 132, 199; /* sky-600 */
--color-accent-700: 3, 105, 161; /* sky-700 */
--color-accent-800: 7, 89, 133; /* sky-800 */
--color-accent-900: 12, 74, 110; /* sky-900 */
--color-accent-950: 8, 47, 73; /* sky-950 */
--theme-button-secondary-background: var(--theme-color-card-background);
--theme-button-secondary-background-active: var(--theme-color-card-background-active);
--popover-border: var(--color-border-secondary);
}
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* width */
::-webkit-scrollbar {
width: 5px;
}
/* Track */
::-webkit-scrollbar-track, ::-webkit-scrollbar-corner {
background: transparent;
}
/* Handle */
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 2px;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: #555;
}
[x-cloak] {
display: none;
}
body {
background-color: var(--theme-color-default-background);
}
/* Inter Variable Font with browser compatibility considerations */
@font-face {
font-family: 'Inter';
src:
url('/fonts/InterVariable.woff2') format('woff2'),
url('/fonts/InterVariable.ttf') format('truetype');
src: url('/fonts/InterVariable.woff2') format('woff2'),
url('/fonts/InterVariable.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}
@layer base {
:root {
--background: var(--color-bg-background);
--foreground: var(--color-text-primary);
--card: var(--theme-color-card-background);
--card-foreground: var(--color-text-primary);
--popover: var(--theme-color-card-background);
--popover-foreground: var(--color-text-primary);
--primary: var(--theme-color-button-primary-background);
--primary-foreground: var(--theme-color-button-primary-text);
--secondary: var(--color-bg-secondary);
--secondary-foreground: var(--color-text-primary);
--muted: var(--color-bg-tertiary);
--muted-foreground: var(--color-text-tertiary);
--accent: var(--theme-color-button-primary-background);
--accent-foreground: var(--theme-color-button-primary-text);
--destructive: 0 84.2% 60.2%;
--destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary);
--input: var(--color-border-tertiary);
--ring: var(--theme-color-ring);
--chart-1: var(--color-accent-400);
--chart-2: var(--color-accent-500);
--chart-3: var(--color-accent-600);
--chart-4: var(--color-accent-700);
--chart-5: var(--color-accent-800);
--radius: 0.5rem;
}
.dark {
--background: var(--color-bg-background);
--foreground: var(--color-text-primary);
--card: var(--theme-color-card-background);
--card-foreground: var(--color-text-primary);
--popover: var(--theme-color-card-background);
--popover-foreground: var(--color-text-primary);
--primary: var(--theme-color-button-primary-background);
--primary-foreground: var(--theme-color-button-primary-text);
--secondary: var(--color-bg-secondary);
--secondary-foreground: var(--color-text-primary);
--muted: var(--color-bg-tertiary);
--muted-foreground: var(--color-text-tertiary);
--accent: var(--theme-color-button-primary-background);
--accent-foreground: var(--theme-color-button-primary-text);
--destructive: 0 62.8% 30.6%;
--destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary);
--input: var(--color-border-tertiary);
--ring: var(--theme-color-ring);
--chart-1: var(--color-accent-200);
--chart-2: var(--color-accent-300);
--chart-3: var(--color-accent-400);
--chart-4: var(--color-accent-500);
--chart-5: var(--color-accent-600);
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -4,11 +4,12 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template>
<TableHeading>
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<div
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary"></div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary"></div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
</div>

View File

@@ -4,10 +4,11 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template>
<TableHeading>
<div class="px-3 py-1.5 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<div
class="px-3 py-1.5 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Email
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
<span class="sr-only">Edit</span>
</div>

View File

@@ -4,13 +4,14 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template>
<TableHeading>
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<div
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Email</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Billable Rate</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Email</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Billable Rate</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
<span class="sr-only">Edit</span>
</div>

View File

@@ -1,48 +0,0 @@
<script setup lang="ts">
import { XMarkIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { Component } from 'vue';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
defineProps<{
icon: Component;
label: string;
filterName: string;
}>();
defineEmits<{
remove: [];
}>();
defineSlots<{
default(): void;
}>();
</script>
<template>
<div
class="inline-flex items-center gap-0.5 rounded-md bg-tertiary dark:bg-secondary border border-border-secondary">
<DropdownMenu>
<DropdownMenuTrigger
class="inline-flex items-center gap-1.5 px-2 py-1 text-sm hover:bg-quaternary dark:hover:bg-tertiary rounded-l-md transition-colors whitespace-nowrap">
<component :is="icon" class="h-3.5 w-3.5 text-icon-default" />
<span class="font-medium text-foreground">{{ filterName }}</span>
<span class="text-muted-foreground">is</span>
<span class="text-foreground">{{ label }}</span>
<ChevronDownIcon class="h-3 w-3 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<slot />
</DropdownMenuContent>
</DropdownMenu>
<button
class="px-1.5 py-1 hover:bg-quaternary dark:hover:bg-tertiary h-full rounded-r-md transition-colors group border-l border-border-secondary"
@click="$emit('remove')">
<XMarkIcon class="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground" />
</button>
</div>
</template>

View File

@@ -1,68 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { UserGroupIcon } from '@heroicons/vue/16/solid';
import { DropdownMenuCheckboxItem, DropdownMenuSeparator } from '@/Components/ui/dropdown-menu';
import BaseFilterBadge from './BaseFilterBadge.vue';
import type { Client } from '@/packages/api/src';
import { NO_CLIENT_ID } from './constants';
const props = defineProps<{
value: string[];
clients: Client[];
}>();
const emit = defineEmits<{
remove: [];
'update:value': [value: string[]];
}>();
const hasNoClient = computed(() => props.value.includes(NO_CLIENT_ID));
const label = computed(() => {
const count = props.value.length;
if (count === 0) return 'None';
if (count === 1) {
if (hasNoClient.value) return 'No client';
const client = props.clients.find((c) => c.id === props.value[0]);
return client?.name ?? 'Client';
}
return `${count} selected`;
});
function toggleClient(clientId: string) {
const clientIds = props.value.includes(clientId)
? props.value.filter((id) => id !== clientId)
: [...props.value, clientId];
emit('update:value', clientIds);
}
function toggleNoClient() {
const clientIds = hasNoClient.value
? props.value.filter((id) => id !== NO_CLIENT_ID)
: [...props.value, NO_CLIENT_ID];
emit('update:value', clientIds);
}
</script>
<template>
<BaseFilterBadge
:icon="UserGroupIcon"
:label="label"
filter-name="Client"
@remove="emit('remove')">
<DropdownMenuCheckboxItem :model-value="hasNoClient" @select.prevent="toggleNoClient">
No client
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
v-for="client in clients"
:key="client.id"
:model-value="value.includes(client.id)"
@select.prevent="toggleClient(client.id)">
{{ client.name }}
</DropdownMenuCheckboxItem>
</BaseFilterBadge>
</template>

View File

@@ -130,7 +130,7 @@ function updateValue(project: Project) {
<ComboboxAnchor>
<ComboboxInput
ref="searchInput"
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a project..."
@keydown.enter="addProjectIfNoneExists" />
</ComboboxAnchor>

View File

@@ -1,46 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { CircleStackIcon } from '@heroicons/vue/16/solid';
import { DropdownMenuItem } from '@/Components/ui/dropdown-menu';
import BaseFilterBadge from './BaseFilterBadge.vue';
type StatusValue = 'active' | 'archived' | 'all';
const props = defineProps<{
value: StatusValue;
}>();
const emit = defineEmits<{
remove: [];
'update:value': [value: StatusValue];
}>();
const statusOptions = [
{ id: 'active' as const, name: 'Active' },
{ id: 'archived' as const, name: 'Archived' },
];
const label = computed(() => {
return statusOptions.find((opt) => opt.id === props.value)?.name ?? 'Status';
});
function updateStatus(status: StatusValue) {
emit('update:value', status);
}
</script>
<template>
<BaseFilterBadge
:icon="CircleStackIcon"
:label="label"
filter-name="Status"
@remove="emit('remove')">
<DropdownMenuItem
v-for="option in statusOptions"
:key="option.id"
:class="[value === option.id && 'bg-accent text-accent-foreground']"
@click="updateStatus(option.id)">
{{ option.name }}
</DropdownMenuItem>
</BaseFilterBadge>
</template>

View File

@@ -4,10 +4,7 @@ import { FolderPlusIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { computed, ref } from 'vue';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import ProjectTableHeading, {
type SortColumn,
type SortDirection,
} from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
@@ -15,96 +12,13 @@ import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import {
useVueTable,
getCoreRowModel,
getSortedRowModel,
type SortingState,
} from '@tanstack/vue-table';
const props = defineProps<{
projects: Project[];
showBillableRate: boolean;
sortColumn: SortColumn;
sortDirection: SortDirection;
}>();
const emit = defineEmits<{
sort: [column: SortColumn];
}>();
const { clients } = storeToRefs(useClientsStore());
// Create a map of client names for sorting
const clientNameMap = computed(() => {
const map = new Map<string, string>();
clients.value.forEach((client) => {
map.set(client.id, client.name);
});
return map;
});
// Convert our sort state to TanStack Table format
const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
]);
// Define column accessors for sorting
const columns = [
{
id: 'name',
accessorFn: (row: Project) => row.name.toLowerCase(),
},
{
id: 'client_name',
accessorFn: (row: Project) => {
if (!row.client_id) return '';
return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase();
},
},
{
id: 'spent_time',
accessorFn: (row: Project) => row.spent_time ?? 0,
},
{
id: 'billable_rate',
accessorFn: (row: Project) => row.billable_rate ?? 0,
},
{
id: 'status',
accessorFn: (row: Project) => (row.is_archived ? 1 : 0),
},
];
const table = useVueTable({
get data() {
return props.projects;
},
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedProjects = computed(() => {
return table.getRowModel().rows.map((row) => row.original);
});
function handleSort(column: SortColumn) {
emit('sort', column);
}
const showCreateProjectModal = ref(false);
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
return await useProjectsStore().createProject(project);
}
@@ -112,10 +26,11 @@ async function createProject(project: CreateProjectBody): Promise<Project | unde
async function createClient(client: CreateClientBody): Promise<Client | undefined> {
return await useClientsStore().createClient(client);
}
const { clients } = storeToRefs(useClientsStore());
const gridTemplate = computed(() => {
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) 80px;`;
});
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
</script>
<template>
@@ -130,11 +45,8 @@ const gridTemplate = computed(() => {
<div class="inline-block min-w-full align-middle">
<div data-testid="project_table" class="grid min-w-full" :style="gridTemplate">
<ProjectTableHeading
:show-billable-rate="props.showBillableRate"
:sort-column="props.sortColumn"
:sort-direction="props.sortDirection"
@sort="handleSort"></ProjectTableHeading>
<div v-if="sortedProjects.length === 0" class="col-span-5 py-24 text-center">
:show-billable-rate="props.showBillableRate"></ProjectTableHeading>
<div v-if="projects.length === 0" class="col-span-5 py-24 text-center">
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-text-primary font-semibold">
{{
@@ -157,7 +69,7 @@ const gridTemplate = computed(() => {
>Create your First Project
</SecondaryButton>
</div>
<template v-for="project in sortedProjects" :key="project.id">
<template v-for="project in projects" :key="project.id">
<ProjectTableRow
:show-billable-rate="props.showBillableRate"
:project="project"></ProjectTableRow>

View File

@@ -1,89 +1,23 @@
<script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue';
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
export type SortColumn = 'name' | 'client_name' | 'spent_time' | 'billable_rate' | 'status';
export type SortDirection = 'asc' | 'desc';
const props = defineProps<{
defineProps<{
showBillableRate: boolean;
sortColumn: SortColumn;
sortDirection: SortDirection;
}>();
const emit = defineEmits<{
sort: [column: SortColumn];
}>();
function handleSort(column: SortColumn) {
emit('sort', column);
}
function isSorted(column: SortColumn): boolean {
return props.sortColumn === column;
}
</script>
<template>
<TableHeading>
<div
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('name')">
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
<ChevronDownIcon v-if="isSorted('name') && sortDirection === 'asc'" class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('name') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('client_name')">
Client
<ChevronDownIcon
v-if="isSorted('client_name') && sortDirection === 'asc'"
class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('client_name') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('spent_time')">
Total Time
<ChevronDownIcon
v-if="isSorted('spent_time') && sortDirection === 'asc'"
class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('spent_time') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Progress</div>
<div
v-if="showBillableRate"
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('billable_rate')">
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Client</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Total Time</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Progress</div>
<div v-if="showBillableRate" class="px-3 py-1.5 text-left font-semibold text-text-primary">
Billable Rate
<ChevronDownIcon
v-if="isSorted('billable_rate') && sortDirection === 'asc'"
class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('billable_rate') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('status')">
Status
<ChevronDownIcon v-if="isSorted('status') && sortDirection === 'asc'" class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('status') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
</div>

View File

@@ -2,7 +2,7 @@
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
import type { Project } from '@/packages/api/src';
import { computed, ref, inject, type ComputedRef } from 'vue';
import { CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/vue/24/outline';
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
import { useTasksStore } from '@/utils/useTasks';
@@ -116,15 +116,9 @@ const showEditProjectModal = ref(false);
{{ billableRateInfo }}
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="project.is_archived">
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<span>Archived</span>
</template>
<template v-else>
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span>
</template>
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
<CheckCircleIcon class="w-5"></CheckCircleIcon>
<span>Active</span>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">

View File

@@ -1,129 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { UserGroupIcon, CheckCircleIcon } from '@heroicons/vue/16/solid';
import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuCheckboxItem,
DropdownMenuSeparator,
} from '@/Components/ui/dropdown-menu';
import { Button } from '@/packages/ui/src';
import type { Client } from '@/packages/api/src';
import { NO_CLIENT_ID } from './constants';
export interface ProjectFilters {
status: 'active' | 'archived' | 'all';
clientIds: string[];
}
const props = defineProps<{
filters: ProjectFilters;
clients: Client[];
}>();
const emit = defineEmits<{
'update:filters': [filters: ProjectFilters];
}>();
const statusOptions = [
{ id: 'active' as const, name: 'Active' },
{ id: 'archived' as const, name: 'Archived' },
];
const open = ref(false);
function updateStatus(status: 'active' | 'archived' | 'all') {
emit('update:filters', {
...props.filters,
status,
});
open.value = false;
}
function toggleClient(clientId: string) {
const clientIds = props.filters.clientIds.includes(clientId)
? props.filters.clientIds.filter((id) => id !== clientId)
: [...props.filters.clientIds, clientId];
emit('update:filters', {
...props.filters,
clientIds,
});
}
function toggleNoClient() {
const clientIds = props.filters.clientIds.includes(NO_CLIENT_ID)
? props.filters.clientIds.filter((id) => id !== NO_CLIENT_ID)
: [...props.filters.clientIds, NO_CLIENT_ID];
emit('update:filters', {
...props.filters,
clientIds,
});
}
const hasActiveFilters = computed(() => {
return props.filters.status !== 'all' || props.filters.clientIds.length > 0;
});
</script>
<template>
<DropdownMenu v-model:open="open">
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="xs" aria-label="Filter projects">
<ListFilterIcon
:class="[hasActiveFilters ? '' : '-ml-0.5', 'h-4 w-4 text-icon-default']" />
<span v-if="!hasActiveFilters" class="text-nowrap">Filter</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-56">
<!-- Status Filter -->
<DropdownMenuSub>
<DropdownMenuSubTrigger class="gap-2">
<CheckCircleIcon class="h-4 w-4 text-icon-default" />
<span>Status</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
v-for="option in statusOptions"
:key="option.id"
:class="[
filters.status === option.id && 'bg-accent text-accent-foreground',
]"
@click="updateStatus(option.id)">
{{ option.name }}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<!-- Client Filter -->
<DropdownMenuSub v-if="clients.length > 0">
<DropdownMenuSubTrigger class="gap-2">
<UserGroupIcon class="h-4 w-4 text-icon-default" />
<span>Client</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent class="max-h-[300px] overflow-y-auto">
<DropdownMenuCheckboxItem
:model-value="filters.clientIds.includes(NO_CLIENT_ID)"
@select.prevent="toggleNoClient">
No client
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
v-for="client in clients"
:key="client.id"
:model-value="filters.clientIds.includes(client.id)"
@select.prevent="toggleClient(client.id)">
{{ client.name }}
</DropdownMenuCheckboxItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</template>

View File

@@ -1 +0,0 @@
export const NO_CLIENT_ID = '__no_client__';

View File

@@ -4,11 +4,12 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template>
<TableHeading>
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<div
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Billable Rate</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Billable Rate</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
</div>

View File

@@ -4,12 +4,13 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template>
<TableHeading>
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<div
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Description</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Visibility</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Public URL</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Description</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Visibility</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Public URL</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
</div>

View File

@@ -113,7 +113,7 @@ const option = computed(() => ({
},
axisLabel: {
fontSize: 12,
fontWeight: 400,
fontWeight: 600,
color: labelColor.value,
margin: 16,
fontFamily: 'Inter, sans-serif',

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Button } from '@/packages/ui/src';
import { Button } from '@/Components/ui/button';
const props = defineProps<{
icon: Component;

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { Switch } from '@/Components/ui/switch';
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src';
import { Button } from '@/packages/ui/src';
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { Button } from '@/Components/ui/button';
import {
Select,
SelectContent,

View File

@@ -30,7 +30,10 @@ const organization = inject<ComputedRef<Organization>>('organization');
<template>
<div
class="contents text-text-primary [&>*]:transition [&>*]:border-card-background-separator [&>*]:border-b [&>*]:h-[50px]">
<div :class="twMerge('pl-6 flex items-center space-x-3', props.indent ? 'pl-16' : '')">
<div
:class="
twMerge('pl-6 font-medium flex items-center space-x-3', props.indent ? 'pl-16' : '')
">
<GroupedItemsCountButton
v-if="entry.grouped_data && entry.grouped_data?.length > 0"
:expanded="expanded"

View File

@@ -2,7 +2,7 @@
<template>
<div
class="contents [&>*]:border-row-separator text-xs [&>*]:border-b [&>*]:border-t [&>*]:bg-row-heading-background">
class="contents [&>*]:border-row-separator text-xs sm:text-sm [&>*]:border-b [&>*]:border-t [&>*]:bg-row-heading-background">
<slot></slot>
</div>
</template>

View File

@@ -4,7 +4,8 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template>
<TableHeading>
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<div
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">

View File

@@ -4,12 +4,13 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template>
<TableHeading>
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<div
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Task Name
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Total Time</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Progress</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Total Time</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Progress</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
</div>

View File

@@ -1,45 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useForm, usePage } from '@inertiajs/vue3';
import type { User } from '@/types/models';
import TimezoneMismatchModal from '@/packages/ui/src/TimezoneMismatchModal.vue';
const show = defineModel('show', { default: false });
const saving = ref(false);
const page = usePage<{
auth: {
user: User;
};
}>();
function handleUpdate(timezone: string) {
saving.value = true;
const form = useForm({
_method: 'PUT',
timezone: timezone,
name: page.props.auth.user.name,
email: page.props.auth.user.email,
week_start: page.props.auth.user.week_start,
});
form.post(route('user-profile-information.update'), {
errorBag: 'updateProfileInformation',
preserveScroll: true,
onSuccess: () => {
saving.value = false;
show.value = false;
location.reload();
},
onError: () => {
saving.value = false;
},
});
}
</script>
<template>
<TimezoneMismatchModal v-model:show="show" :saving="saving" @update="handleUpdate" />
</template>
<style scoped></style>

View File

@@ -1,7 +1,8 @@
<template>
<section class="flex flex-col">
<CardTitle :title="title" :icon="icon"></CardTitle>
<div class="rounded-lg border border-card-border flex-1 flex items-stretch">
<div
class="rounded-lg bg-card-background border border-card-border flex-1 flex items-stretch shadow-card">
<div class="w-full flex flex-col">
<slot></slot>
</div>

View File

@@ -4,7 +4,9 @@ import { computed } from 'vue';
import RecentlyTrackedTasksCardEntry from '@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue';
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { PlusCircleIcon } from '@heroicons/vue/24/solid';
import { router } from '@inertiajs/vue3';
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '@/packages/api/src';
import { LoadingSpinner } from '@/packages/ui/src';
@@ -88,8 +90,23 @@ window.addEventListener('dashboard:refresh', () => {
<div v-else class="text-center flex flex-1 justify-center items-center">
<div>
<PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
<h3 class="text-text-primary font-semibold text-sm">No recent time entries</h3>
<p class="pb-5 text-sm">Start tracking your time!</p>
<h3 class="text-text-primary font-semibold text-sm">No recent tasks found</h3>
<p class="pb-5 text-sm">Create tasks inside of a project!</p>
<SecondaryButton @click="router.visit(route('projects'))"
>Go to Projects
</SecondaryButton>
</div>
</div>
<div
v-if="latestTasks && latestTasks.length === 1"
class="text-center flex flex-1 justify-center items-center text-sm">
<div>
<PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
<h3 class="text-text-primary font-semibold">Add more tasks</h3>
<p class="pb-5">Create tasks inside of a project!</p>
<SecondaryButton @click="router.visit(route('projects'))"
>Go to Projects
</SecondaryButton>
</div>
</div>
</DashboardCard>

View File

@@ -10,8 +10,7 @@ defineProps<{
<div class="px-4 py-2 2xl:py-3 border-b border-b-background-separator">
<div class="col-span-2">
<div class="flex justify-between">
<p
class="font-semibold text-sm min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-primary">
<p class="font-semibold text-sm text-text-primary">
{{ name }}
</p>
<div v-if="working" class="flex space-x-1.5 items-center justify-end">

View File

@@ -1,7 +1,7 @@
<template>
<div
aria-live="assertive"
class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-end sm:p-6 z-[70]">
class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-end sm:p-6 sm:pb-24 z-[70]">
<div class="flex w-full flex-col items-center space-y-4 sm:items-end">
<Notification
v-for="notification in notifications"

View File

@@ -1,23 +1,12 @@
<script setup lang="ts">
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
import { Link, usePage } from '@inertiajs/vue3';
import {
Cog6ToothIcon,
PlusCircleIcon,
CheckCircleIcon,
ArrowRightIcon,
} from '@heroicons/vue/24/solid';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import DropdownLink from '@/Components/DropdownLink.vue';
import { usePage } from '@inertiajs/vue3';
import type { Organization, User } from '@/types/models';
import { isBillingActivated } from '@/utils/billing';
import { canManageBilling } from '@/utils/permissions';
import { switchOrganization } from '@/utils/useOrganization';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
} from '@/Components/ui/dropdown-menu';
const page = usePage<{
jetstream: {
@@ -39,79 +28,84 @@ const switchToTeam = (organization: Organization) => {
</script>
<template>
<DropdownMenu v-if="page.props.jetstream.hasTeamFeatures">
<DropdownMenuTrigger
class="flex w-full text-left hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-ring cursor-pointer transition pl-2 py-1 rounded w-full items-center justify-between"
as-child>
<button data-testid="organization_switcher">
<Dropdown v-if="page.props.jetstream.hasTeamFeatures" align="center" width="60">
<template #trigger>
<div
data-testid="organization_switcher"
class="flex hover:bg-white/10 cursor-pointer transition px-2 py-1 rounded-lg w-full items-center justify-between font-medium">
<div class="flex flex-1 space-x-2 items-center w-[calc(100%-30px)]">
<div
class="rounded bg-blue-900 font-medium text-xs flex-shrink-0 text-white w-5 h-5 flex items-center justify-center">
class="rounded sm:rounded-lg bg-blue-900 font-semibold text-xs sm:text-sm flex-shrink-0 text-white w-5 sm:w-6 h-5 sm:h-6 flex items-center justify-center">
{{ page.props.auth.user.current_team.name.slice(0, 1).toUpperCase() }}
</div>
<span class="text-xs flex-1 truncate font-medium">
<span class="text-sm flex-1 truncate font-semibold">
{{ page.props.auth.user.current_team.name }}
</span>
</div>
<div class="w-[30px]">
<div class="p-1 rounded-full flex items-center w-6 h-6">
<ChevronDownIcon class="w-4 sm:w-full mt-[1px]"></ChevronDownIcon>
</div>
<button
class="p-1 transition hover:bg-white/10 rounded-full flex items-center w-8 h-8">
<ChevronDownIcon class="w-5 sm:w-full mt-[1px]"></ChevronDownIcon>
</button>
</div>
</button>
</DropdownMenuTrigger>
</div>
</template>
<DropdownMenuContent align="start">
<template #content>
<div class="w-60">
<DropdownMenuLabel>Manage Organization</DropdownMenuLabel>
<!-- Organization Management -->
<div class="block px-4 py-2 text-xs text-text-secondary">Manage Organization</div>
<DropdownMenuItem as-child>
<Link
:href="route('teams.show', page.props.auth.user.current_team.id)"
class="inline-flex items-center gap-2.5 w-full">
<Cog6ToothIcon class="w-5 h-5 text-icon-default" />
<span>Organization Settings</span>
</Link>
</DropdownMenuItem>
<!-- Organization Settings -->
<DropdownLink :href="route('teams.show', page.props.auth.user.current_team.id)">
Organization Settings
</DropdownLink>
<DropdownMenuItem v-if="canManageBilling() && isBillingActivated()" as-child>
<Link href="/billing" class="inline-flex items-center w-full"> Billing </Link>
</DropdownMenuItem>
<DropdownLink v-if="canManageBilling() && isBillingActivated()" href="/billing">
Billing
</DropdownLink>
<DropdownMenuItem v-if="page.props.jetstream.canCreateTeams" as-child>
<Link
:href="route('teams.create')"
class="inline-flex items-center gap-2.5 w-full">
<PlusCircleIcon class="w-5 h-5 text-icon-default" />
<span>Create new organization</span>
</Link>
</DropdownMenuItem>
<DropdownLink
v-if="page.props.jetstream.canCreateTeams"
:href="route('teams.create')">
Create new organization
</DropdownLink>
<!-- Organization Switcher -->
<template v-if="page.props.auth.user.all_teams.length > 1">
<div class="border-t border-card-background-separator" />
<DropdownMenuLabel>Switch Organizations</DropdownMenuLabel>
<div class="block px-4 py-2 text-xs text-text-secondary">
Switch Organizations
</div>
<template v-for="team in page.props.auth.user.all_teams" :key="team.id">
<form @submit.prevent="switchToTeam(team)">
<DropdownMenuItem
as-child
class="inline-flex gap-2.5 items-center w-full">
<button type="submit">
<CheckCircleIcon
<DropdownLink as="button">
<div class="flex items-center">
<svg
v-if="team.id == page.props.auth.user.current_team_id"
class="h-5 w-5 text-green-400" />
<ArrowRightIcon v-else class="h-5 w-5 text-icon-default" />
class="me-2 h-5 w-5 text-green-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="w-full truncate text-left">
<div>
{{ team.name }}
</div>
</button>
</DropdownMenuItem>
</div>
</DropdownLink>
</form>
</template>
</template>
</div>
</DropdownMenuContent>
</DropdownMenu>
</template>
</Dropdown>
</template>

View File

@@ -16,25 +16,12 @@ import { useProjectsStore } from '@/utils/useProjects';
import { useTasksStore } from '@/utils/useTasks';
import { useTagsStore } from '@/utils/useTags';
import TimeTrackerControls from '@/packages/ui/src/TimeTracker/TimeTrackerControls.vue';
import type {
CreateClientBody,
CreateProjectBody,
CreateTimeEntryBody,
Project,
Tag,
} from '@/packages/api/src';
import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
import TimeTrackerRunningInDifferentOrganizationOverlay from '@/packages/ui/src/TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
import TimeTrackerMoreOptionsDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerMoreOptionsDropdown.vue';
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
import { useClientsStore } from '@/utils/useClients';
import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canCreateProjects } from '@/utils/permissions';
import { ref } from 'vue';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { useNotificationsStore } from '@/utils/notification';
const page = usePage<{
auth: {
@@ -60,8 +47,6 @@ const emit = defineEmits<{
change: [];
}>();
const showManualTimeEntryModal = ref(false);
watch(isActive, () => {
if (isActive.value) {
startLiveTimer();
@@ -108,73 +93,14 @@ function switchToTimeEntryOrganization() {
switchOrganization(currentTimeEntry.value.organization_id);
}
}
async function createTag(tag: string): Promise<Tag | undefined> {
async function createTag(tag: string) {
return await useTagsStore().createTag(tag);
}
async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
await useTimeEntriesStore().createTimeEntry(timeEntry);
showManualTimeEntryModal.value = false;
}
async function createTimeEntryFromCurrentEntry() {
const { start, end, description, project_id, task_id, billable, tags } = currentTimeEntry.value;
await createTimeEntry({ start, end, description, project_id, task_id, billable, tags });
currentTimeEntryStore.$reset();
}
const { handleApiRequestNotifications } = useNotificationsStore();
const queryClient = useQueryClient();
const deleteTimeEntryMutation = useMutation({
mutationFn: async (timeEntryId: string) => {
const organizationId = getCurrentOrganizationId();
if (!organizationId) {
throw new Error('No organization selected');
}
return await api.deleteTimeEntry(undefined, {
params: {
organization: organizationId,
timeEntry: timeEntryId,
},
});
},
onSuccess: async () => {
await currentTimeEntryStore.fetchCurrentTimeEntry();
await useTimeEntriesStore().fetchTimeEntries();
queryClient.invalidateQueries({ queryKey: ['timeEntry'] });
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
},
});
async function discardCurrentTimeEntry() {
if (currentTimeEntry.value.id) {
await handleApiRequestNotifications(
() => deleteTimeEntryMutation.mutateAsync(currentTimeEntry.value.id),
'Time entry discarded successfully',
'Failed to discard time entry'
);
}
}
const { tags } = storeToRefs(useTagsStore());
const { timeEntries } = storeToRefs(useTimeEntriesStore());
</script>
<template>
<TimeEntryCreateModal
v-model:show="showManualTimeEntryModal"
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:create-project="createProject"
:create-client="createClient"
:create-tag="createTag"
:create-time-entry="createTimeEntry"
:currency="getOrganizationCurrencyString()"
:can-create-project="canCreateProjects()"
:projects
:tasks
:tags
:clients></TimeEntryCreateModal>
<CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle>
<div class="relative">
<TimeTrackerRunningInDifferentOrganizationOverlay
@@ -183,36 +109,24 @@ const { timeEntries } = storeToRefs(useTimeEntriesStore());
switchToTimeEntryOrganization
"></TimeTrackerRunningInDifferentOrganizationOverlay>
<div class="flex w-full items-center gap-2">
<div class="flex w-full items-center gap-2">
<div class="flex-1">
<TimeTrackerControls
v-model:current-time-entry="currentTimeEntry"
v-model:live-timer="now"
:create-project
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:can-create-project="canCreateProjects()"
:create-client
:clients
:tags
:tasks
:projects
:time-entries
:create-tag
:is-active
:currency="getOrganizationCurrencyString()"
@start-live-timer="startLiveTimer"
@stop-live-timer="stopLiveTimer"
@start-timer="setActiveState(true)"
@stop-timer="setActiveState(false)"
@update-time-entry="updateTimeEntry"
@create-time-entry="createTimeEntryFromCurrentEntry"></TimeTrackerControls>
</div>
<TimeTrackerMoreOptionsDropdown
:has-active-timer="isActive"
@manual-entry="showManualTimeEntryModal = true"
@discard="discardCurrentTimeEntry"></TimeTrackerMoreOptionsDropdown>
</div>
</div>
<TimeTrackerControls
v-model:current-time-entry="currentTimeEntry"
v-model:live-timer="now"
:create-project
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:can-create-project="canCreateProjects()"
:create-client
:clients
:tags
:tasks
:projects
:create-tag
:is-active
:currency="getOrganizationCurrencyString()"
@start-live-timer="startLiveTimer"
@stop-live-timer="stopLiveTimer"
@start-timer="setActiveState(true)"
@stop-timer="setActiveState(false)"
@update-time-entry="updateTimeEntry"></TimeTrackerControls>
</div>
</template>

View File

@@ -1,24 +1,10 @@
<script setup lang="ts">
import { Link, router, usePage } from '@inertiajs/vue3';
import { router, usePage } from '@inertiajs/vue3';
import type { Organization, User } from '@/types/models';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
} from '@/Components/ui/dropdown-menu';
import {
UserCircleIcon,
KeyIcon,
ArrowLeftOnRectangleIcon,
ChatBubbleLeftRightIcon,
} from '@heroicons/vue/24/solid';
import { openFeedback } from '@/utils/feedback';
import DropdownLink from '@/Components/DropdownLink.vue';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
const page = usePage<{
has_services_extension?: boolean;
has_billing_extension?: boolean;
jetstream: {
canCreateTeams: boolean;
hasTeamFeatures: boolean;
@@ -37,58 +23,60 @@ const logout = () => {
};
</script>
<template>
<div class="relative">
<DropdownMenu>
<DropdownMenuTrigger
class="flex text-sm border-2 outline-none border-transparent rounded-full focus-visible:ring-2 focus-visible:ring-ring transition"
as-child>
<button data-testid="current_user_button">
<div class="ms-3 relative">
<Dropdown align="center" width="48">
<template #trigger>
<button
v-if="page.props.jetstream.managesProfilePhotos"
data-testid="current_user_button"
class="flex text-sm border-2 border-transparent rounded-full focus:outline-none focus:border-gray-300 transition">
<img
class="h-7 w-7 rounded-full object-cover"
class="h-8 w-8 rounded-full object-cover"
:src="page.props.auth.user.profile_photo_url"
:alt="page.props.auth.user.name" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" class="max-w-48">
<DropdownMenuLabel>Manage Account</DropdownMenuLabel>
<DropdownMenuItem as-child>
<Link
:href="route('profile.show')"
class="inline-flex items-center gap-2.5 w-full">
<UserCircleIcon class="w-5 h-5 text-icon-default" />
<span>Profile Settings</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem v-if="page.props.jetstream.hasApiFeatures" as-child>
<Link
:href="route('api-tokens.index')"
class="inline-flex items-center gap-2.5 w-full">
<KeyIcon class="w-5 h-5 text-icon-default" />
<span>API Tokens</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem v-if="page.props.has_services_extension" as-child>
<span v-else class="inline-flex rounded-md">
<button
type="button"
class="inline-flex items-center gap-2.5 w-full"
@click="openFeedback">
<ChatBubbleLeftRightIcon class="w-5 h-5 text-icon-default" />
<span>Feedback</span>
</button>
</DropdownMenuItem>
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none focus:bg-gray-50 active:bg-gray-50 transition ease-in-out duration-150">
{{ page.props.auth.user.name }}
<form class="w-full" @submit.prevent="logout">
<DropdownMenuItem as-child class="inline-flex items-center gap-2.5 w-full">
<button type="submit" data-testid="logout_button">
<ArrowLeftOnRectangleIcon class="w-5 h-5 text-icon-default" />
<span>Log Out</span>
</button>
</DropdownMenuItem>
<svg
class="ms-2 -me-0.5 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
</span>
</template>
<template #content>
<!-- Account Management -->
<div class="block px-4 py-2 text-xs text-gray-400">Manage Account</div>
<DropdownLink :href="route('profile.show')"> Profile </DropdownLink>
<DropdownLink
v-if="page.props.jetstream.hasApiFeatures"
:href="route('api-tokens.index')">
API Tokens
</DropdownLink>
<div class="border-t border-card-border" />
<!-- Authentication -->
<form @submit.prevent="logout">
<DropdownLink as="button" data-testid="logout_button"> Log Out </DropdownLink>
</form>
</DropdownMenuContent>
</DropdownMenu>
</template>
</Dropdown>
</div>
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { cn } from '@/lib/utils';
import { AccordionContent, type AccordionContentProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { cn } from '@/lib/utils';
import { AccordionItem, type AccordionItemProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { cn } from '@/lib/utils';
import { ChevronDown } from 'lucide-vue-next';
import { AccordionHeader, AccordionTrigger, type AccordionTriggerProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { buttonVariants } from '@/packages/ui/src';
import { buttonVariants } from '@/Components/ui/button';
import { AlertDialogAction, type AlertDialogActionProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import { twMerge } from 'tailwind-merge';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { buttonVariants } from '@/packages/ui/src';
import { buttonVariants } from '@/Components/ui/button';
import { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import { twMerge } from 'tailwind-merge';

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { cn, buttonVariants } from '@/packages/ui/src';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/Components/ui/button';
import { CalendarCellTrigger, type CalendarCellTriggerProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src';
import { Button } from '@/packages/ui/src';
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { Button } from '@/Components/ui/button';
import { Calendar } from '@/Components/ui/calendar';
import { CalendarIcon, XIcon } from 'lucide-vue-next';
import { formatDate } from '@/packages/ui/src/utils/time';

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { cn, buttonVariants } from '@/packages/ui/src/index';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/Components/ui/button';
import { ChevronRight } from 'lucide-vue-next';
import { CalendarNext, type CalendarNextProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { cn, buttonVariants } from '@/packages/ui/src';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/Components/ui/button';
import { ChevronLeft } from 'lucide-vue-next';
import { CalendarPrev, type CalendarPrevProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -19,7 +19,7 @@ const forwardedProps = useForwardProps(delegatedProps);
<template>
<DropdownMenuLabel
v-bind="forwardedProps"
:class="cn('block px-2 py-2 text-xs text-gray-400', inset && 'pl-8', props.class)">
:class="cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', props.class)">
<slot />
</DropdownMenuLabel>
</template>

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { cn, buttonVariants } from '@/packages/ui/src';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/Components/ui/button';
import {
RangeCalendarCellTrigger,
type RangeCalendarCellTriggerProps,

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { cn, buttonVariants } from '@/packages/ui/src';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/Components/ui/button';
import { ChevronRight } from 'lucide-vue-next';
import { RangeCalendarNext, type RangeCalendarNextProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { cn, buttonVariants } from '@/packages/ui/src';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/Components/ui/button';
import { ChevronLeft } from 'lucide-vue-next';
import { RangeCalendarPrev, type RangeCalendarPrevProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -15,7 +15,9 @@ const delegatedProps = computed(() => {
<template>
<TabsList
v-bind="delegatedProps"
:class="cn('inline-flex items-center rounded-lg text-muted-foreground', props.class)">
:class="
cn('inline-flex items-center rounded-lg bg-muted text-muted-foreground', props.class)
">
<slot />
</TabsList>
</template>

View File

@@ -5,7 +5,6 @@ import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
import {
Bars3Icon,
CalendarIcon,
ChartBarIcon,
ClockIcon,
Cog6ToothIcon,
@@ -40,19 +39,14 @@ import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
import { fetchToken, isTokenValid } from '@/utils/session';
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
import UserTimezoneMismatchModal from '@/Components/Common/User/UserTimezoneMismatchModal.vue';
import { useTheme } from '@/utils/theme';
import { useQuery } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
import { twMerge } from 'tailwind-merge';
import { Button } from '@/packages/ui/src';
import { openFeedback } from '@/utils/feedback';
defineProps({
title: String,
mainClass: String,
});
const showSidebarMenu = ref(false);
@@ -96,8 +90,8 @@ onMounted(async () => {
}, 100);
};
});
const page = usePage<{
has_services_extension?: boolean;
auth: {
user: User;
};
@@ -108,7 +102,7 @@ const page = usePage<{
<div v-bind="$attrs" class="flex flex-wrap bg-background text-text-secondary">
<div
:class="{
'!flex bg-default-background w-full z-30': showSidebarMenu,
'!flex bg-default-background w-full z-[9999999999]': showSidebarMenu,
}"
class="flex-shrink-0 h-screen hidden fixed w-[230px] 2xl:w-[250px] px-2.5 2xl:px-3 py-4 lg:flex flex-col justify-between">
<div class="flex flex-col h-full">
@@ -137,11 +131,6 @@ const page = usePage<{
:icon="ClockIcon"
:current="route().current('time')"
:href="route('time')"></NavigationSidebarItem>
<NavigationSidebarItem
title="Calendar"
:icon="CalendarIcon"
:current="route().current('calendar')"
:href="route('calendar')"></NavigationSidebarItem>
<NavigationSidebarItem
title="Reporting"
:icon="ChartBarIcon"
@@ -244,44 +233,35 @@ const page = usePage<{
<div class="justify-self-end">
<UpdateSidebarNotification></UpdateSidebarNotification>
<ul
class="border-t border-default-background-separator pt-3 gap-1 pr-2 flex justify-between items-center">
<UserSettingsIcon></UserSettingsIcon>
class="border-t border-default-background-separator pt-3 flex justify-between pr-4 items-center">
<NavigationSidebarItem
class="flex-1"
title="Profile Settings"
:icon="Cog6ToothIcon"
:href="route('profile.show')"></NavigationSidebarItem>
<Button
v-if="page.props.has_services_extension"
variant="outline"
size="xs"
class="rounded-full ml-2 flex h-6 w-6 items-center text-xs text-icon-default justify-center"
@click="openFeedback">
?
</Button>
<UserSettingsIcon></UserSettingsIcon>
</ul>
</div>
</div>
</div>
<div class="flex-1 lg:ml-[230px] 2xl:ml-[250px] min-w-0">
<div
class="h-screen overflow-y-auto flex flex-col bg-default-background border-l border-default-background-separator">
<div
class="lg:hidden w-full px-3 py-1 border-b border-b-default-background-separator text-text-secondary flex justify-between items-center">
<Bars3Icon
class="w-7 text-text-secondary"
@click="showSidebarMenu = !showSidebarMenu"></Bars3Icon>
<OrganizationSwitcher></OrganizationSwitcher>
</div>
class="lg:hidden w-full px-3 py-1 border-b border-b-default-background-separator text-text-secondary flex justify-between items-center">
<Bars3Icon
class="w-7 text-text-secondary"
@click="showSidebarMenu = !showSidebarMenu"></Bars3Icon>
<OrganizationSwitcher></OrganizationSwitcher>
</div>
<Head :title="title" />
<Head :title="title" />
<Banner />
<BillingBanner v-if="isBillingActivated()" />
<div
class="min-h-screen flex flex-col bg-default-background border-l border-default-background-separator">
<!-- Page Heading -->
<Banner />
<BillingBanner v-if="isBillingActivated()" />
<header
v-if="$slots.header"
class="bg-default-background border-b border-default-background-separator shadow">
@@ -293,7 +273,7 @@ const page = usePage<{
</header>
<!-- Page Content -->
<main :class="twMerge('pb-28 relative flex-1', mainClass)">
<main class="pb-28 flex-1">
<div
v-if="isOrganizationLoading"
class="flex items-center justify-center h-screen">
@@ -305,5 +285,4 @@ const page = usePage<{
</div>
</div>
<NotificationContainer></NotificationContainer>
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
</template>

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