change color palette, change user_id to member_id

This commit is contained in:
Gregor Vostrak
2024-05-21 01:53:50 +02:00
parent 68ecc1227d
commit 8b12dec546
51 changed files with 836 additions and 388 deletions

View File

@@ -224,7 +224,7 @@ class TimeEntryController extends Controller
/** @var string $group2Type */ /** @var string $group2Type */
$group2Response[] = [ $group2Response[] = [
'type' => $group2Type, 'type' => $group2Type,
'key' => $group2 === '' ? null : $group2, 'key' => $group2 === '' ? null : (string) $group2,
'seconds' => (int) $aggregate->get(0)->aggregate, 'seconds' => (int) $aggregate->get(0)->aggregate,
'cost' => (int) $aggregate->get(0)->cost, 'cost' => (int) $aggregate->get(0)->cost,
]; ];
@@ -241,7 +241,7 @@ class TimeEntryController extends Controller
/** @var string $group1Type */ /** @var string $group1Type */
$group1Response[] = [ $group1Response[] = [
'type' => $group1Type, 'type' => $group1Type,
'key' => $group1 === '' ? null : $group1, 'key' => $group1 === '' ? null : (string) $group1,
'seconds' => $group2ResponseSum, 'seconds' => $group2ResponseSum,
'cost' => $group2ResponseCost, 'cost' => $group2ResponseCost,
'grouped_data' => $group2Response, 'grouped_data' => $group2Response,

View File

@@ -85,6 +85,7 @@ class ShareInertiaData
'currency' => $organization->currency, 'currency' => $organization->currency,
'membership' => [ 'membership' => [
'role' => $organization->membership->role, 'role' => $organization->membership->role,
'id' => $organization->membership->id,
], ],
]; ];
})->all(), })->all(),

View File

@@ -117,9 +117,9 @@ return [
'super_admins' => ! is_string(env('SUPER_ADMINS', null)) ? [] : explode(',', env('SUPER_ADMINS')), 'super_admins' => ! is_string(env('SUPER_ADMINS', null)) ? [] : explode(',', env('SUPER_ADMINS')),
'terms_url' => env('TERMS_URL'), 'terms_url' => env('TERMS_URL', ''),
'privacy_policy_url' => env('PRIVACY_POLICY_URL'), 'privacy_policy_url' => env('PRIVACY_POLICY_URL', ''),
'newsletter_consent' => env('NEWSLETTER_CONSENT', false), 'newsletter_consent' => env('NEWSLETTER_CONSENT', false),

View File

@@ -14,7 +14,7 @@ test('test that creating and deleting a new project via the modal works', async
'New Project ' + Math.floor(1 + Math.random() * 10000); 'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page); await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByPlaceholder('Project Name').fill(newProjectName); await page.getByLabel('Project Name').fill(newProjectName);
await Promise.all([ await Promise.all([
page.getByRole('button', { name: 'Create Project' }).nth(1).click(), page.getByRole('button', { name: 'Create Project' }).nth(1).click(),
page.waitForResponse( page.waitForResponse(

View File

@@ -14,7 +14,7 @@ test('test that creating and deleting a new tag in a new project works', async (
'New Project ' + Math.floor(1 + Math.random() * 10000); 'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page); await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByPlaceholder('Project Name').fill(newProjectName); await page.getByLabel('Project Name').fill(newProjectName);
await Promise.all([ await Promise.all([
page.getByRole('button', { name: 'Create Project' }).nth(1).click(), page.getByRole('button', { name: 'Create Project' }).nth(1).click(),
page.waitForResponse( page.waitForResponse(

View File

@@ -57,7 +57,7 @@ test('test that starting and stopping an empty time entry shows a new time entry
async function assertThatTimeEntryRowIsStopped(newTimeEntry: Locator) { async function assertThatTimeEntryRowIsStopped(newTimeEntry: Locator) {
await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass( await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass(
/bg-accent-300\/50/ /bg-accent-300\/70/
); );
} }
@@ -297,7 +297,7 @@ test('test that stopping a time entry from the overview works', async ({
]); ]);
await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass( await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass(
/bg-accent-300\/50/ /bg-accent-300\/70/
); );
}); });
@@ -311,7 +311,7 @@ test('test that starting a time entry from the overview works', async ({
const newTimeEntry = timeEntryRows.first(); const newTimeEntry = timeEntryRows.first();
const startButton = newTimeEntry.getByTestId('timer_button'); const startButton = newTimeEntry.getByTestId('timer_button');
await expect(startButton).toHaveClass(/bg-accent-300\/50/); await expect(startButton).toHaveClass(/bg-accent-300\/70/);
await Promise.all([ await Promise.all([
page.waitForResponse(async (response) => { page.waitForResponse(async (response) => {
@@ -341,7 +341,7 @@ test('test that starting a time entry from the overview works', async ({
); );
}), }),
startOrStopTimerWithButton(page), startOrStopTimerWithButton(page),
expect(startButton).toHaveClass(/bg-accent-300\/50/), expect(startButton).toHaveClass(/bg-accent-300\/70/),
]); ]);
}); });
@@ -401,7 +401,7 @@ test('test that updating a the duration in the overview for a running timer work
); );
}), }),
startOrStopTimerWithButton(page), startOrStopTimerWithButton(page),
expect(startButton).toHaveClass(/bg-accent-300\/50/), expect(startButton).toHaveClass(/bg-accent-300\/70/),
]); ]);
}); });

View File

@@ -40,7 +40,7 @@ export async function assertThatTimerIsStopped(page: Page) {
page.locator( page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"]' '[data-testid="dashboard_timer"] [data-testid="timer_button"]'
) )
).toHaveClass(/bg-accent-300\/50/); ).toHaveClass(/bg-accent-300\/70/);
} }
export async function stoppedTimeEntryResponse( export async function stoppedTimeEntryResponse(

View File

@@ -83,13 +83,13 @@ const ProjectMemberResource = z
.object({ .object({
id: z.string(), id: z.string(),
billable_rate: z.union([z.number(), z.null()]), billable_rate: z.union([z.number(), z.null()]),
user_id: z.string(), member_id: z.string(),
project_id: z.string(), project_id: z.string(),
}) })
.passthrough(); .passthrough();
const createProjectMember_Body = z const createProjectMember_Body = z
.object({ .object({
user_id: z.string().uuid(), member_id: z.string().uuid(),
billable_rate: z.union([z.number(), z.null()]).optional(), billable_rate: z.union([z.number(), z.null()]).optional(),
}) })
.passthrough(); .passthrough();
@@ -137,7 +137,7 @@ const TimeEntryResource = z
const TimeEntryCollection = z.array(TimeEntryResource); const TimeEntryCollection = z.array(TimeEntryResource);
const createTimeEntry_Body = z const createTimeEntry_Body = z
.object({ .object({
user_id: z.string().uuid(), member_id: z.string().uuid(),
project_id: z.union([z.string(), z.null()]).optional(), project_id: z.union([z.string(), z.null()]).optional(),
task_id: z.union([z.string(), z.null()]).optional(), task_id: z.union([z.string(), z.null()]).optional(),
start: z.string(), start: z.string(),
@@ -147,8 +147,41 @@ const createTimeEntry_Body = z
tags: z.union([z.array(z.string()), z.null()]).optional(), tags: z.union([z.array(z.string()), z.null()]).optional(),
}) })
.passthrough(); .passthrough();
const v1_time_entries_update_multiple_Body = z
.object({
ids: z.array(z.string()),
changes: z
.object({
member_id: z.string().uuid(),
project_id: z.union([z.string(), z.null()]),
task_id: z.union([z.string(), z.null()]),
billable: z.boolean(),
description: z.union([z.string(), z.null()]),
tags: z.union([z.array(z.string()), z.null()]),
})
.partial()
.passthrough(),
})
.passthrough();
const group = z
.union([
z.enum([
'day',
'week',
'month',
'year',
'user',
'project',
'task',
'client',
'billable',
]),
z.null(),
])
.optional();
const updateTimeEntry_Body = z const updateTimeEntry_Body = z
.object({ .object({
member_id: z.string().uuid().optional(),
project_id: z.union([z.string(), z.null()]).optional(), project_id: z.union([z.string(), z.null()]).optional(),
task_id: z.union([z.string(), z.null()]).optional(), task_id: z.union([z.string(), z.null()]).optional(),
start: z.string(), start: z.string(),
@@ -184,6 +217,8 @@ export const schemas = {
TimeEntryResource, TimeEntryResource,
TimeEntryCollection, TimeEntryCollection,
createTimeEntry_Body, createTimeEntry_Body,
v1_time_entries_update_multiple_Body,
group,
updateTimeEntry_Body, updateTimeEntry_Body,
}; };
@@ -197,7 +232,7 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.object({ data: OrganizationResource }).passthrough(), response: z.object({ data: OrganizationResource }).passthrough(),
@@ -228,7 +263,7 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.object({ data: OrganizationResource }).passthrough(), response: z.object({ data: OrganizationResource }).passthrough(),
@@ -264,7 +299,7 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.object({ data: ClientCollection }).passthrough(), response: z.object({ data: ClientCollection }).passthrough(),
@@ -295,7 +330,7 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.object({ data: ClientResource }).passthrough(), response: z.object({ data: ClientResource }).passthrough(),
@@ -336,12 +371,12 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'client', name: 'client',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.object({ data: ClientResource }).passthrough(), response: z.object({ data: ClientResource }).passthrough(),
@@ -382,12 +417,12 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'client', name: 'client',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.null(), response: z.null(),
@@ -429,7 +464,7 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z response: z
@@ -497,7 +532,7 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z response: z
@@ -535,7 +570,7 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z response: z
@@ -608,7 +643,7 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.null(), response: z.null(),
@@ -649,12 +684,12 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'invitation', name: 'invitation',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.null(), response: z.null(),
@@ -685,12 +720,12 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'invitation', name: 'invitation',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.null(), response: z.null(),
@@ -716,7 +751,7 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z response: z
@@ -777,7 +812,7 @@ const endpoints = makeApi([
}, },
{ {
method: 'put', method: 'put',
path: '/v1/organizations/:organization/members/:membership', path: '/v1/organizations/:organization/members/:member',
alias: 'updateMember', alias: 'updateMember',
requestFormat: 'json', requestFormat: 'json',
parameters: [ parameters: [
@@ -789,12 +824,12 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'membership', name: 'member',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.object({ data: MemberResource }).passthrough(), response: z.object({ data: MemberResource }).passthrough(),
@@ -823,7 +858,7 @@ const endpoints = makeApi([
}, },
{ {
method: 'delete', method: 'delete',
path: '/v1/organizations/:organization/members/:membership', path: '/v1/organizations/:organization/members/:member',
alias: 'removeMember', alias: 'removeMember',
requestFormat: 'json', requestFormat: 'json',
parameters: [ parameters: [
@@ -835,12 +870,12 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'membership', name: 'member',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.null(), response: z.null(),
@@ -870,7 +905,7 @@ const endpoints = makeApi([
}, },
{ {
method: 'post', method: 'post',
path: '/v1/organizations/:organization/members/:membership/invite-placeholder', path: '/v1/organizations/:organization/members/:member/invite-placeholder',
alias: 'invitePlaceholder', alias: 'invitePlaceholder',
requestFormat: 'json', requestFormat: 'json',
parameters: [ parameters: [
@@ -882,12 +917,12 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'membership', name: 'member',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.null(), response: z.null(),
@@ -929,12 +964,12 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'projectMember', name: 'projectMember',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.object({ data: ProjectMemberResource }).passthrough(), response: z.object({ data: ProjectMemberResource }).passthrough(),
@@ -975,12 +1010,12 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'projectMember', name: 'projectMember',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.null(), response: z.null(),
@@ -1006,7 +1041,12 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
},
{
name: 'page',
type: 'Query',
schema: z.number().int().gte(1).optional(),
}, },
], ],
response: z response: z
@@ -1053,6 +1093,16 @@ const endpoints = makeApi([
description: `Not found`, description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(), schema: z.object({ message: z.string() }).passthrough(),
}, },
{
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
], ],
}, },
{ {
@@ -1069,7 +1119,7 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.object({ data: ProjectResource }).passthrough(), response: z.object({ data: ProjectResource }).passthrough(),
@@ -1105,12 +1155,12 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'project', name: 'project',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.object({ data: ProjectResource }).passthrough(), response: z.object({ data: ProjectResource }).passthrough(),
@@ -1141,12 +1191,12 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'project', name: 'project',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.object({ data: ProjectResource }).passthrough(), response: z.object({ data: ProjectResource }).passthrough(),
@@ -1187,12 +1237,12 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'project', name: 'project',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.null(), response: z.null(),
@@ -1229,12 +1279,12 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'project', name: 'project',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z response: z
@@ -1297,12 +1347,12 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'project', name: 'project',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.object({ data: ProjectMemberResource }).passthrough(), response: z.object({ data: ProjectMemberResource }).passthrough(),
@@ -1349,7 +1399,7 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.object({ data: TagCollection }).passthrough(), response: z.object({ data: TagCollection }).passthrough(),
@@ -1380,7 +1430,7 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.object({ data: TagResource }).passthrough(), response: z.object({ data: TagResource }).passthrough(),
@@ -1421,12 +1471,12 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'tag', name: 'tag',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.object({ data: TagResource }).passthrough(), response: z.object({ data: TagResource }).passthrough(),
@@ -1467,12 +1517,12 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'tag', name: 'tag',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.null(), response: z.null(),
@@ -1509,7 +1559,7 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'project_id', name: 'project_id',
@@ -1587,7 +1637,7 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.object({ data: TaskResource }).passthrough(), response: z.object({ data: TaskResource }).passthrough(),
@@ -1628,12 +1678,12 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'task', name: 'task',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.object({ data: TaskResource }).passthrough(), response: z.object({ data: TaskResource }).passthrough(),
@@ -1674,12 +1724,12 @@ const endpoints = makeApi([
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'task', name: 'task',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.null(), response: z.null(),
@@ -1718,10 +1768,10 @@ Users with the permission `time-entries:view:own` can only use this en
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'user_id', name: 'member_id',
type: 'Query', type: 'Query',
schema: z.string().uuid().optional(), schema: z.string().uuid().optional(),
}, },
@@ -1740,6 +1790,11 @@ Users with the permission `time-entries:view:own` can only use this en
type: 'Query', type: 'Query',
schema: z.enum(['true', 'false']).optional(), schema: z.enum(['true', 'false']).optional(),
}, },
{
name: 'billable',
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{ {
name: 'limit', name: 'limit',
type: 'Query', type: 'Query',
@@ -1750,6 +1805,26 @@ Users with the permission `time-entries:view:own` can only use this en
type: 'Query', type: 'Query',
schema: z.enum(['true', 'false']).optional(), schema: z.enum(['true', 'false']).optional(),
}, },
{
name: 'member_ids',
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'project_ids',
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_ids',
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'task_ids',
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
], ],
response: z.object({ data: TimeEntryCollection }).passthrough(), response: z.object({ data: TimeEntryCollection }).passthrough(),
errors: [ errors: [
@@ -1789,7 +1864,7 @@ Users with the permission `time-entries:view:own` can only use this en
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.object({ data: TimeEntryResource }).passthrough(), response: z.object({ data: TimeEntryResource }).passthrough(),
@@ -1827,6 +1902,49 @@ Users with the permission `time-entries:view:own` can only use this en
}, },
], ],
}, },
{
method: 'patch',
path: '/v1/organizations/:organization/time-entries',
alias: 'v1.time-entries.update-multiple',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: v1_time_entries_update_multiple_Body,
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
],
response: z
.object({ success: z.string(), error: z.string() })
.passthrough(),
errors: [
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
},
{ {
method: 'put', method: 'put',
path: '/v1/organizations/:organization/time-entries/:timeEntry', path: '/v1/organizations/:organization/time-entries/:timeEntry',
@@ -1841,12 +1959,12 @@ Users with the permission `time-entries:view:own` can only use this en
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'timeEntry', name: 'timeEntry',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.object({ data: TimeEntryResource }).passthrough(), response: z.object({ data: TimeEntryResource }).passthrough(),
@@ -1898,12 +2016,12 @@ Users with the permission `time-entries:view:own` can only use this en
{ {
name: 'organization', name: 'organization',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
{ {
name: 'timeEntry', name: 'timeEntry',
type: 'Path', type: 'Path',
schema: z.string().uuid(), schema: z.string(),
}, },
], ],
response: z.null(), response: z.null(),
@@ -1920,6 +2038,145 @@ Users with the permission `time-entries:view:own` can only use this en
}, },
], ],
}, },
{
method: 'get',
path: '/v1/organizations/:organization/time-entries/aggregate',
alias: 'getAggregatedTimeEntries',
description: `This endpoint allows you to filter time entries and aggregate them by different criteria.
The parameters `group` and `sub_group` allow you to group the time entries by different criteria.
If the group parameters are all set to `null` or are all missing, the endpoint will aggregate all filtered time entries.`,
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'group',
type: 'Query',
schema: group,
},
{
name: 'sub_group',
type: 'Query',
schema: group,
},
{
name: 'member_id',
type: 'Query',
schema: z.string().uuid().optional(),
},
{
name: 'user_id',
type: 'Query',
schema: z.string().uuid().optional(),
},
{
name: 'before',
type: 'Query',
schema: before,
},
{
name: 'after',
type: 'Query',
schema: before,
},
{
name: 'active',
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'billable',
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'member_ids',
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'project_ids',
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_ids',
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'task_ids',
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
],
response: z
.object({
data: z
.object({
grouped_data: z.union([
z.array(
z
.object({
type: z.string(),
key: z.union([z.string(), z.null()]),
seconds: z.number().int(),
cost: z.number().int(),
grouped_data: z.union([
z.array(
z
.object({
type: z.string(),
key: z.union([
z.string(),
z.null(),
]),
seconds: z
.number()
.int(),
cost: z.number().int(),
})
.passthrough()
),
z.null(),
]),
})
.passthrough()
),
z.null(),
]),
seconds: z.number().int(),
cost: z.number().int(),
})
.passthrough(),
})
.passthrough(),
errors: [
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
},
{ {
method: 'get', method: 'get',
path: '/v1/users/me/time-entries/active', path: '/v1/users/me/time-entries/active',

10
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{ {
"name": "html", "name": "solidtime",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
@@ -19,7 +19,7 @@
"pinia": "^2.1.7", "pinia": "^2.1.7",
"radix-vue": "^1.5.2", "radix-vue": "^1.5.2",
"tailwind-merge": "^2.2.1", "tailwind-merge": "^2.2.1",
"vue-echarts": "^6.6.9" "vue-echarts": "^6.7.2"
}, },
"devDependencies": { "devDependencies": {
"@inertiajs/vue3": "^1.0.0", "@inertiajs/vue3": "^1.0.0",
@@ -5735,9 +5735,9 @@
} }
}, },
"node_modules/vue-echarts": { "node_modules/vue-echarts": {
"version": "6.6.9", "version": "6.7.2",
"resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-6.6.9.tgz", "resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-6.7.2.tgz",
"integrity": "sha512-mojIq3ZvsjabeVmDthhAUDV8Kgf2Rr/X4lV4da7gEFd1fP05gcSJ0j7wa7HQkW5LlFmF2gdCJ8p4Chas6NNIQQ==", "integrity": "sha512-SG8Vmszhx24KjtySsk361DogZLRkPCyLhgoyh7iN1eH3WGJ0kyl3k0g4QiSJqK0+F1Ej0HDopq4A5OGcBlAwzw==",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"resize-detector": "^0.3.0", "resize-detector": "^0.3.0",

View File

@@ -47,6 +47,6 @@
"pinia": "^2.1.7", "pinia": "^2.1.7",
"radix-vue": "^1.5.2", "radix-vue": "^1.5.2",
"tailwind-merge": "^2.2.1", "tailwind-merge": "^2.2.1",
"vue-echarts": "^6.6.9" "vue-echarts": "^6.7.2"
} }
} }

View File

@@ -2,16 +2,40 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
:root {
--color-bg-primary: #0f1011;
--color-bg-secondary: #1b1c20;
--color-bg-tertiary: #2A2C32;
--color-bg-quaternary: #141518;
--color-text-primary: #ffffff;
--color-text-secondary: #e3e4e6;
--color-text-tertiary: #969799;
--color-text-quaternary: #595a5c;
--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);
:root{ --color-accent-primary: 14, 165, 233; /* sky-500 */
--theme-color-default-background: #0b0d1c; --color-accent-secondary: 56, 189, 248;
--theme-color-icon-default: #42466C; --color-accent-tertiary: 125, 211, 252;
--theme-color-card-background: #13152B; --color-accent-quaternary: 186, 230, 253;
--theme-color-card-background-active: #1C1E34;
--theme-color-card-background-separator: #1c2033; --theme-color-default-background: var(--color-bg-primary);
--theme-color-card-border: #1c2033; --theme-color-icon-default: var(--color-text-tertiary);
--theme-color-card-border-active: #2A3461; --theme-color-icon-active: rgb(var(--color-text-tertiary));
--theme-color-default-background-separator: #141a2f; --theme-color-card-background: var(--color-bg-secondary);
--theme-color-card-background-active: var(--color-bg-tertiary);
--theme-color-card-background-separator: var(--color-border-quaternary);
--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-muted-text: var(--color-text-secondary);
--theme-color-menu-active: var(--color-bg-secondary);
--theme-color-input-border: var(--color-border-quaternary);
--theme-color-input-background: var(--color-bg-secondary);
--theme-color-tab-background: var(--theme-color-card-background); --theme-color-tab-background: var(--theme-color-card-background);
--theme-color-tab-background-active: var(--theme-color-card-background-active); --theme-color-tab-background-active: var(--theme-color-card-background-active);
--theme-color-tab-border: var(--theme-color-card-border); --theme-color-tab-border: var(--theme-color-card-border);
@@ -21,17 +45,15 @@
--theme-color-row-heading-border: var(--theme-color-card-border); --theme-color-row-heading-border: var(--theme-color-card-border);
} }
*{ * {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
[x-cloak] { [x-cloak] {
display: none; display: none;
} }
body {
body{
background-color: var(--theme-color-default-background); background-color: var(--theme-color-default-background);
} }

View File

@@ -37,10 +37,10 @@ const borderClasses = computed(() => {
:is="tag" :is="tag"
:class=" :class="
twMerge( twMerge(
props.class,
badgeClasses[size], badgeClasses[size],
borderClasses, borderClasses,
'rounded inline-flex items-center font-semibold text-white' 'rounded inline-flex items-center font-semibold text-white',
props.class
) )
"> ">
<slot></slot> <slot></slot>

View File

@@ -6,6 +6,10 @@ import {
getOrganizationCurrencySymbol, getOrganizationCurrencySymbol,
} from '../../utils/money'; } from '../../utils/money';
defineProps<{
name: string;
}>();
const model = defineModel({ const model = defineModel({
default: null, default: null,
type: Number, type: Number,
@@ -51,13 +55,14 @@ function formatCents(modelValue: number) {
<template> <template>
<div class="relative"> <div class="relative">
<TextInput <TextInput
id="projectMemberRate" :id="name"
ref="projectMemberRateInput" ref="projectMemberRateInput"
:modelValue="formatCents(model)" :modelValue="formatCents(model)"
@blur="updateRate($event.target.value)" @blur="updateRate($event.target.value)"
type="text" type="text"
:name="name"
placeholder="Billable Rate" placeholder="Billable Rate"
class="mt-1 block w-full" class="mt-2 block w-full"
autocomplete="teamMemberRate" /> autocomplete="teamMemberRate" />
<span> <span>
<div <div

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import BillableIcon from '@/Components/Common/Icons/BillableIcon.vue';
const active = defineModel({ default: false }); const active = defineModel({ default: false });
const emit = defineEmits(['changed']); const emit = defineEmits(['changed']);
function toggleBillable() { function toggleBillable() {
@@ -19,7 +20,7 @@ const props = withDefaults(
const iconColorClasses = computed(() => { const iconColorClasses = computed(() => {
if (active.value) { if (active.value) {
return 'text-accent-200/80 focus:text-accent-200 hover:text-accent-200'; return 'text-accent-300 focus:text-accent-200 hover:text-accent-200';
} else { } else {
return 'text-icon-default focus:text-icon-active hover:text-icon-active'; return 'text-icon-default focus:text-icon-active hover:text-icon-active';
} }
@@ -49,18 +50,7 @@ const iconSizeWrapperClasses =
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-separator hover:bg-card-background-separator rounded-full flex items-center justify-center' 'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-separator hover:bg-card-background-separator rounded-full flex items-center justify-center'
) )
"> ">
<svg <BillableIcon :class="iconSizeClasses"></BillableIcon>
:class="iconSizeClasses"
viewBox="0 0 8 14"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M4 1V13M1 10.182L1.879 10.841C3.05 11.72 4.949 11.72 6.121 10.841C7.293 9.962 7.293 8.538 6.121 7.659C5.536 7.219 4.768 7 4 7C3.275 7 2.55 6.78 1.997 6.341C0.891 5.462 0.891 4.038 1.997 3.159C3.103 2.28 4.897 2.28 6.003 3.159L6.418 3.489"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</button> </button>
</template> </template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
import { computed } from 'vue';
const props = withDefaults(
defineProps<{
expanded?: boolean;
size: string;
}>(),
{
expanded: false,
size: 'w-7 h-7',
}
);
const expandedStatusClasses = computed(() => {
if (props.expanded) {
return 'border-card-border border bg-card-background-active text-white';
}
return 'border-card-border border bg-card-background text-muted';
});
</script>
<template>
<button
:class="
twMerge(
'font-medium rounded flex items-center transition justify-center',
expandedStatusClasses,
props.size
)
">
<slot></slot>
</button>
</template>
<style scoped></style>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts"></script>
<template>
<svg viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4 1V13M1 10.182L1.879 10.841C3.05 11.72 4.949 11.72 6.121 10.841C7.293 9.962 7.293 8.538 6.121 7.659C5.536 7.219 4.768 7 4 7C3.275 7 2.55 6.78 1.997 6.341C0.891 5.462 0.891 4.038 1.997 3.159C3.103 2.28 4.897 2.28 6.003 3.159L6.418 3.489"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</template>
<style scoped></style>

View File

@@ -37,7 +37,7 @@ const filteredMembers = computed(() => {
.toLowerCase() .toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '') && .includes(searchValue.value?.toLowerCase()?.trim() || '') &&
!props.hiddenMembers.some( !props.hiddenMembers.some(
(hiddenMember) => hiddenMember.user_id === member.user_id (hiddenMember) => hiddenMember.id === member.id
) && ) &&
member.is_placeholder === false member.is_placeholder === false
); );
@@ -54,7 +54,7 @@ onMounted(() => {
function resetHighlightedItem() { function resetHighlightedItem() {
if (filteredMembers.value.length > 0) { if (filteredMembers.value.length > 0) {
highlightedItemId.value = filteredMembers.value[0].user_id; highlightedItemId.value = filteredMembers.value[0].id;
} }
} }
@@ -65,10 +65,10 @@ function updateSearchValue(event: Event) {
const highlightedClientId = highlightedItemId.value; const highlightedClientId = highlightedItemId.value;
if (highlightedClientId) { if (highlightedClientId) {
const highlightedClient = members.value.find( const highlightedClient = members.value.find(
(member) => member.user_id === highlightedClientId (member) => member.id === highlightedClientId
); );
if (highlightedClient) { if (highlightedClient) {
model.value = highlightedClient.user_id; model.value = highlightedClient.id;
} }
} }
} else { } else {
@@ -94,10 +94,10 @@ function moveHighlightUp() {
); );
if (currentHightlightedIndex === 0) { if (currentHightlightedIndex === 0) {
highlightedItemId.value = highlightedItemId.value =
filteredMembers.value[filteredMembers.value.length - 1].user_id; filteredMembers.value[filteredMembers.value.length - 1].id;
} else { } else {
highlightedItemId.value = highlightedItemId.value =
filteredMembers.value[currentHightlightedIndex - 1].user_id; filteredMembers.value[currentHightlightedIndex - 1].id;
} }
} }
} }
@@ -108,10 +108,10 @@ function moveHighlightDown() {
highlightedItem.value highlightedItem.value
); );
if (currentHightlightedIndex === filteredMembers.value.length - 1) { if (currentHightlightedIndex === filteredMembers.value.length - 1) {
highlightedItemId.value = filteredMembers.value[0].user_id; highlightedItemId.value = filteredMembers.value[0].id;
} else { } else {
highlightedItemId.value = highlightedItemId.value =
filteredMembers.value[currentHightlightedIndex + 1].user_id; filteredMembers.value[currentHightlightedIndex + 1].id;
} }
} }
} }
@@ -119,14 +119,13 @@ function moveHighlightDown() {
const highlightedItemId = ref<string | null>(null); const highlightedItemId = ref<string | null>(null);
const highlightedItem = computed(() => { const highlightedItem = computed(() => {
return members.value.find( return members.value.find(
(member) => member.user_id === highlightedItemId.value (member) => member.id === highlightedItemId.value
); );
}); });
const currentValue = computed(() => { const currentValue = computed(() => {
if (model.value) { if (model.value) {
return members.value.find((member) => member.user_id === model.value) return members.value.find((member) => member.id === model.value)?.name;
?.name;
} }
return searchValue.value; return searchValue.value;
}); });
@@ -186,18 +185,18 @@ function onUnfocus() {
</div> </div>
<div <div
v-for="member in filteredMembers" v-for="member in filteredMembers"
:key="member.user_id" :key="member.id"
role="option" role="option"
:value="member.user_id" :value="member.id"
:class="{ :class="{
'bg-card-background-active': 'bg-card-background-active':
member.user_id === highlightedItemId, member.id === highlightedItemId,
}" }"
@click="updateMember(member.user_id)" @click="updateMember(member.id)"
data-testid="client_dropdown_entries" data-testid="client_dropdown_entries"
:data-client-id="member.user_id"> :data-client-id="member.id">
<ClientDropdownItem <ClientDropdownItem
:selected="isMemberSelected(member.user_id)" :selected="isMemberSelected(member.id)"
:name="member.name"></ClientDropdownItem> :name="member.name"></ClientDropdownItem>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import MultiselectDropdown from '@/Components/Common/MultiselectDropdown.vue';
import { useMembersStore } from '@/utils/useMembers';
import { storeToRefs } from 'pinia';
import type { Member } from '@/utils/api';
const membersStore = useMembersStore();
const { members } = storeToRefs(membersStore);
function getKeyFromItem(item: Member) {
return item.id;
}
function getNameForItem(item: Member) {
return item.name;
}
</script>
<template>
<MultiselectDropdown
searchPlaceholder="Search for a Member..."
:items="members"
:get-key-from-item="getKeyFromItem"
:get-name-for-item="getNameForItem">
<template #trigger>
<slot name="trigger"></slot>
</template>
</MultiselectDropdown>
</template>

View File

@@ -29,7 +29,7 @@ async function invitePlaceholder(id: string) {
{ {
params: { params: {
organization: organizationId, organization: organizationId,
membership: id, member: id,
}, },
} }
), ),

View File

@@ -6,15 +6,17 @@ const model = defineModel<string>({ default: '' });
</script> </script>
<template> <template>
<div class="px-3"> <div>
<Dropdown align="bottom"> <Dropdown align="bottom">
<template #trigger> <template #trigger>
<div <button
:style="{ class="p-2 bg-input-background hover:bg-tertiary transition rounded-full border border-input-border">
backgroundColor: model, <div
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(5px + var(--tw-ring-offset-width)) ${model}30`, :style="{
}" backgroundColor: model,
class="w-4 h-4 rounded-full cursor-pointer"></div> }"
class="w-5 h-5 rounded-full cursor-pointer"></div>
</button>
</template> </template>
<template #content> <template #content>
<div class="text-white grid grid-cols-6 gap-3 px-3 py-3"> <div class="text-white grid grid-cols-6 gap-3 px-3 py-3">

View File

@@ -9,12 +9,13 @@ import PrimaryButton from '@/Components/PrimaryButton.vue';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
import { useFocus } from '@vueuse/core'; import { useFocus } from '@vueuse/core';
import ClientDropdown from '@/Components/Common/Client/ClientDropdown.vue'; import ClientDropdown from '@/Components/Common/Client/ClientDropdown.vue';
import { twMerge } from 'tailwind-merge';
import Badge from '@/Components/Common/Badge.vue'; import Badge from '@/Components/Common/Badge.vue';
import { useClientsStore } from '@/utils/useClients'; import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import ProjectColorSelector from '@/Components/Common/Project/ProjectColorSelector.vue'; import ProjectColorSelector from '@/Components/Common/Project/ProjectColorSelector.vue';
import BillableRateInput from '@/Components/Common/BillableRateInput.vue'; import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
import { UserCircleIcon } from '@heroicons/vue/20/solid';
import InputLabel from '@/Components/InputLabel.vue';
const { createProject } = useProjectsStore(); const { createProject } = useProjectsStore();
const { clients } = storeToRefs(useClientsStore()); const { clients } = storeToRefs(useClientsStore());
@@ -63,33 +64,47 @@ const currentClientName = computed(() => {
<div <div
class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4"> class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4">
<div class="flex-1 flex items-center"> <div class="flex-1 flex items-center">
<ProjectColorSelector <div class="text-center pr-5">
v-model="project.color"></ProjectColorSelector> <InputLabel for="color" value="Color" />
<TextInput <ProjectColorSelector
id="projectName" class="mt-2.5"
ref="projectNameInput" v-model="project.color"></ProjectColorSelector>
v-model="project.name" </div>
type="text" <div class="w-full">
placeholder="Project Name" <InputLabel for="projectName" value="Project name" />
@keydown.enter="submit()" <TextInput
class="mt-1 block w-full" id="projectName"
required name="projectName"
autocomplete="projectName" /> ref="projectNameInput"
v-model="project.name"
type="text"
placeholder="The next big thing"
@keydown.enter="submit()"
class="mt-2 block w-full"
required
autocomplete="projectName" />
</div>
</div> </div>
<div class="sm:max-w-[120px]"> <div class="sm:max-w-[120px]">
<BillableRateInput v-model="project.billable_rate" /> <InputLabel for="billableRate" value="Billable Rate" />
<BillableRateInput
v-model="project.billable_rate"
name="billableRate" />
</div> </div>
<div> <div>
<ClientDropdown v-model="project.client_id"> <InputLabel for="client" value="Client" />
<ClientDropdown class="mt-2" v-model="project.client_id">
<template #trigger> <template #trigger>
<Badge size="large"> <Badge
<div class="bg-input-background cursor-pointer hover:bg-tertiary"
:class=" size="xlarge">
twMerge('inline-block rounded-full') <div class="flex items-center space-x-2">
"></div> <UserCircleIcon
<span> class="w-5 text-icon-default"></UserCircleIcon>
{{ currentClientName }} <span>
</span> {{ currentClientName }}
</span>
</div>
</Badge> </Badge>
</template> </template>
</ClientDropdown> </ClientDropdown>
@@ -97,7 +112,7 @@ const currentClientName = computed(() => {
</div> </div>
</template> </template>
<template #footer> <template #footer>
<SecondaryButton @click="show = false"> Cancel </SecondaryButton> <SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton <PrimaryButton
class="ms-3" class="ms-3"

View File

@@ -78,7 +78,9 @@ const currentClientName = computed(() => {
autocomplete="projectName" /> autocomplete="projectName" />
</div> </div>
<div class="sm:max-w-[120px]"> <div class="sm:max-w-[120px]">
<BillableRateInput v-model="project.billable_rate" /> <BillableRateInput
v-model="project.billable_rate"
name="billable_rate" />
</div> </div>
<div class=""> <div class="">
<ClientDropdown v-model="project.client_id"> <ClientDropdown v-model="project.client_id">

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import MultiselectDropdown from '@/Components/Common/MultiselectDropdown.vue';
import { storeToRefs } from 'pinia';
import { useProjectsStore } from '@/utils/useProjects';
import type { Project } from '@/utils/api';
const projectsStore = useProjectsStore();
const { projects } = storeToRefs(projectsStore);
function getKeyFromItem(item: Project) {
return item.id;
}
function getNameForItem(item: Project) {
return item.name;
}
</script>
<template>
<MultiselectDropdown
searchPlaceholder="Search for a Project..."
:items="projects"
:get-key-from-item="getKeyFromItem"
:get-name-for-item="getNameForItem">
<template #trigger>
<slot name="trigger"></slot>
</template>
</MultiselectDropdown>
</template>

View File

@@ -18,7 +18,7 @@ const props = defineProps<{
}>(); }>();
const projectMember = ref<CreateProjectMemberBody>({ const projectMember = ref<CreateProjectMemberBody>({
user_id: '', member_id: '',
billable_rate: null, billable_rate: null,
}); });
@@ -26,7 +26,7 @@ async function submit() {
await createProjectMember(props.projectId, projectMember.value); await createProjectMember(props.projectId, projectMember.value);
show.value = false; show.value = false;
projectMember.value = { projectMember.value = {
user_id: '', member_id: '',
billable_rate: null, billable_rate: null,
}; };
} }
@@ -49,10 +49,11 @@ useFocus(projectNameInput, { initialValue: true });
<div class="col-span-3 sm:col-span-2"> <div class="col-span-3 sm:col-span-2">
<MemberCombobox <MemberCombobox
:hidden-members="props.existingMembers" :hidden-members="props.existingMembers"
v-model="projectMember.user_id"></MemberCombobox> v-model="projectMember.member_id"></MemberCombobox>
</div> </div>
<div class="col-span-3 sm:col-span-1 flex-1"> <div class="col-span-3 sm:col-span-1 flex-1">
<BillableRateInput <BillableRateInput
name="billable_rate"
v-model=" v-model="
projectMember.billable_rate projectMember.billable_rate
"></BillableRateInput> "></BillableRateInput>

View File

@@ -22,7 +22,7 @@ function deleteProjectMember() {
const { members } = storeToRefs(useMembersStore()); const { members } = storeToRefs(useMembersStore());
const member = computed(() => { const member = computed(() => {
return members.value.find( return members.value.find(
(member) => member.user_id === props.projectMember.user_id (member) => member.id === props.projectMember.member_id
); );
}); });
</script> </script>

View File

@@ -14,13 +14,13 @@ import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import TimeEntryMoreOptionsDropdown from '@/Components/Common/TimeEntry/TimeEntryMoreOptionsDropdown.vue'; import TimeEntryMoreOptionsDropdown from '@/Components/Common/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
import TimeTrackerProjectTaskDropdown from '@/Components/Common/TimeTracker/TimeTrackerProjectTaskDropdown.vue'; import TimeTrackerProjectTaskDropdown from '@/Components/Common/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import BillableToggleButton from '@/Components/Common/BillableToggleButton.vue'; import BillableToggleButton from '@/Components/Common/BillableToggleButton.vue';
import { computed, ref } from 'vue'; import { ref } from 'vue';
import { twMerge } from 'tailwind-merge';
import { import {
formatHumanReadableDuration, formatHumanReadableDuration,
formatStartEnd, formatStartEnd,
} from '../../../utils/time'; } from '../../../utils/time';
import TimeEntryRow from '@/Components/Common/TimeEntry/TimeEntryRow.vue'; import TimeEntryRow from '@/Components/Common/TimeEntry/TimeEntryRow.vue';
import GroupedItemsCountButton from '@/Components/Common/GroupedItemsCountButton.vue';
const currentTimeEntryStore = useCurrentTimeEntryStore(); const currentTimeEntryStore = useCurrentTimeEntryStore();
const { stopTimer } = currentTimeEntryStore; const { stopTimer } = currentTimeEntryStore;
@@ -99,13 +99,6 @@ function updateProjectAndTask(projectId: string, taskId: string) {
} }
const expanded = ref(false); const expanded = ref(false);
const expandedStatusClasses = computed(() => {
if (expanded.value) {
return 'border-card-border border bg-card-background-active text-white';
}
return 'border-card-border border bg-card-background text-muted';
});
</script> </script>
<template> <template>
@@ -118,18 +111,11 @@ const expandedStatusClasses = computed(() => {
<input <input
type="checkbox" type="checkbox"
class="h-4 w-4 rounded bg-card-background border-input-border text-accent-500/80 focus:ring-accent-500/80" /> class="h-4 w-4 rounded bg-card-background border-input-border text-accent-500/80 focus:ring-accent-500/80" />
<button <GroupedItemsCountButton
@click="expanded = !expanded" :expanded="expanded"
:class=" @click="expanded = !expanded">
twMerge( {{ timeEntry?.timeEntries?.length }}
expandedStatusClasses, </GroupedItemsCountButton>
'font-medium w-7 h-7 rounded flex items-center transition justify-center'
)
">
<span>
{{ timeEntry?.timeEntries?.length }}
</span>
</button>
<TimeEntryDescriptionInput <TimeEntryDescriptionInput
@changed="updateTimeEntryDescription" @changed="updateTimeEntryDescription"
class="flex-1" class="flex-1"

View File

@@ -19,7 +19,7 @@ function onChange(event: Event) {
@blur="onChange" @blur="onChange"
@keydown.enter="onChange" @keydown.enter="onChange"
placeholder="Add a description" placeholder="Add a description"
class="text-white placeholder-muted font-medium bg-transparent hover:bg-card-background rounded-lg border border-transparent hover:border-card-border" /> class="text-white font-medium bg-transparent focus-visible:ring-0 hover:bg-card-background rounded-lg border border-transparent hover:border-card-border" />
</label> </label>
</div> </div>
</template> </template>

View File

@@ -21,7 +21,9 @@ const timeEntryTags = computed<Tag[]>(() => {
<template> <template>
<TagDropdown @changed="emit('changed', model)" v-model="model"> <TagDropdown @changed="emit('changed', model)" v-model="model">
<template #trigger> <template #trigger>
<button data-testid="time_entry_tag_dropdown"> <button
data-testid="time_entry_tag_dropdown"
class="opacity-50 group-hover:opacity-100 transition">
<TagBadge <TagBadge
:border="false" :border="false"
size="large" size="large"

View File

@@ -8,6 +8,9 @@ import { useTasksStore } from '@/utils/useTasks';
import ProjectDropdownItem from '@/Components/Common/Project/ProjectDropdownItem.vue'; import ProjectDropdownItem from '@/Components/Common/Project/ProjectDropdownItem.vue';
import type { Project, Task } from '@/utils/api'; import type { Project, Task } from '@/utils/api';
import ProjectBadge from '@/Components/Common/Project/ProjectBadge.vue'; import ProjectBadge from '@/Components/Common/Project/ProjectBadge.vue';
import Badge from '@/Components/Common/Badge.vue';
import { PlusIcon } from '@heroicons/vue/16/solid';
import ProjectCreateModal from '@/Components/Common/Project/ProjectCreateModal.vue';
const projectStore = useProjectsStore(); const projectStore = useProjectsStore();
const { projects } = storeToRefs(projectStore); const { projects } = storeToRefs(projectStore);
@@ -94,9 +97,7 @@ const filteredProjects = computed(() => {
}); });
async function addClientIfNoneExists() { async function addClientIfNoneExists() {
if (highlightedItemId.value) { setProjectAndClientBasedOnHighlightedItem();
setProjectAndClientBasedOnHighlightedItem();
}
} }
function isProjectSelected(project: Project) { function isProjectSelected(project: Project) {
@@ -142,131 +143,122 @@ function updateSearchValue(event: Event) {
const emit = defineEmits(['update:modelValue', 'changed']); const emit = defineEmits(['update:modelValue', 'changed']);
function moveHighlightUp() { function moveHighlightUp() {
if (highlightedItemId.value) { const currentHighlightedIndex = filteredProjects.value.findIndex(
const currentHighlightedIndex = filteredProjects.value.findIndex( (projectWithTasks) =>
projectWithTasks.project.id === highlightedItemId.value
);
// check if it is a project id
if (currentHighlightedIndex === -1) {
// the ID is a task ID
const currentProjectWithTasks = filteredProjects.value.find(
(projectWithTasks) => (projectWithTasks) =>
projectWithTasks.project.id === highlightedItemId.value projectWithTasks.tasks.some(
);
// check if it is a project id
if (currentHighlightedIndex === -1) {
// the ID is a task ID
const currentProjectWithTasks = filteredProjects.value.find(
(projectWithTasks) =>
projectWithTasks.tasks.some(
(task) => task.id === highlightedItemId.value
)
);
if (currentProjectWithTasks) {
const taskIndex = currentProjectWithTasks.tasks.findIndex(
(task) => task.id === highlightedItemId.value (task) => task.id === highlightedItemId.value
); )
if (taskIndex === -1) { );
return; if (currentProjectWithTasks) {
} const taskIndex = currentProjectWithTasks.tasks.findIndex(
if (taskIndex === 0) { (task) => task.id === highlightedItemId.value
// highlight the project if it was the first task before );
highlightedItemId.value = if (taskIndex === -1) {
currentProjectWithTasks.project.id; return;
return;
}
highlightedItemId.value =
currentProjectWithTasks.tasks[taskIndex - 1].id;
} }
if (taskIndex === 0) {
// highlight the project if it was the first task before
highlightedItemId.value = currentProjectWithTasks.project.id;
return;
}
highlightedItemId.value =
currentProjectWithTasks.tasks[taskIndex - 1].id;
} }
if (currentHighlightedIndex === 0) { }
// highlight the last project or the last project of the last project if (currentHighlightedIndex === 0) {
const lastProject = // highlight the last project or the last project of the last project
filteredProjects.value[filteredProjects.value.length - 1]; const lastProject =
if (lastProject.tasks.length > 0) { filteredProjects.value[filteredProjects.value.length - 1];
// highlight last task of last project if (lastProject.tasks.length > 0) {
highlightedItemId.value = // highlight last task of last project
lastProject.tasks[lastProject.tasks.length - 1].id; highlightedItemId.value =
} else { lastProject.tasks[lastProject.tasks.length - 1].id;
highlightedItemId.value =
filteredProjects.value[
filteredProjects.value.length - 1
].project.id;
}
} else { } else {
const previousProject = highlightedItemId.value =
filteredProjects.value[currentHighlightedIndex - 1]; filteredProjects.value[
if (previousProject.tasks.length > 0) { filteredProjects.value.length - 1
// highlight last task of previous project ].project.id;
highlightedItemId.value = }
previousProject.tasks[previousProject.tasks.length - 1].id; } else {
} else { const previousProject =
highlightedItemId.value = filteredProjects.value[currentHighlightedIndex - 1];
filteredProjects.value[ if (previousProject.tasks.length > 0) {
currentHighlightedIndex - 1 // highlight last task of previous project
].project.id; highlightedItemId.value =
} previousProject.tasks[previousProject.tasks.length - 1].id;
} else {
highlightedItemId.value =
filteredProjects.value[currentHighlightedIndex - 1].project.id;
} }
} }
} }
function moveHighlightDown() { function moveHighlightDown() {
if (highlightedItemId.value) { const currentHighlightedIndex = filteredProjects.value.findIndex(
const currentHighlightedIndex = filteredProjects.value.findIndex( (projectWithTasks) =>
projectWithTasks.project.id === highlightedItemId.value
);
// check if it is a project id
if (currentHighlightedIndex === -1) {
// the ID is a task ID
const currentProjectWithTasks = filteredProjects.value.find(
(projectWithTasks) => (projectWithTasks) =>
projectWithTasks.project.id === highlightedItemId.value projectWithTasks.tasks.some(
);
// check if it is a project id
if (currentHighlightedIndex === -1) {
// the ID is a task ID
const currentProjectWithTasks = filteredProjects.value.find(
(projectWithTasks) =>
projectWithTasks.tasks.some(
(task) => task.id === highlightedItemId.value
)
);
if (currentProjectWithTasks) {
const taskIndex = currentProjectWithTasks.tasks.findIndex(
(task) => task.id === highlightedItemId.value (task) => task.id === highlightedItemId.value
)
);
if (currentProjectWithTasks) {
const taskIndex = currentProjectWithTasks.tasks.findIndex(
(task) => task.id === highlightedItemId.value
);
if (taskIndex === -1) {
return;
}
if (taskIndex === currentProjectWithTasks.tasks.length - 1) {
// highlight the next project if it was the last task in current project
const projectIndex = filteredProjects.value.indexOf(
currentProjectWithTasks
); );
if (taskIndex === -1) { if (projectIndex === filteredProjects.value.length - 1) {
return; // highlight the first project if it was the last project
highlightedItemId.value =
filteredProjects.value[0].project.id;
} else {
highlightedItemId.value =
filteredProjects.value[projectIndex + 1].project.id;
} }
if (taskIndex === currentProjectWithTasks.tasks.length - 1) { return;
// highlight the next project if it was the last task in current project
const projectIndex = filteredProjects.value.indexOf(
currentProjectWithTasks
);
if (projectIndex === filteredProjects.value.length - 1) {
// highlight the first project if it was the last project
highlightedItemId.value =
filteredProjects.value[0].project.id;
} else {
highlightedItemId.value =
filteredProjects.value[projectIndex + 1].project.id;
}
return;
}
highlightedItemId.value =
currentProjectWithTasks.tasks[taskIndex + 1].id;
} }
highlightedItemId.value =
currentProjectWithTasks.tasks[taskIndex + 1].id;
} }
if (currentHighlightedIndex === filteredProjects.value.length - 1) { }
// highlight the first project or the last project of the last project if (currentHighlightedIndex === filteredProjects.value.length - 1) {
const lastProject = // highlight the first project or the last project of the last project
filteredProjects.value[filteredProjects.value.length - 1]; const lastProject =
if (lastProject.tasks.length > 0) { filteredProjects.value[filteredProjects.value.length - 1];
// highlight last task of last project if (lastProject.tasks.length > 0) {
highlightedItemId.value = lastProject.tasks[0].id; // highlight last task of last project
} else { highlightedItemId.value = lastProject.tasks[0].id;
highlightedItemId.value = filteredProjects.value[0].project.id;
}
} else { } else {
const currentProjectWithTasks = highlightedItemId.value = filteredProjects.value[0].project.id;
filteredProjects.value[currentHighlightedIndex]; }
if (currentProjectWithTasks.tasks.length > 0) { } else {
// highlight last task of previous project const currentProjectWithTasks =
highlightedItemId.value = currentProjectWithTasks.tasks[0].id; filteredProjects.value[currentHighlightedIndex];
} else { if (currentProjectWithTasks.tasks.length > 0) {
highlightedItemId.value = // highlight last task of previous project
filteredProjects.value[ highlightedItemId.value = currentProjectWithTasks.tasks[0].id;
currentHighlightedIndex + 1 } else {
].project.id; highlightedItemId.value =
} filteredProjects.value[currentHighlightedIndex + 1].project.id;
} }
} }
} }
@@ -307,10 +299,23 @@ function selectProject(projectId: string) {
open.value = false; open.value = false;
emit('changed', project.value, task.value); emit('changed', project.value, task.value);
} }
const showCreateProject = ref(false);
</script> </script>
<template> <template>
<Dropdown width="120" v-model="open" :closeOnContentClick="true"> <div v-if="projects.length === 0">
<Badge
@click="showCreateProject = true"
size="large"
class="cursor-pointer hover:bg-tertiary">
<PlusIcon class="-ml-1 w-5"></PlusIcon>
<span>Add new project</span>
</Badge>
<ProjectCreateModal
v-model:show="showCreateProject"></ProjectCreateModal>
</div>
<Dropdown v-else width="120" v-model="open" :closeOnContentClick="true">
<template #trigger> <template #trigger>
<ProjectBadge <ProjectBadge
ref="projectDropdownTrigger" ref="projectDropdownTrigger"
@@ -319,7 +324,7 @@ function selectProject(projectId: string) {
:border="showBadgeBorder" :border="showBadgeBorder"
tag="button" tag="button"
:name="selectedProjectName" :name="selectedProjectName"
class="focus:border-input-border-active focus:outline-0 focus:bg-card-background-separator hover:bg-card-background-separator"> class="focus:border-border-tertiary focus:outline-0 focus:bg-card-background-separator hover:bg-card-background-separator">
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<span> <span>
{{ selectedProjectName }} {{ selectedProjectName }}

View File

@@ -25,7 +25,7 @@ const iconColorClasses = computed(() => {
:class=" :class="
twMerge( twMerge(
iconColorClasses, iconColorClasses,
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-separator hover:bg-card-background-separator rounded-full w-7 sm:w-10 h-7 sm:h-10 flex items-center justify-center' 'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus-visible:bg-card-background-separator hover:bg-card-background-separator rounded-full w-7 sm:w-10 h-7 sm:h-10 flex items-center justify-center'
) )
"> ">
<TagIcon class="w-5 sm:w-6 h-5 sm:h-6"></TagIcon> <TagIcon class="w-5 sm:w-6 h-5 sm:h-6"></TagIcon>

View File

@@ -27,7 +27,7 @@ const buttonColorClasses = computed(() => {
if (props.active) { if (props.active) {
return 'bg-red-400/80 hover:bg-red-500/80 focus:bg-red-500/80'; return 'bg-red-400/80 hover:bg-red-500/80 focus:bg-red-500/80';
} else { } else {
return 'bg-accent-300/50 hover:bg-accent-400/70 focus:bg-accent-400/70'; return 'bg-accent-300/70 hover:bg-accent-400/70 focus:bg-accent-400/70';
} }
}); });

View File

@@ -19,6 +19,7 @@ import {
formatHumanReadableDuration, formatHumanReadableDuration,
getDayJsInstance, getDayJsInstance,
} from '@/utils/time'; } from '@/utils/time';
import { useCssVar } from '@vueuse/core';
const props = defineProps<{ const props = defineProps<{
dailyHoursTracked: { duration: number; date: string }[]; dailyHoursTracked: { duration: number; date: string }[];
@@ -40,6 +41,8 @@ const max = Math.max(
1 1
); );
const backgroundColor = useCssVar('--color-bg-secondary');
const itemBackgroundColor = useCssVar('--color-bg-tertiary');
const option = ref({ const option = ref({
tooltip: {}, tooltip: {},
visualMap: { visualMap: {
@@ -50,7 +53,7 @@ const option = ref({
left: 'center', left: 'center',
top: 'center', top: 'center',
inRange: { inRange: {
color: ['#242940', '#2DBE45'], color: [itemBackgroundColor.value, '#2DBE45'],
}, },
show: false, show: false,
}, },
@@ -74,8 +77,9 @@ const option = ref({
.format('YYYY-MM-DD'), .format('YYYY-MM-DD'),
], ],
itemStyle: { itemStyle: {
color: 'transparent',
borderWidth: 8, borderWidth: 8,
borderColor: '#13152B', borderColor: backgroundColor.value,
}, },
yearLabel: { show: false }, yearLabel: { show: false },
}, },
@@ -85,6 +89,8 @@ const option = ref({
data: props.dailyHoursTracked.map((el) => [el.date, el.duration]), data: props.dailyHoursTracked.map((el) => [el.date, el.duration]),
itemStyle: { itemStyle: {
borderRadius: 5, borderRadius: 5,
borderColor: 'rgba(255,255,255,0.05)',
borderWidth: 1,
}, },
tooltip: { tooltip: {
valueFormatter: (value: number, dataIndex: number) => { valueFormatter: (value: number, dataIndex: number) => {
@@ -104,10 +110,9 @@ const option = ref({
<DashboardCard title="Activity Graph" :icon="BoltIcon"> <DashboardCard title="Activity Graph" :icon="BoltIcon">
<div class="px-2"> <div class="px-2">
<v-chart <v-chart
:autoresize="true"
class="chart" class="chart"
:option="option" :option="option"
style="height: 260px" /> style="height: 260px; background-color: transparent" />
</div> </div>
</DashboardCard> </DashboardCard>
</template> </template>

View File

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

View File

@@ -1,20 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import VChart from 'vue-echarts'; import VChart from 'vue-echarts';
import { ref } from 'vue'; import { ref } from 'vue';
import { useCssVar } from '@vueuse/core';
const props = defineProps<{ const props = defineProps<{
history: number[]; history: number[];
}>(); }>();
const accentColor = useCssVar('--color-accent-quaternary');
const seriesData = props.history.map((el) => { const seriesData = props.history.map((el) => {
return { return {
value: el, value: el,
...{ ...{
itemStyle: { itemStyle: {
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(125,156,188,1)', borderColor: 'rgba(' + accentColor.value + ',0.8)',
borderRadius: [2, 2, 0, 0], borderRadius: [2, 2, 0, 0],
color: 'rgba(125,156,188,1)', color: 'rgba(' + accentColor.value + ',0.8)',
}, },
}, },
}; };

View File

@@ -11,6 +11,8 @@ import {
TitleComponent, TitleComponent,
TooltipComponent, TooltipComponent,
} from 'echarts/components'; } from 'echarts/components';
import { useCssVar } from '@vueuse/core';
import { formatHumanReadableDuration } from '@/utils/time';
use([ use([
CanvasRenderer, CanvasRenderer,
@@ -23,6 +25,8 @@ use([
provide(THEME_KEY, 'dark'); provide(THEME_KEY, 'dark');
const backgroundColor = useCssVar('--theme-color-default-background');
function hexToRGBA(hex: string, opacity = 1) { function hexToRGBA(hex: string, opacity = 1) {
// Remove the hash at the start if it's there // Remove the hash at the start if it's there
hex = hex.replace(/^#/, ''); hex = hex.replace(/^#/, '');
@@ -60,7 +64,7 @@ const seriesData = props.weeklyProjectOverview.map((el) => {
itemStyle: { itemStyle: {
borderRadius: 15, borderRadius: 15,
// TODO: Fix dynamic color // TODO: Fix dynamic color
borderColor: '#0b0d1c', borderColor: backgroundColor.value,
borderWidth: 18, borderWidth: 18,
color: new LinearGradient(0, 0, 0, 1, [ color: new LinearGradient(0, 0, 0, 1, [
{ {
@@ -77,13 +81,23 @@ const seriesData = props.weeklyProjectOverview.map((el) => {
}; };
}); });
const option = ref({ const option = ref({
tooltip: {
trigger: 'item',
},
legend: {
orient: 'vertical',
bottom: 'bottom',
},
backgroundColor: 'transparent', backgroundColor: 'transparent',
series: [ series: [
{ {
label: { label: {
// TODO: Muted color make dynamic show: false,
color: '#D9DCFB', },
fontWeight: 'bold', tooltip: {
valueFormatter: (value: number) => {
return formatHumanReadableDuration(value);
},
}, },
data: seriesData, data: seriesData,
radius: ['30%', '65%'], radius: ['30%', '65%'],

View File

@@ -18,6 +18,7 @@ import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
import { formatHumanReadableDuration } from '@/utils/time'; import { formatHumanReadableDuration } from '@/utils/time';
import { formatCents } from '@/utils/money'; import { formatCents } from '@/utils/money';
import { getWeekStart } from '@/utils/useUser'; import { getWeekStart } from '@/utils/useUser';
import { useCssVar } from '@vueuse/core';
use([ use([
CanvasRenderer, CanvasRenderer,
@@ -47,6 +48,7 @@ const props = defineProps<{
duration: number; duration: number;
}[]; }[];
}>(); }>();
const accentColor = useCssVar('--color-accent-quaternary');
const seriesData = props.weeklyHistory.map((el) => { const seriesData = props.weeklyHistory.map((el) => {
return { return {
@@ -56,23 +58,34 @@ const seriesData = props.weeklyHistory.map((el) => {
borderColor: new LinearGradient(0, 0, 0, 1, [ borderColor: new LinearGradient(0, 0, 0, 1, [
{ {
offset: 0, offset: 0,
color: 'rgba(125,156,188,1)', color: 'rgba(' + accentColor.value + ',0.7)',
}, },
{ {
offset: 1, offset: 1,
color: 'rgba(125,156,188,0.7)', color: 'rgba(' + accentColor.value + ',0.5)',
}, },
]), ]),
borderWidth: 3, emphasis: {
color: new LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: 'rgba(' + accentColor.value + ',0.9)',
},
{
offset: 1,
color: 'rgba(' + accentColor.value + ',0.7)',
},
]),
},
borderRadius: [12, 12, 0, 0], borderRadius: [12, 12, 0, 0],
color: new LinearGradient(0, 0, 0, 1, [ color: new LinearGradient(0, 0, 0, 1, [
{ {
offset: 0, offset: 0,
color: 'rgba(125,156,188,0.9)', color: 'rgba(' + accentColor.value + ',0.7)',
}, },
{ {
offset: 1, offset: 1,
color: 'rgba(125,156,188,0.4)', color: 'rgba(' + accentColor.value + ',0.5)',
}, },
]), ]),
}, },
@@ -106,6 +119,8 @@ const weekdays = computed(() => {
} }
}); });
const markLineColor = useCssVar('--color-border-secondary');
const option = ref({ const option = ref({
tooltip: { tooltip: {
trigger: 'item', trigger: 'item',
@@ -120,12 +135,6 @@ const option = ref({
xAxis: { xAxis: {
type: 'category', type: 'category',
data: weekdays.value, data: weekdays.value,
markLine: {
lineStyle: {
color: 'rgba(125,156,188,0.1)',
type: 'dashed',
},
},
axisLine: { axisLine: {
lineStyle: { lineStyle: {
color: 'transparent', // Set desired color here color: 'transparent', // Set desired color here
@@ -147,7 +156,7 @@ const option = ref({
type: 'value', type: 'value',
splitLine: { splitLine: {
lineStyle: { lineStyle: {
color: 'rgba(125,156,188,0.2)', // Set desired color here color: markLineColor.value,
}, },
}, },
}, },

View File

@@ -40,7 +40,7 @@ const close = () => {
</div> </div>
<div <div
class="flex flex-row justify-end px-6 py-4 border-t border-card-background-separator bg-card-background text-end"> class="flex flex-row justify-end px-6 py-4 border-t border-card-background-separator bg-default-background rounded-b-2xl text-end">
<slot name="footer" /> <slot name="footer" />
</div> </div>
</Modal> </Modal>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'; import { onMounted, onUnmounted, ref } from 'vue';
import { flip, type Placement, useFloating } from '@floating-ui/vue'; import { flip, type Placement, useFloating } from '@floating-ui/vue';
import { offset } from '@floating-ui/vue'; import { offset } from '@floating-ui/vue';
import { autoUpdate } from '@floating-ui/vue'; import { autoUpdate } from '@floating-ui/vue';
@@ -7,12 +7,10 @@ import { autoUpdate } from '@floating-ui/vue';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
align: Placement; align: Placement;
width: string;
closeOnContentClick: boolean; closeOnContentClick: boolean;
}>(), }>(),
{ {
align: 'bottom-start', align: 'bottom-start',
width: '48',
closeOnContentClick: true, closeOnContentClick: true,
} }
); );
@@ -39,12 +37,6 @@ function onContentClick() {
} }
} }
const widthClass = computed(() => {
return {
48: 'w-48',
}[props.width.toString()];
});
function toggleOpen() { function toggleOpen() {
open.value = !open.value; open.value = !open.value;
if (open.value === true) { if (open.value === true) {
@@ -82,7 +74,6 @@ const { floatingStyles } = useFloating(reference, floating, {
v-show="open" v-show="open"
ref="floating" ref="floating"
class="z-50" class="z-50"
:class="[widthClass]"
:style="floatingStyles" :style="floatingStyles"
@click="onContentClick"> @click="onContentClick">
<transition <transition
@@ -94,7 +85,7 @@ const { floatingStyles } = useFloating(reference, floating, {
leave-to-class="transform opacity-0 scale-95"> leave-to-class="transform opacity-0 scale-95">
<div <div
v-if="open" v-if="open"
class="rounded-lg ring-1 relative ring-black ring-opacity-5 border border-card-border overflow-none bg-card-background shadow-lg"> class="rounded-lg ring-1 relative ring-black ring-opacity-5 border border-card-border overflow-none shadow-dropdown bg-card-background">
<slot name="content" /> <slot name="content" />
</div> </div>
</transition> </transition>

View File

@@ -91,7 +91,7 @@ const maxWidthClass = computed(() => {
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"> leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<div <div
v-show="show" v-show="show"
class="mb-6 bg-card-background border border-card-border rounded-lg shadow-xl transform transition-all sm:w-full sm:mx-auto" class="mb-6 bg-default-background border border-card-border rounded-lg shadow-xl transform transition-all sm:w-full sm:mx-auto"
:class="maxWidthClass"> :class="maxWidthClass">
<slot v-if="show" /> <slot v-if="show" />
</div> </div>

View File

@@ -26,7 +26,7 @@ const sizeClasses = {
:type="type" :type="type"
:class=" :class="
twMerge( twMerge(
'bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover shadow-sm transition text-white rounded-lg font-semibold inline-flex items-center space-x-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25 ease-in-out', 'bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover shadow-sm transition text-white rounded-lg font-semibold inline-flex items-center space-x-1.5 focus-visible:border-input-border-active focus:outline-none focus:ring-0 disabled:opacity-25 ease-in-out',
sizeClasses[props.size] sizeClasses[props.size]
) )
"> ">

View File

@@ -28,7 +28,7 @@ function updateValue(event: Event) {
<template> <template>
<input <input
ref="input" ref="input"
class="border-input-border bg-input-background text-white focus:border-input-border-active rounded-md shadow-sm" class="border-input-border border bg-input-background text-white focus:ring-input-border-active focus:ring-0 focus-visible:border-input-border-active rounded-md shadow-sm"
:value="modelValue" :value="modelValue"
:name="name" :name="name"
@input="updateValue" /> @input="updateValue" />

View File

@@ -186,7 +186,7 @@ function switchToTimeEntryOrganization() {
</div> </div>
<div class="flex items-center relative" data-testid="dashboard_timer"> <div class="flex items-center relative" data-testid="dashboard_timer">
<div <div
class="flex flex-col sm:flex-row w-full rounded-lg bg-card-background border-card-border border transition"> class="flex flex-col sm:flex-row w-full rounded-lg bg-card-background border-card-border border transition shadow-card">
<div class="flex-1 flex items-center pr-6"> <div class="flex-1 flex items-center pr-6">
<input <input
placeholder="What are you working on?" placeholder="What are you working on?"
@@ -194,7 +194,7 @@ function switchToTimeEntryOrganization() {
v-model="currentTimeEntry.description" v-model="currentTimeEntry.description"
@keydown.enter="startTimerIfNotActive" @keydown.enter="startTimerIfNotActive"
@blur="updateTimeEntry" @blur="updateTimeEntry"
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3 border-b border-b-card-background-separator sm:px-4 text-base sm:text-lg text-white focus:bg-card-background-active font-medium bg-transparent border-none placeholder-muted focus:ring-0 transition" class="w-full rounded-l-lg py-4 sm:py-2.5 px-3 border-b border-b-card-background-separator sm:px-4 text-base sm:text-lg text-white font-medium bg-transparent border-none placeholder-muted focus:ring-0 transition"
type="text" /> type="text" />
</div> </div>
<div class="flex items-center justify-between pl-2"> <div class="flex items-center justify-between pl-2">
@@ -226,7 +226,7 @@ function switchToTimeEntryOrganization() {
@blur="updateTimerAndStartLiveTimerUpdate" @blur="updateTimerAndStartLiveTimerUpdate"
@keydown.enter="onTimeEntryEnterPress" @keydown.enter="onTimeEntryEnterPress"
v-model="currentTime" v-model="currentTime"
class="w-[110px] sm:w-[130px] h-full text-white py-2.5 rounded-r-lg text-center px-4 text-sm sm:text-lg font-bold bg-card-background border-none placeholder-muted focus:ring-0 transition focus:bg-card-background-active" class="w-[110px] sm:w-[130px] h-full text-white py-2.5 rounded-r-lg text-center px-4 text-sm sm:text-lg font-bold bg-card-background border-none placeholder-muted focus:ring-0 transition"
type="text" /> type="text" />
</div> </div>
</div> </div>

View File

@@ -1,14 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import MainContainer from '@/Pages/MainContainer.vue'; import MainContainer from '@/Pages/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue'; import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid'; import { TagIcon, PlusIcon } from '@heroicons/vue/16/solid';
import SecondaryButton from '@/Components/SecondaryButton.vue'; import SecondaryButton from '@/Components/SecondaryButton.vue';
import { ref } from 'vue'; import { ref } from 'vue';
import TagTable from '@/Components/Common/Tag/TagTable.vue'; import TagTable from '@/Components/Common/Tag/TagTable.vue';
import TagCreateModal from '@/Components/Common/Tag/TagCreateModal.vue'; import TagCreateModal from '@/Components/Common/Tag/TagCreateModal.vue';
import PageTitle from '@/Components/Common/PageTitle.vue'; import PageTitle from '@/Components/Common/PageTitle.vue';
import { canCreateTags } from '@/utils/permissions'; import { canCreateTags } from '@/utils/permissions';
const createTag = ref(false); const createTag = ref(false);
</script> </script>
@@ -17,7 +16,7 @@ const createTag = ref(false);
<MainContainer <MainContainer
class="py-5 border-b border-default-background-separator flex justify-between items-center"> class="py-5 border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-6"> <div class="flex items-center space-x-6">
<PageTitle :icon="FolderIcon" title="Tags"> </PageTitle> <PageTitle :icon="TagIcon" title="Tags"> </PageTitle>
</div> </div>
<SecondaryButton <SecondaryButton
v-if="canCreateTags()" v-if="canCreateTags()"

View File

@@ -68,12 +68,10 @@ const groupedTimeEntries = computed(() => {
e.billable === entry.billable && e.billable === entry.billable &&
e.description === entry.description e.description === entry.description
); );
console.log(oldEntriesIndex);
if (oldEntriesIndex !== -1 && newDailyEntries[oldEntriesIndex]) { if (oldEntriesIndex !== -1 && newDailyEntries[oldEntriesIndex]) {
newDailyEntries[oldEntriesIndex].timeEntries.push(entry); newDailyEntries[oldEntriesIndex].timeEntries.push(entry);
// Add up durations for time entries of the same type // Add up durations for time entries of the same type
console.log(newDailyEntries[oldEntriesIndex], entry?.duration);
newDailyEntries[oldEntriesIndex].duration = newDailyEntries[oldEntriesIndex].duration =
(newDailyEntries[oldEntriesIndex].duration ?? 0) + (newDailyEntries[oldEntriesIndex].duration ?? 0) +
(entry?.duration ?? 0); (entry?.duration ?? 0);

View File

@@ -75,6 +75,8 @@ export interface Task {
organization: Organization; organization: Organization;
} }
type OrganizationWithMembership = Organization & { membership: Membership };
export interface User { export interface User {
// columns // columns
id: string; id: string;
@@ -98,6 +100,7 @@ export interface User {
organizations: Organization[]; organizations: Organization[];
clients: Client[]; clients: Client[];
current_team: Organization; current_team: Organization;
all_teams: OrganizationWithMembership[];
owned_teams: Organization[]; owned_teams: Organization[];
teams: Organization[]; teams: Organization[];
} }

View File

@@ -73,8 +73,8 @@ export const useNotificationsStore = defineStore('notifications', () => {
); );
} }
} }
throw new Error('Failed to handle API request', { cause: error });
} }
throw new Error('Failed to handle API request');
} }
return { addNotification, notifications, handleApiRequestNotifications }; return { addNotification, notifications, handleApiRequestNotifications };

View File

@@ -4,7 +4,11 @@ import { api } from '../../../openapi.json.client';
import type { TimeEntry } from '@/utils/api'; import type { TimeEntry } from '@/utils/api';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import utc from 'dayjs/plugin/utc'; import utc from 'dayjs/plugin/utc';
import { getCurrentOrganizationId, getCurrentUserId } from '@/utils/useUser'; import {
getCurrentMembershipId,
getCurrentOrganizationId,
getCurrentUserId,
} from '@/utils/useUser';
import { useLocalStorage } from '@vueuse/core'; import { useLocalStorage } from '@vueuse/core';
import { useTimeEntriesStore } from '@/utils/useTimeEntries'; import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import { useNotificationsStore } from '@/utils/notification'; import { useNotificationsStore } from '@/utils/notification';
@@ -77,9 +81,9 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
} }
async function startTimer() { async function startTimer() {
const user = getCurrentUserId();
const organization = getCurrentOrganizationId(); const organization = getCurrentOrganizationId();
if (organization) { const membership = getCurrentMembershipId();
if (organization && membership) {
const startTime = const startTime =
currentTimeEntry.value.start !== '' currentTimeEntry.value.start !== ''
? currentTimeEntry.value.start ? currentTimeEntry.value.start
@@ -87,7 +91,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
const response = await handleApiRequestNotifications( const response = await handleApiRequestNotifications(
api.createTimeEntry( api.createTimeEntry(
{ {
user_id: user, member_id: membership,
start: startTime, start: startTime,
description: currentTimeEntry.value?.description, description: currentTimeEntry.value?.description,
project_id: currentTimeEntry.value?.project_id, project_id: currentTimeEntry.value?.project_id,

View File

@@ -33,7 +33,7 @@ export const useMembersStore = defineStore('members', () => {
{ {
params: { params: {
organization: organization, organization: organization,
membership: membershipId, member: membershipId,
}, },
} }
), ),

View File

@@ -1,10 +1,14 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { getCurrentOrganizationId, getCurrentUserId } from '@/utils/useUser'; import {
getCurrentMembershipId,
getCurrentOrganizationId,
} from '@/utils/useUser';
import { api } from '../../../openapi.json.client'; import { api } from '../../../openapi.json.client';
import { reactive, ref } from 'vue'; import { reactive, ref } from 'vue';
import type { CreateTimeEntryBody, TimeEntry } from '@/utils/api'; import type { CreateTimeEntryBody, TimeEntry } from '@/utils/api';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useNotificationsStore } from '@/utils/notification'; import { useNotificationsStore } from '@/utils/notification';
export type TimeEntriesGroupedByType = TimeEntry & { timeEntries: TimeEntry[] }; export type TimeEntriesGroupedByType = TimeEntry & { timeEntries: TimeEntry[] };
export const useTimeEntriesStore = defineStore('timeEntries', () => { export const useTimeEntriesStore = defineStore('timeEntries', () => {
@@ -12,6 +16,7 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
const allTimeEntriesLoaded = ref(false); const allTimeEntriesLoaded = ref(false);
const { handleApiRequestNotifications } = useNotificationsStore(); const { handleApiRequestNotifications } = useNotificationsStore();
async function fetchTimeEntries() { async function fetchTimeEntries() {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (organizationId) { if (organizationId) {
@@ -22,7 +27,7 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
}, },
queries: { queries: {
only_full_dates: 'true', only_full_dates: 'true',
user_id: getCurrentUserId(), member_id: getCurrentMembershipId(),
}, },
}), }),
undefined, undefined,
@@ -48,7 +53,7 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
}, },
queries: { queries: {
only_full_dates: 'true', only_full_dates: 'true',
user_id: getCurrentUserId(), member_id: getCurrentMembershipId(),
before: dayjs(latestTimeEntry.start).utc().format(), before: dayjs(latestTimeEntry.start).utc().format(),
}, },
}), }),
@@ -84,11 +89,18 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
} }
} }
async function createTimeEntry(timeEntry: CreateTimeEntryBody) { async function createTimeEntry(
timeEntry: Omit<CreateTimeEntryBody, 'member_id'>
) {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (organizationId) { const memberId = getCurrentMembershipId();
if (organizationId && memberId !== undefined) {
const newTimeEntry = {
...timeEntry,
member_id: memberId,
} as CreateTimeEntryBody;
await handleApiRequestNotifications( await handleApiRequestNotifications(
api.createTimeEntry(timeEntry, { api.createTimeEntry(newTimeEntry, {
params: { params: {
organization: organizationId, organization: organizationId,
}, },

View File

@@ -18,6 +18,12 @@ function getCurrentOrganizationId() {
return page.props.auth.user.current_team_id; return page.props.auth.user.current_team_id;
} }
function getCurrentMembershipId() {
return page.props.auth.user.all_teams.find(
(team) => team.id === getCurrentOrganizationId()
)?.membership.id;
}
function getUserTimezone() { function getUserTimezone() {
return page.props.auth.user.timezone; return page.props.auth.user.timezone;
} }
@@ -27,4 +33,5 @@ export {
getCurrentUserId, getCurrentUserId,
getUserTimezone, getUserTimezone,
getWeekStart, getWeekStart,
getCurrentMembershipId,
}; };

View File

@@ -2,7 +2,7 @@ import defaultTheme from 'tailwindcss/defaultTheme';
import forms from '@tailwindcss/forms'; import forms from '@tailwindcss/forms';
import typography from '@tailwindcss/typography'; import typography from '@tailwindcss/typography';
/** @type {import('tailwindcss').Config} */ /** @type {import("tailwindcss").Config} */
export default { export default {
content: [ content: [
'./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php', './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',
@@ -11,9 +11,12 @@ export default {
'./resources/views/**/*.blade.php', './resources/views/**/*.blade.php',
'./resources/js/**/*.vue', './resources/js/**/*.vue',
], ],
theme: { theme: {
extend: { extend: {
boxShadow: {
'card': '0 4px 7px 0px rgb(0 0 0 / 30%)',
'dropdown': '0 4px 7px 0px rgb(0 0 0 / 40%)',
},
containers: { containers: {
'2xs': '16rem', '2xs': '16rem',
}, },
@@ -21,27 +24,38 @@ export default {
sans: ['Outfit', ...defaultTheme.fontFamily.sans], sans: ['Outfit', ...defaultTheme.fontFamily.sans],
}, },
colors: { colors: {
'white': '#D9DCFB', 'primary': 'var(--color-bg-primary)',
'secondary': 'var(--color-bg-secondary)',
'tertiary': 'var(--color-bg-tertiary)',
'quaternary': 'var(--color-bg-quaternary)',
'text-primary': 'var(--color-text-primary)',
'text-secondary': 'var(--color-text-secondary)',
'text-tertiary': 'var(--color-text-tertiary)',
'text-quaternary': 'var(--color-text-quaternary)',
'border-primary': 'var(--color-border-primary)',
'border-secondary': 'var(--color-border-secondary)',
'border-tertiary': 'var(--color-border-tertiary)',
'default-background': 'var(--theme-color-default-background)', 'default-background': 'var(--theme-color-default-background)',
'default-background-separator': '#13152B', 'default-background-separator':
'var(--theme-color-default-background-separator)',
'card-background': 'var(--theme-color-card-background)', 'card-background': 'var(--theme-color-card-background)',
'card-background-active': 'card-background-active':
'var(--theme-color-card-background-active)', 'var(--theme-color-card-background-active)',
'card-background-separator': '#262A51', 'card-background-separator':
'var(--theme-color-card-background-separator)',
'card-border': 'var(--theme-color-card-border)', 'card-border': 'var(--theme-color-card-border)',
'card-border-active': 'var(--theme-color-card-border-active)', 'card-border-active': 'var(--theme-color-card-border-active)',
'muted': '#8F93B7', 'muted': 'var(--theme-color-muted-text)',
'icon-default': 'var(--theme-color-icon-default)', 'icon-default': 'var(--theme-color-icon-default)',
'tab-background': 'var(--theme-color-tab-background)', 'tab-background': 'var(--theme-color-tab-background)',
'tab-background-active': 'tab-background-active':
'var(--theme-color-tab-background-active)', 'var(--theme-color-tab-background-active)',
'tab-border': 'var(--theme-color-tab-border)', 'tab-border': 'var(--theme-color-tab-border)',
'icon-active': '#787DA8', 'icon-active': 'var(--theme-color-icon-active)',
'menu-active': '#13152B', 'menu-active': 'var(--theme-color-menu-active)',
'input-placeholder': '#42466C', 'input-border': 'var(--theme-color-input-border)',
'input-border': '#242740', 'input-border-active': 'var(--color-input-border-active)',
'input-border-active': '#797EA8', 'input-background': 'var(--theme-color-input-background)',
'input-background': '#030513',
'button-secondary-background': 'button-secondary-background':
'var(--theme-color-card-background)', 'var(--theme-color-card-background)',
'button-secondary-background-hover': 'button-secondary-background-hover':
@@ -52,17 +66,10 @@ export default {
'var(--theme-color-row-heading-background)', 'var(--theme-color-row-heading-background)',
'row-heading-border': 'var(--theme-color-row-heading-border)', 'row-heading-border': 'var(--theme-color-row-heading-border)',
'accent': { 'accent': {
'50': '#eff7ff', '200': 'rgba(var(--color-accent-quaternary), <alpha-value>)',
'100': '#daecff', '300': 'rgba(var(--color-accent-tertiary), <alpha-value>)',
'200': '#b0d7ff', '400': 'rgba(var(--color-accent-secondary), <alpha-value>)',
'300': '#91caff', '500': 'rgba(var(--color-accent-primary), <alpha-value>)',
'400': '#5eadfc',
'500': '#388bf9',
'600': '#226cee',
'700': '#1a57db',
'800': '#1c46b1',
'900': '#1c3f8c',
'950': '#162755',
}, },
}, },
}, },