add basic crud frontend for clients, tags and projects

This commit is contained in:
Gregor Vostrak
2024-04-03 16:07:23 +02:00
parent 824e3d99ae
commit 0081aa7a6e
77 changed files with 2670 additions and 330 deletions

View File

@@ -67,7 +67,7 @@ The Zodius HTTP client is generated using the following command:
```bash
npm run generate:zod
npm run zod:generate
```
## Contributing

View File

@@ -29,6 +29,8 @@ class ClientController extends Controller
* @return ClientCollection<ClientResource>
*
* @throws AuthorizationException
*
* @operationId getClients
*/
public function index(Organization $organization): ClientCollection
{
@@ -46,6 +48,8 @@ class ClientController extends Controller
* Create client
*
* @throws AuthorizationException
*
* @operationId createClient
*/
public function store(Organization $organization, TagStoreRequest $request): ClientResource
{
@@ -63,6 +67,8 @@ class ClientController extends Controller
* Update client
*
* @throws AuthorizationException
*
* @operationId updateClient
*/
public function update(Organization $organization, Client $client, TagUpdateRequest $request): ClientResource
{
@@ -78,6 +84,8 @@ class ClientController extends Controller
* Delete client
*
* @throws AuthorizationException
*
* @operationId deleteClient
*/
public function destroy(Organization $organization, Client $client): JsonResponse
{

View File

@@ -23,6 +23,8 @@ class MemberController extends Controller
* @return MemberCollection<MemberResource>>
*
* @throws AuthorizationException
*
* @operationId getMembers
*/
public function index(Organization $organization, MemberIndexRequest $request): MemberCollection
{
@@ -38,6 +40,8 @@ class MemberController extends Controller
* Invite a placeholder user to become a member of the organization
*
* @throws AuthorizationException|UserNotPlaceholderApiException
*
* @operationId invitePlaceholder
*/
public function invitePlaceholder(Organization $organization, User $user, Request $request): JsonResponse
{

View File

@@ -2,7 +2,6 @@ import { expect, test } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
async function registerNewUser(page, email, password) {
//await page.getByRole('link', { name: 'Register' }).click();
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
await page.getByLabel('Name').fill('John Doe');
await page.getByLabel('Email').fill(email);

51
e2e/clients.spec.ts Normal file
View File

@@ -0,0 +1,51 @@
import { expect, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
}
// Create new project via modal
test('test that creating and deleting a new client via the modal works', async ({
page,
}) => {
const newClientName =
'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Client' }).click();
await page.getByPlaceholder('Client Name').fill(newClientName);
await Promise.all([
page.getByRole('button', { name: 'Create Client' }).nth(1).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/clients') &&
response.request().method() === 'POST' &&
response.status() === 201 &&
(await response.json()).data.id !== null &&
(await response.json()).data.name === newClientName
),
]);
await expect(page.getByTestId('client_table')).toContainText(newClientName);
const moreButton = page.locator(
"[aria-label='Actions for Client " + newClientName + "']"
);
moreButton.click();
const deleteButton = page.locator(
"[aria-label='Delete Client " + newClientName + "']"
);
await Promise.all([
deleteButton.click(),
page.waitForResponse(
async (response) =>
response.url().includes('/clients') &&
response.request().method() === 'DELETE' &&
response.status() === 204
),
]);
await expect(page.getByTestId('client_table')).not.toContainText(
newClientName
);
});

65
e2e/projects.spec.ts Normal file
View File

@@ -0,0 +1,65 @@
import { expect, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
}
// 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);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByPlaceholder('Project Name').fill(newProjectName);
await Promise.all([
page.getByRole('button', { name: 'Create Project' }).nth(1).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/projects') &&
response.request().method() === 'POST' &&
response.status() === 201 &&
(await response.json()).data.id !== null &&
(await response.json()).data.color !== null &&
(await response.json()).data.client_id === null &&
(await response.json()).data.name === newProjectName
),
]);
await expect(page.getByTestId('project_table')).toContainText(
newProjectName
);
const moreButton = page.locator(
"[aria-label='Actions for Project " + newProjectName + "']"
);
moreButton.click();
const deleteButton = page.locator(
"[aria-label='Delete Project " + newProjectName + "']"
);
await Promise.all([
deleteButton.click(),
page.waitForResponse(
async (response) =>
response.url().includes('/projects') &&
response.request().method() === 'DELETE' &&
response.status() === 204
),
]);
await expect(page.getByTestId('project_table')).not.toContainText(
newProjectName
);
});
// Create new project with new Client
// Create new project with existing Client
// Delete project via More Options
// Test that project task count is displayed correctly
// Test that active / archive / all filter works (once implemented)

48
e2e/tags.spec.ts Normal file
View File

@@ -0,0 +1,48 @@
import { expect, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
async function goToTagsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/tags');
}
// Create new project via modal
test('test that creating and deleting a new client via the modal works', async ({
page,
}) => {
const newTagName = 'New Tag ' + Math.floor(1 + Math.random() * 10000);
await goToTagsOverview(page);
await page.getByRole('button', { name: 'Create Tag' }).click();
await page.getByPlaceholder('Tag Name').fill(newTagName);
await Promise.all([
page.getByRole('button', { name: 'Create Tag' }).nth(1).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/tags') &&
response.request().method() === 'POST' &&
response.status() === 201 &&
(await response.json()).data.id !== null &&
(await response.json()).data.name === newTagName
),
]);
await expect(page.getByTestId('tag_table')).toContainText(newTagName);
const moreButton = page.locator(
"[aria-label='Actions for Tag " + newTagName + "']"
);
moreButton.click();
const deleteButton = page.locator(
"[aria-label='Delete Tag " + newTagName + "']"
);
await Promise.all([
deleteButton.click(),
page.waitForResponse(
async (response) =>
response.url().includes('/tags') &&
response.request().method() === 'DELETE' &&
response.status() === 204
),
]);
await expect(page.getByTestId('tag_table')).not.toContainText(newTagName);
});

View File

@@ -428,12 +428,15 @@ test('test that deleting a time entry from the overview works', async ({
test('test that load more works when the end of page is reached', async ({
page,
}) => {
await goToTimeOverview(page);
await page.waitForResponse(
await Promise.all([
goToTimeOverview(page),
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.status() === 200
);
),
]);
await page.waitForTimeout(200);
await Promise.all([
page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)),

View File

@@ -7,8 +7,10 @@ import {
startOrStopTimerWithButton,
stoppedTimeEntryResponse,
} from './utils/currentTimeEntry';
import { Page } from '@playwright/test';
import { newTagResponse } from './utils/tags';
async function goToDashboard(page) {
async function goToDashboard(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
}
@@ -37,46 +39,16 @@ test('test that starting and stopping a timer with a description works', async (
.getByTestId('time_entry_description')
.fill('New Time Entry Description');
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 201 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end === null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description ===
'New Time Entry Description' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration === null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
newTimeEntryResponse(page, {
description: 'New Time Entry Description',
}),
startOrStopTimerWithButton(page),
]);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(1500);
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description ===
'New Time Entry Description' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
stoppedTimeEntryResponse(page, {
description: 'New Time Entry Description',
}),
await startOrStopTimerWithButton(page),
]);
@@ -89,23 +61,7 @@ test('test that starting and updating the description while running works', asyn
await goToDashboard(page);
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 201 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end === null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description === '' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration === null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
}),
newTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await assertThatTimerHasStarted(page);
@@ -115,47 +71,18 @@ test('test that starting and updating the description while running works', asyn
.fill('New Time Entry Description');
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end === null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description ===
'New Time Entry Description' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration === null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
newTimeEntryResponse(page, {
status: 200,
description: 'New Time Entry Description',
}),
page.getByTestId('time_entry_description').press('Tab'),
]);
await page.waitForTimeout(500);
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description ===
'New Time Entry Description' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
stoppedTimeEntryResponse(page, {
description: 'New Time Entry Description',
}),
await startOrStopTimerWithButton(page),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
});
@@ -165,23 +92,7 @@ test('test that starting and updating the time while running works', async ({
}) => {
await goToDashboard(page);
const [createResponse] = await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 201 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end === null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description === '' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration === null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
}),
newTimeEntryResponse(page),
await startOrStopTimerWithButton(page),
]);
await assertThatTimerHasStarted(page);
@@ -214,71 +125,26 @@ test('test that starting and updating the time while running works', async ({
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/);
await page.waitForTimeout(500);
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description === '' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
}),
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
});
test('test that entering a time starts the timer on blur', async ({ page }) => {
test('test that entering a human readable time starts the timer on blur', async ({
page,
}) => {
await goToDashboard(page);
await page.getByTestId('time_entry_time').fill('20min');
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 201 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end === null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description === '' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration === null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
}),
newTimeEntryResponse(page),
page.getByTestId('time_entry_time').press('Tab'),
]);
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20:/);
await assertThatTimerHasStarted(page);
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description === '' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
}),
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await page.locator(
@@ -286,50 +152,71 @@ test('test that entering a time starts the timer on blur', async ({ page }) => {
);
});
test('test that entering a number in the time range starts the timer on blur', async ({
page,
}) => {
await goToDashboard(page);
await page.getByTestId('time_entry_time').fill('5');
await Promise.all([
newTimeEntryResponse(page),
page.getByTestId('time_entry_time').press('Tab'),
]);
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:05:/);
await assertThatTimerHasStarted(page);
await Promise.all([
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
);
});
test('test that entering a value with the format hh:mm in the time range starts the timer on blur', async ({
page,
}) => {
await goToDashboard(page);
await page.getByTestId('time_entry_time').fill('12:30');
await Promise.all([
newTimeEntryResponse(page),
page.getByTestId('time_entry_time').press('Tab'),
]);
await expect(page.getByTestId('time_entry_time')).toHaveValue(/12:30:/);
await assertThatTimerHasStarted(page);
await Promise.all([
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
);
});
test('test that entering a random value in the time range does not start the timer on blur', async ({
page,
}) => {
await goToDashboard(page);
await page.getByTestId('time_entry_time').fill('asdasdasd');
await page.getByTestId('time_entry_time').press('Tab'),
await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
);
});
test('test that entering a time starts the timer on enter', async ({
page,
}) => {
await goToDashboard(page);
await page.getByTestId('time_entry_time').fill('20min');
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 201 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end === null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description === '' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration === null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
}),
newTimeEntryResponse(page),
page.getByTestId('time_entry_time').press('Enter'),
]);
await assertThatTimerHasStarted(page);
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description === '' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
}),
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
@@ -342,14 +229,7 @@ test('test that adding a new tag works', async ({ page }) => {
await page.getByTestId('tag_dropdown_search').fill(newTagName);
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 201 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.name === newTagName
);
}),
newTagResponse(page, { name: newTagName }),
page.getByTestId('tag_dropdown_search').press('Enter'),
]);
@@ -370,56 +250,18 @@ test('test that adding a new tag when the timer is running', async ({
await page.getByTestId('tag_dropdown').click();
await page.getByTestId('tag_dropdown_search').fill(newTagName);
const [tagCreateResponse] = await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 201 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.name === newTagName
);
}),
newTagResponse(page, { name: newTagName }),
page.getByTestId('tag_dropdown_search').press('Enter'),
]);
await page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end === null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description === '' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration === null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([(await tagCreateResponse.json()).data.id])
);
});
const tagId = (await tagCreateResponse.json()).data.id;
await newTimeEntryResponse(page, { status: 200, tags: [tagId] });
await expect(page.getByTestId('tag_dropdown_search')).toHaveValue('');
await expect(page.getByRole('option', { name: newTagName })).toBeVisible();
await page.getByTestId('tag_dropdown_search').press('Escape');
await page.waitForTimeout(1000);
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description === '' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([(await tagCreateResponse.json()).data.id])
);
}),
stoppedTimeEntryResponse(page, { tags: [tagId] }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);

View File

@@ -12,22 +12,25 @@ export async function assertThatTimerHasStarted(page: Page) {
);
}
export function newTimeEntryResponse(page: Page) {
export function newTimeEntryResponse(
page: Page,
{ description = '', status = 201, tags = [] } = {}
) {
return page.waitForResponse(async (response) => {
return (
response.status() === 201 &&
response.status() === status &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end === null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description === '' &&
(await response.json()).data.description === description &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration === null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
JSON.stringify(tags)
);
});
}
@@ -40,7 +43,10 @@ export async function assertThatTimerIsStopped(page: Page) {
).toHaveClass(/bg-accent-300\/50/);
}
export async function stoppedTimeEntryResponse(page: Page) {
export async function stoppedTimeEntryResponse(
page: Page,
{ description = '', tags = [] } = {}
) {
return page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
@@ -50,12 +56,12 @@ export async function stoppedTimeEntryResponse(page: Page) {
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description === '' &&
(await response.json()).data.description === description &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
JSON.stringify(tags)
);
});
}

12
e2e/utils/tags.ts Normal file
View File

@@ -0,0 +1,12 @@
import { Page } from '@playwright/test';
export function newTagResponse(page: Page, { name = '' } = {}) {
return page.waitForResponse(async (response) => {
return (
response.status() === 201 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.name === name
);
});
}

View File

@@ -20,11 +20,23 @@ const MemberResource = z
email: z.string(),
role: z.string(),
is_placeholder: z.boolean(),
billable_rate: z.union([z.number(), z.null()]),
})
.passthrough();
const MemberCollection = z.array(MemberResource);
const OrganizationResource = z
.object({ id: z.string(), name: z.string(), is_personal: z.string() })
.object({
id: z.string(),
name: z.string(),
is_personal: z.string(),
billable_rate: z.union([z.number(), z.null()]),
})
.passthrough();
const v1_organizations_update_Body = z
.object({
name: z.string(),
billable_rate: z.union([z.number(), z.null()]).optional(),
})
.passthrough();
const ProjectResource = z
.object({
@@ -32,16 +44,35 @@ const ProjectResource = z
name: z.string(),
color: z.string(),
client_id: z.union([z.string(), z.null()]),
billable_rate: z.union([z.number(), z.null()]),
})
.passthrough();
const ProjectCollection = z.array(ProjectResource);
const createProject_Body = z
.object({
name: z.string(),
color: z.string(),
billable_rate: z.union([z.number(), z.null()]).optional(),
client_id: z.union([z.string(), z.null()]).optional(),
})
.passthrough();
const ProjectMemberResource = z
.object({
id: z.string(),
billable_rate: z.union([z.number(), z.null()]),
user_id: z.string(),
project_id: z.string(),
})
.passthrough();
const createProjectMember_Body = z
.object({
user_id: z.string().uuid(),
billable_rate: z.union([z.number(), z.null()]).optional(),
})
.passthrough();
const updateProjectMember_Body = z
.object({ billable_rate: z.union([z.number(), z.null()]) })
.partial()
.passthrough();
const TagResource = z
.object({
id: z.string(),
@@ -51,6 +82,18 @@ const TagResource = z
})
.passthrough();
const TagCollection = z.array(TagResource);
const TaskResource = z
.object({
id: z.string(),
name: z.string(),
project_id: z.string(),
created_at: z.string(),
updated_at: z.string(),
})
.passthrough();
const createTask_Body = z
.object({ name: z.string(), project_id: z.string() })
.passthrough();
const before = z.union([z.string(), z.null()]).optional();
const TimeEntryResource = z
.object({
@@ -98,11 +141,16 @@ export const schemas = {
MemberResource,
MemberCollection,
OrganizationResource,
v1_organizations_update_Body,
ProjectResource,
ProjectCollection,
createProject_Body,
ProjectMemberResource,
createProjectMember_Body,
updateProjectMember_Body,
TagResource,
TagCollection,
TaskResource,
createTask_Body,
before,
TimeEntryResource,
TimeEntryCollection,
@@ -146,7 +194,7 @@ const endpoints = makeApi([
{
name: 'body',
type: 'Body',
schema: z.object({ name: z.string() }).passthrough(),
schema: v1_organizations_update_Body,
},
{
name: 'organization',
@@ -181,7 +229,7 @@ const endpoints = makeApi([
{
method: 'get',
path: '/v1/organizations/:organization/clients',
alias: 'v1.clients.index',
alias: 'getClients',
requestFormat: 'json',
parameters: [
{
@@ -207,7 +255,7 @@ const endpoints = makeApi([
{
method: 'post',
path: '/v1/organizations/:organization/clients',
alias: 'v1.clients.store',
alias: 'createClient',
requestFormat: 'json',
parameters: [
{
@@ -248,7 +296,7 @@ const endpoints = makeApi([
{
method: 'put',
path: '/v1/organizations/:organization/clients/:client',
alias: 'v1.clients.update',
alias: 'updateClient',
requestFormat: 'json',
parameters: [
{
@@ -294,7 +342,7 @@ const endpoints = makeApi([
{
method: 'delete',
path: '/v1/organizations/:organization/clients/:client',
alias: 'v1.clients.destroy',
alias: 'deleteClient',
requestFormat: 'json',
parameters: [
{
@@ -400,7 +448,7 @@ const endpoints = makeApi([
{
method: 'get',
path: '/v1/organizations/:organization/members',
alias: 'v1.users.index',
alias: 'getMembers',
requestFormat: 'json',
parameters: [
{
@@ -436,7 +484,7 @@ const endpoints = makeApi([
{
method: 'post',
path: '/v1/organizations/:organization/members/:user/invite-placeholder',
alias: 'v1.users.invite-placeholder',
alias: 'invitePlaceholder',
requestFormat: 'json',
parameters: [
{
@@ -480,6 +528,88 @@ const endpoints = makeApi([
},
],
},
{
method: 'put',
path: '/v1/organizations/:organization/project-members/:projectMember',
alias: 'updateProjectMember',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: updateProjectMember_Body,
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
{
name: 'projectMember',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.object({ data: ProjectMemberResource }).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: 'delete',
path: '/v1/organizations/:organization/project-members/:projectMember',
alias: 'deleteProjectMember',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({}).partial().passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
{
name: 'projectMember',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.null(),
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(),
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/projects',
@@ -492,7 +622,39 @@ const endpoints = makeApi([
schema: z.string().uuid(),
},
],
response: z.object({ data: ProjectCollection }).passthrough(),
response: z
.object({
data: z.array(ProjectResource),
links: z
.object({
first: z.union([z.string(), z.null()]),
last: z.union([z.string(), z.null()]),
prev: z.union([z.string(), z.null()]),
next: z.union([z.string(), z.null()]),
})
.passthrough(),
meta: z
.object({
current_page: z.number().int(),
from: z.union([z.number(), z.null()]),
last_page: z.number().int(),
links: z.array(
z
.object({
url: z.union([z.string(), z.null()]),
label: z.string(),
active: z.boolean(),
})
.passthrough()
),
path: z.union([z.string(), z.null()]),
per_page: z.number().int(),
to: z.union([z.number(), z.null()]),
total: z.number().int(),
})
.passthrough(),
})
.passthrough(),
errors: [
{
status: 403,
@@ -660,6 +822,115 @@ const endpoints = makeApi([
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/projects/:project/project-members',
alias: 'getProjectMembers',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
{
name: 'project',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z
.object({
data: z.array(ProjectMemberResource),
links: z
.object({
first: z.union([z.string(), z.null()]),
last: z.union([z.string(), z.null()]),
prev: z.union([z.string(), z.null()]),
next: z.union([z.string(), z.null()]),
})
.passthrough(),
meta: z
.object({
current_page: z.number().int(),
from: z.union([z.number(), z.null()]),
last_page: z.number().int(),
links: z.array(
z
.object({
url: z.union([z.string(), z.null()]),
label: z.string(),
active: z.boolean(),
})
.passthrough()
),
path: z.union([z.string(), z.null()]),
per_page: z.number().int(),
to: z.union([z.number(), z.null()]),
total: 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(),
},
],
},
{
method: 'post',
path: '/v1/organizations/:organization/projects/:project/project-members',
alias: 'createProjectMember',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: createProjectMember_Body,
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
{
name: 'project',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.object({ data: ProjectMemberResource }).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',
path: '/v1/organizations/:organization/tags',
@@ -809,6 +1080,202 @@ const endpoints = makeApi([
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/tasks',
alias: 'getTasks',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
{
name: 'project_id',
type: 'Query',
schema: z.string().uuid().optional(),
},
],
response: z
.object({
data: z.array(TaskResource),
links: z
.object({
first: z.union([z.string(), z.null()]),
last: z.union([z.string(), z.null()]),
prev: z.union([z.string(), z.null()]),
next: z.union([z.string(), z.null()]),
})
.passthrough(),
meta: z
.object({
current_page: z.number().int(),
from: z.union([z.number(), z.null()]),
last_page: z.number().int(),
links: z.array(
z
.object({
url: z.union([z.string(), z.null()]),
label: z.string(),
active: z.boolean(),
})
.passthrough()
),
path: z.union([z.string(), z.null()]),
per_page: z.number().int(),
to: z.union([z.number(), z.null()]),
total: 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: 'post',
path: '/v1/organizations/:organization/tasks',
alias: 'createTask',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: createTask_Body,
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.object({ data: TaskResource }).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',
path: '/v1/organizations/:organization/tasks/:task',
alias: 'updateTask',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({ name: z.string() }).passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
{
name: 'task',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.object({ data: TaskResource }).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: 'delete',
path: '/v1/organizations/:organization/tasks/:task',
alias: 'deleteTask',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({}).partial().passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
{
name: 'task',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.null(),
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(),
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/time-entries',
@@ -848,7 +1315,7 @@ const endpoints = makeApi([
{
name: 'only_full_dates',
type: 'Query',
schema: z.boolean().optional(),
schema: z.enum(['true', 'false']).optional(),
},
],
response: z.object({ data: TimeEntryCollection }).passthrough(),
@@ -951,6 +1418,17 @@ const endpoints = makeApi([
],
response: z.object({ data: TimeEntryResource }).passthrough(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
status: 403,
description: `Authorization error`,

2
package-lock.json generated
View File

@@ -5,6 +5,8 @@
"packages": {
"": {
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/vue": "^1.0.6",
"@heroicons/vue": "^2.1.1",
"@rushstack/eslint-patch": "^1.7.0",
"@tailwindcss/container-queries": "^0.1.1",

View File

@@ -8,7 +8,7 @@
"lint:fix": "eslint --fix --ext .js,.vue,.ts --ignore-path .gitignore .",
"type-check": "vue-tsc --noEmit",
"test:e2e": "rm -rf test-results/.auth && npx playwright test",
"generate:zod": "npx openapi-zod-client http://localhost:80/docs/api.json --output openapi.json.client.ts --base-url /api"
"zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output openapi.json.client.ts --base-url /api"
},
"devDependencies": {
"@inertiajs/vue3": "^1.0.0",
@@ -32,6 +32,8 @@
"vue-tsc": "^1.8.27"
},
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/vue": "^1.0.6",
"@heroicons/vue": "^2.1.1",
"@rushstack/eslint-patch": "^1.7.0",
"@tailwindcss/container-queries": "^0.1.1",

View File

@@ -6,6 +6,17 @@
:root{
--theme-color-icon-default: #42466C;
--theme-color-card-background: #13152B;
--theme-color-card-background-active: #1C1E34;
--theme-color-card-border: #242940;
--theme-color-card-border-active: #2A3461;
--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-card-border);
--theme-color-row-heading-background: var(--theme-color-card-background);
--theme-color-row-border: var(--theme-color-card-border);
--theme-color-row-heading-border: var(--theme-color-card-border);
}
*{

BIN
resources/js/Components/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -4,7 +4,6 @@ import { computed } from 'vue';
const props = withDefaults(
defineProps<{
name: string;
size: 'base' | 'large';
tag: string;
class?: string;

View File

@@ -21,7 +21,7 @@ const iconColorClasses = computed(() => {
:class="
twMerge(
iconColorClasses,
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-seperator hover:bg-card-background-seperator rounded-full w-11 h-11 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 w-11 h-11 flex items-center justify-center'
)
">
<svg

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import TextInput from '@/Components/TextInput.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import { ref } from 'vue';
import type { CreateClientBody } from '@/utils/api';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import { useClientsStore } from '@/utils/useClients';
const { createClient } = useClientsStore();
const show = defineModel('show', { default: false });
const saving = ref(false);
const client = ref<CreateClientBody>({
name: '',
});
async function submit() {
await createClient(client.value);
show.value = false;
}
const clientNameInput = ref<HTMLInputElement | null>(null);
useFocus(clientNameInput, { initialValue: true });
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Create Client </span>
</div>
</template>
<template #content>
<div class="flex items-center space-x-4">
<div class="col-span-6 sm:col-span-4 flex-1">
<TextInput
id="clientName"
ref="clientNameInput"
v-model="client.name"
type="text"
placeholder="Client Name"
@keydown.enter="submit"
class="mt-1 block w-full"
required
autocomplete="clientName" />
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit">
Create Client
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -0,0 +1,179 @@
<script setup lang="ts">
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/Components/Dropdown.vue';
import { type Component, computed, nextTick, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useClientsStore } from '@/utils/useClients';
import ClientDropdownItem from '@/Components/Common/Client/ClientDropdownItem.vue';
const clientsStore = useClientsStore();
const { clients } = storeToRefs(clientsStore);
const model = defineModel<string | null>({
default: null,
});
const searchInput = ref<HTMLInputElement | null>(null);
const open = ref(false);
const dropdownViewport = ref<Component | null>(null);
const searchValue = ref('');
function isClientSelected(id: string) {
return model.value === id;
}
watch(open, (isOpen) => {
if (isOpen) {
nextTick(() => {
searchInput.value?.focus();
});
}
});
const filteredClients = computed(() => {
return clients.value.filter((client) => {
return client.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
});
});
async function addClientIfNoneExists() {
if (searchValue.value.length > 0 && filteredClients.value.length === 0) {
const newClient = await clientsStore.createClient({
name: searchValue.value,
});
if (newClient) {
model.value = newClient.id;
searchValue.value = '';
}
} else {
if (highlightedItemId.value) {
model.value = highlightedItemId.value;
}
}
}
watch(filteredClients, () => {
if (filteredClients.value.length > 0) {
highlightedItemId.value = filteredClients.value[0].id;
}
});
function updateSearchValue(event: Event) {
const newInput = (event.target as HTMLInputElement).value;
if (newInput === ' ') {
searchValue.value = '';
const highlightedClientId = highlightedItemId.value;
if (highlightedClientId) {
const highlightedClient = clients.value.find(
(client) => client.id === highlightedClientId
);
if (highlightedClient) {
model.value = highlightedClient.id;
}
}
} else {
searchValue.value = newInput;
}
}
const emit = defineEmits(['update:modelValue', 'changed']);
function updateClient(newValue: string) {
model.value = newValue;
nextTick(() => {
emit('changed');
});
}
function moveHighlightUp() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredClients.value.indexOf(
highlightedItem.value
);
if (currentHightlightedIndex === 0) {
highlightedItemId.value =
filteredClients.value[filteredClients.value.length - 1].id;
} else {
highlightedItemId.value =
filteredClients.value[currentHightlightedIndex - 1].id;
}
}
}
function moveHighlightDown() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredClients.value.indexOf(
highlightedItem.value
);
if (currentHightlightedIndex === filteredClients.value.length - 1) {
highlightedItemId.value = filteredClients.value[0].id;
} else {
highlightedItemId.value =
filteredClients.value[currentHightlightedIndex + 1].id;
}
}
}
const highlightedItemId = ref<string | null>(null);
const highlightedItem = computed(() => {
return clients.value.find(
(client) => client.id === highlightedItemId.value
);
});
</script>
<template>
<Dropdown width="120" v-model="open" :closeOnContentClick="true">
<template #trigger>
<slot name="trigger"></slot>
</template>
<template #content>
<input
:value="searchValue"
@input="updateSearchValue"
@keydown.enter="addClientIfNoneExists"
data-testid="client_dropdown_search"
@keydown.up.prevent="moveHighlightUp"
@keydown.down.prevent="moveHighlightDown"
ref="searchInput"
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a client..." />
<div ref="dropdownViewport" class="w-60">
<div
v-if="
searchValue.length > 0 && filteredClients.length === 0
"
class="bg-card-background-active">
<div
class="flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t rounded-b-lg border-card-background-separator">
<PlusCircleIcon
class="w-5 flex-shrink-0"></PlusCircleIcon>
<span>Add "{{ searchValue }}" as a new Client</span>
</div>
</div>
<div v-else></div>
<div
v-for="client in filteredClients"
:key="client.id"
role="option"
:value="client.id"
:class="{
'bg-card-background-active':
client.id === highlightedItemId,
}"
data-testid="client_dropdown_entries"
:data-client-id="client.id">
<ClientDropdownItem
:selected="isClientSelected(client.id)"
@click="updateClient(client.id)"
:name="client.name"></ClientDropdownItem>
</div>
</div>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import { computed } from 'vue';
import { twMerge } from 'tailwind-merge';
const props = defineProps<{
name: string;
selected: boolean;
}>();
const iconClasses = computed(() => {
if (props.selected) {
return 'text-accent-200';
} else {
return 'text-card-border';
}
});
</script>
<template>
<div
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<CheckCircleIcon :class="twMerge(iconClasses, 'w-5')"></CheckCircleIcon>
<span>{{ name }}</span>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import Dropdown from '@/Components/Dropdown.vue';
import { TrashIcon } from '@heroicons/vue/20/solid';
import type { Client } from '@/utils/api';
const emit = defineEmits<{
delete: [];
}>();
const props = defineProps<{
client: Client;
}>();
</script>
<template>
<Dropdown>
<template #trigger>
<svg
data-testid="client_actions"
:aria-label="'Actions for Client ' + props.client.name"
class="h-10 w-10 p-2 rounded-full hover:bg-card-background opacity-20 group-hover:opacity-100 transition"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
</template>
<template #content>
<button
@click="emit('delete')"
:aria-label="'Delete Client ' + props.client.name"
data-testid="client_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import SecondaryButton from '@/Components/SecondaryButton.vue';
import { UserCircleIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useClientsStore } from '@/utils/useClients';
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
const { clients } = storeToRefs(useClientsStore());
const createClient = ref(false);
</script>
<template>
<ClientCreateModal v-model:show="createClient"></ClientCreateModal>
<div class="flow-root">
<div class="inline-block min-w-full align-middle">
<div
data-testid="client_table"
class="grid min-w-full divide-y divide-row-separator border-b border-row-separator"
style="grid-template-columns: 1fr 150px 80px">
<ClientTableHeading></ClientTableHeading>
<div
class="col-span-2 py-24 text-center"
v-if="clients.length === 0">
<UserCircleIcon
class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
<h3 class="text-white font-semibold">No clients found</h3>
<p class="pb-5">Create your first client now!</p>
<SecondaryButton
@click="createClient = true"
:icon="PlusIcon"
>Create your First Client
</SecondaryButton>
</div>
<template v-for="client in clients" :key="client.id">
<ClientTableRow :client="client"></ClientTableRow>
</template>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts"></script>
<template>
<div
class="py-1.5 pr-3 text-left text-sm font-semibold text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 bg-row-heading-background border-t border-row-heading-border">
Name
</div>
<div
class="px-3 py-1.5 text-left text-sm font-semibold text-white bg-row-heading-background">
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>
</template>
<style scoped></style>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import type { Client } from '@/utils/api';
import { computed } from 'vue';
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
import ClientMoreOptionsDropdown from '@/Components/Common/Client/ClientMoreOptionsDropdown.vue';
import { useProjectsStore } from '@/utils/useProjects';
const { projects } = storeToRefs(useProjectsStore());
const props = defineProps<{
client: Client;
}>();
function deleteClient() {
useClientsStore().deleteClient(props.client.id);
}
const projectCount = computed(() => {
return projects.value.filter(
(projects) => projects.client_id === props.client.id
).length;
});
</script>
<template>
<div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>
{{ client.name }}
</span>
<span class="text-muted"> {{ projectCount }} Projects </span>
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-muted 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 sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<ClientMoreOptionsDropdown
:client="client"
@delete="deleteClient"></ClientMoreOptionsDropdown>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,148 @@
<script setup lang="ts">
import TextInput from '@/Components/TextInput.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import { ref } from 'vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import InputLabel from '@/Components/InputLabel.vue';
import InputError from '@/Components/InputError.vue';
import type { Role } from '@/types/jetstream';
import { useForm } from '@inertiajs/vue3';
import { getCurrentOrganizationId } from '@/utils/useUser';
const show = defineModel('show', { default: false });
const saving = ref(false);
defineProps<{
availableRoles: Role[];
}>();
const addTeamMemberForm = useForm({
email: '',
role: null as string | null,
});
async function submit() {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
addTeamMemberForm.post(route('team-members.store', organizationId), {
errorBag: 'addTeamMember',
preserveScroll: true,
onSuccess: () => {
addTeamMemberForm.reset();
show.value = false;
},
});
}
}
const clientNameInput = ref<HTMLInputElement | null>(null);
useFocus(clientNameInput, { initialValue: true });
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Invite Member </span>
</div>
</template>
<template #content>
<div class="space-y-4">
<div class="col-span-6 sm:col-span-4 flex-1">
<InputLabel for="roles" value="Email" />
<TextInput
id="email"
name="email"
ref="memberEmailInput"
v-model="addTeamMemberForm.email"
type="text"
placeholder="Member Email"
@keydown.enter="submit"
class="mt-1 block w-full"
required
autocomplete="memberName" />
</div>
<div v-if="availableRoles.length > 0">
<InputLabel for="roles" value="Role" />
<InputError
:message="addTeamMemberForm.errors.role"
class="mt-2" />
<div
class="relative z-0 mt-1 border border-card-border rounded-lg cursor-pointer">
<button
v-for="(role, i) in availableRoles"
:key="role.key"
type="button"
class="relative px-4 py-3 inline-flex w-full rounded-lg focus:z-10 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500"
:class="{
'border-t border-card-border focus:border-none rounded-t-none':
i > 0,
'rounded-b-none':
i != Object.keys(availableRoles).length - 1,
}"
@click="addTeamMemberForm.role = role.key">
<div
:class="{
'opacity-50':
addTeamMemberForm.role &&
addTeamMemberForm.role != role.key,
}">
<!-- Role Name -->
<div class="flex items-center">
<div
class="text-sm text-white"
:class="{
'font-semibold':
addTeamMemberForm.role ==
role.key,
}">
{{ role.name }}
</div>
<svg
v-if="
addTeamMemberForm.role == role.key
"
class="ms-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>
<!-- Role Description -->
<div class="mt-2 text-xs text-muted text-start">
{{ role.description }}
</div>
</div>
</button>
</div>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit">
Invite Member
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import Dropdown from '@/Components/Dropdown.vue';
import { TrashIcon } from '@heroicons/vue/20/solid';
import type { Member } from '@/utils/api';
const emit = defineEmits<{
delete: [];
}>();
const props = defineProps<{
member: Member;
}>();
</script>
<template>
<Dropdown>
<template #trigger>
<svg
data-testid="client_actions"
:aria-label="'Actions for Member ' + props.member.name"
class="h-10 w-10 p-2 rounded-full hover:bg-card-background opacity-20 group-hover:opacity-100 transition"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
</template>
<template #content>
<button
@click="emit('delete')"
:aria-label="'Delete Member ' + props.member.name"
data-testid="client_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { ref } from 'vue';
import { storeToRefs } from 'pinia';
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
import MemberTableHeading from '@/Components/Common/Member/MemberTableHeading.vue';
import MemberTableRow from '@/Components/Common/Member/MemberTableRow.vue';
import { useMembersStore } from '@/utils/useMembers';
const { members } = storeToRefs(useMembersStore());
const createClient = ref(false);
</script>
<template>
<ClientCreateModal v-model:show="createClient"></ClientCreateModal>
<div class="flow-root">
<div class="inline-block min-w-full align-middle">
<div
data-testid="client_table"
class="grid min-w-full divide-y divide-row-separator border-b border-row-separator"
style="grid-template-columns: 1fr 1fr 180px 180px 150px 80px">
<MemberTableHeading></MemberTableHeading>
<template v-for="member in members" :key="member.id">
<MemberTableRow :member="member"></MemberTableRow>
</template>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts"></script>
<template>
<div
class="py-1.5 pr-3 text-left text-sm font-semibold text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 bg-row-heading-background border-t border-row-heading-border">
Name
</div>
<div
class="px-3 py-1.5 text-left text-sm font-semibold text-white bg-row-heading-background">
Email
</div>
<div
class="px-3 py-1.5 text-left text-sm font-semibold text-white bg-row-heading-background">
Role
</div>
<div
class="px-3 py-1.5 text-left text-sm font-semibold text-white bg-row-heading-background">
Billable Rate
</div>
<div
class="px-3 py-1.5 text-left text-sm font-semibold text-white bg-row-heading-background">
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>
</template>
<style scoped></style>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { Member } from '@/utils/api';
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/20/solid';
import { useClientsStore } from '@/utils/useClients';
import MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue';
const props = defineProps<{
member: Member;
}>();
function removeMember() {
useClientsStore().deleteClient(props.member.id);
}
function capitalizeFirstLetter(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
</script>
<template>
<div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>
{{ member.name }}
</span>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
{{ member.email }}
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
{{ capitalizeFirstLetter(member.role) }}
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
{{ member.billable_rate ?? '--' }}
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-muted flex space-x-1 items-center font-medium">
<CheckCircleIcon
v-if="member.is_placeholder === false"
class="w-5"></CheckCircleIcon>
<span v-if="member.is_placeholder === false">Active</span>
<UserCircleIcon
v-if="member.is_placeholder === true"
class="w-5"></UserCircleIcon>
<span v-if="member.is_placeholder === true">Inactive</span>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<MemberMoreOptionsDropdown
:member="member"
@delete="removeMember"></MemberMoreOptionsDropdown>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import TextInput from '@/Components/TextInput.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import { computed, ref } from 'vue';
import type { CreateProjectBody } from '@/utils/api';
import { getRandomColor } from '@/utils/color';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import { useProjectsStore } from '@/utils/useProjects';
import { useFocus } from '@vueuse/core';
import ClientDropdown from '@/Components/Common/Client/ClientDropdown.vue';
import { twMerge } from 'tailwind-merge';
import Badge from '@/Components/Common/Badge.vue';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
const { createProject } = useProjectsStore();
const { clients } = storeToRefs(useClientsStore());
const show = defineModel('show', { default: false });
const saving = ref(false);
const project = ref<CreateProjectBody>({
name: '',
color: getRandomColor(),
client_id: null,
});
async function submit() {
await createProject(project.value);
show.value = false;
}
const projectNameInput = ref<HTMLInputElement | null>(null);
useFocus(projectNameInput, { initialValue: true });
const currentClientName = computed(() => {
if (project.value.client_id) {
return clients.value.find(
(client) => client.id === project.value.client_id
)?.name;
}
return 'No Client';
});
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Create Project </span>
</div>
</template>
<template #content>
<div class="flex items-center space-x-4">
<div class="px-3">
<div
:style="{
backgroundColor: project.color,
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(5px + var(--tw-ring-offset-width)) ${project.color}30`,
}"
class="w-4 h-4 rounded-full"></div>
</div>
<div class="col-span-6 sm:col-span-4 flex-1">
<TextInput
id="projectName"
ref="projectNameInput"
v-model="project.name"
type="text"
placeholder="Project Name"
class="mt-1 block w-full"
required
autocomplete="projectName" />
</div>
<div class="col-span-6 sm:col-span-4">
<ClientDropdown v-model="project.client_id">
<template #trigger>
<Badge size="large">
<div
:class="
twMerge('inline-block rounded-full')
"></div>
<span>
{{ currentClientName }}
</span>
</Badge>
</template>
</ClientDropdown>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit">
Create Project
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -116,7 +116,7 @@ function updateValue(project: Project) {
:border
tag="button"
:name="selectedProjectName"
class="focus:border-input-border-active focus:outline-0 focus:bg-card-background-seperator hover:bg-card-background-seperator"></ProjectBadge>
class="focus:border-input-border-active focus:outline-0 focus:bg-card-background-separator hover:bg-card-background-separator"></ProjectBadge>
</template>
<template #content>
@@ -131,7 +131,7 @@ function updateValue(project: Project) {
<ComboboxInput
@keydown.enter="addProjectIfNoneExists"
ref="searchInput"
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-seperator focus:border-card-background-seperator w-full"
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a project..." />
</ComboboxAnchor>
<ComboboxContent>
@@ -168,7 +168,7 @@ function updateValue(project: Project) {
"
class="bg-card-background-active">
<div
class="flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t rounded-b-lg border-card-background-seperator">
class="flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t rounded-b-lg border-card-background-separator">
<PlusCircleIcon
class="w-5 flex-shrink-0"></PlusCircleIcon>
<span

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import Dropdown from '@/Components/Dropdown.vue';
import { TrashIcon } from '@heroicons/vue/20/solid';
import type { Project } from '@/utils/api';
const emit = defineEmits<{
delete: [];
}>();
const props = defineProps<{
project: Project;
}>();
</script>
<template>
<Dropdown>
<template #trigger>
<svg
data-testid="project_actions"
:aria-label="'Actions for Project ' + props.project.name"
class="h-10 w-10 p-2 rounded-full hover:bg-card-background opacity-20 group-hover:opacity-100 transition"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
</template>
<template #content>
<button
@click="emit('delete')"
:aria-label="'Delete Project ' + props.project.name"
data-testid="project_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { useProjectsStore } from '@/utils/useProjects';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import { FolderPlusIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { ref } from 'vue';
import ProjectCreateModal from '@/Components/Common/Project/ProjectCreateModal.vue';
import { storeToRefs } from 'pinia';
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
const { projects } = storeToRefs(useProjectsStore());
const createProject = ref(false);
</script>
<template>
<ProjectCreateModal v-model:show="createProject"></ProjectCreateModal>
<div class="flow-root">
<div class="inline-block min-w-full align-middle">
<div
data-testid="project_table"
class="grid min-w-full divide-y divide-row-separator border-b border-row-separator"
style="grid-template-columns: 1fr 150px 150px 150px 80px">
<ProjectTableHeading></ProjectTableHeading>
<div
class="col-span-5 py-24 text-center"
v-if="projects.length === 0">
<FolderPlusIcon
class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-white font-semibold">No projects found</h3>
<p class="pb-5">Create your first project now!</p>
<SecondaryButton
@click="createProject = true"
:icon="PlusIcon"
>Create your First Project
</SecondaryButton>
</div>
<template v-for="project in projects" :key="project.id">
<ProjectTableRow :project="project"></ProjectTableRow>
</template>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts"></script>
<template>
<div
class="py-1.5 pr-3 text-left text-sm font-semibold text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 bg-row-heading-background border-t border-row-heading-border">
Name
</div>
<div
class="px-3 py-1.5 text-left text-sm font-semibold text-white bg-row-heading-background">
Client
</div>
<div
class="px-3 py-1.5 text-left text-sm font-semibold text-white bg-row-heading-background">
Team
</div>
<div
class="px-3 py-1.5 text-left text-sm font-semibold text-white bg-row-heading-background">
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>
</template>
<style scoped></style>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
import type { Project } from '@/utils/api';
import { computed } from 'vue';
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
import { useTasksStore } from '@/utils/useTasks';
import { useProjectsStore } from '@/utils/useProjects';
const { clients } = storeToRefs(useClientsStore());
const { tasks } = storeToRefs(useTasksStore());
const props = defineProps<{
project: Project;
}>();
const client = computed(() => {
return clients.value.find(
(client) => client.id === props.project.client_id
);
});
const projectTasksCount = computed(() => {
return tasks.value.filter((task) => task.project_id === props.project.id)
.length;
});
function deleteProject() {
useProjectsStore().deleteProject(props.project.id);
}
</script>
<template>
<div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<div
:style="{
backgroundColor: project.color,
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
}"
class="w-3 h-3 rounded-full"></div>
<span>
{{ project.name }}
</span>
<span class="text-muted"> {{ projectTasksCount }} Tasks </span>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
<div v-if="project.client_id">
{{ client?.name }}
</div>
<div v-else>mem No client</div>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
<div class="isolate flex -space-x-1 opacity-50">
<img
class="relative z-30 inline-block h-6 w-6 rounded-full ring-4 ring-card-background"
src="https://images.unsplash.com/photo-1491528323818-fdd1faba62cc?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
alt="" />
<img
class="relative z-20 inline-block h-6 w-6 rounded-full ring-4 ring-card-background"
src="https://images.unsplash.com/photo-1550525811-e5869dd03032?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
alt="" />
<img
class="relative z-10 inline-block h-6 w-6 rounded-full ring-4 ring-card-background"
src="https://images.unsplash.com/photo-1500648767791-00dcc994a43e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2.25&w=256&h=256&q=80"
alt="" />
<img
class="relative z-0 inline-block h-6 w-6 rounded-full ring-4 ring-card-background"
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
alt="" />
</div>
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-muted 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 sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<ProjectMoreOptionsDropdown
:project="project"
@delete="deleteProject"></ProjectMoreOptionsDropdown>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts"></script>
<template>
<div class="flex items-center space-x-1">
<slot></slot>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
import { computed } from 'vue';
const props = defineProps<{
active?: boolean;
}>();
const activeClass = computed(() => {
if (props.active) {
return 'bg-tab-background hover:bg-tab-background-active border border-tab-border text-white font-semibold';
}
return '';
});
</script>
<template>
<button
:class="
twMerge(
'rounded-md transition px-3 py-1.5 text-sm font-medium hover:text-white',
activeClass
)
">
<slot></slot>
</button>
</template>
<style scoped></style>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import TextInput from '@/Components/TextInput.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import { ref } from 'vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import type { CreateTagBody } from '@/utils/api';
import { useTagsStore } from '@/utils/useTags';
const show = defineModel('show', { default: false });
const saving = ref(false);
const { createTag } = useTagsStore();
const tag = ref<CreateTagBody>({
name: '',
});
async function submit() {
await createTag(tag.value.name);
show.value = false;
}
const tagNameInput = ref<HTMLInputElement | null>(null);
useFocus(tagNameInput, { initialValue: true });
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Create Tags </span>
</div>
</template>
<template #content>
<div class="flex items-center space-x-4">
<div class="col-span-6 sm:col-span-4 flex-1">
<TextInput
id="tagName"
ref="tagNameInput"
v-model="tag.name"
type="text"
placeholder="Tag Name"
class="mt-1 block w-full"
required
autocomplete="tagName" />
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit">
Create Tag
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -102,7 +102,9 @@ function toggleTag(newValue: string) {
} else {
model.value.push(newValue);
}
nextTick(() => {
emit('changed');
});
}
function moveHighlightUp() {
@@ -155,14 +157,14 @@ const highlightedItem = computed(() => {
@keydown.up.prevent="moveHighlightUp"
@keydown.down.prevent="moveHighlightDown"
ref="searchInput"
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-seperator focus:border-card-background-seperator w-full"
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a tag..." />
<div ref="dropdownViewport" class="w-60">
<div
v-if="searchValue.length > 0 && filteredTags.length === 0"
class="bg-card-background-active">
<div
class="flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t rounded-b-lg border-card-background-seperator">
class="flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t rounded-b-lg border-card-background-separator">
<PlusCircleIcon
class="w-5 flex-shrink-0"></PlusCircleIcon>
<span>Add "{{ searchValue }}" as a new Tag</span>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import Dropdown from '@/Components/Dropdown.vue';
import { TrashIcon } from '@heroicons/vue/20/solid';
import type { Tag } from '@/utils/api';
const emit = defineEmits<{
delete: [];
}>();
const props = defineProps<{
tag: Tag;
}>();
</script>
<template>
<Dropdown>
<template #trigger>
<svg
data-testid="tag_actions"
:aria-label="'Actions for Tag ' + props.tag.name"
class="h-10 w-10 p-2 rounded-full hover:bg-card-background opacity-20 group-hover:opacity-100 transition"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
</template>
<template #content>
<button
@click="emit('delete')"
:aria-label="'Delete Tag ' + props.tag.name"
data-testid="tag_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import SecondaryButton from '@/Components/SecondaryButton.vue';
import { FolderPlusIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useTagsStore } from '@/utils/useTags';
import TagTableRow from '@/Components/Common/Tag/TagTableRow.vue';
import TagCreateModal from '@/Components/Common/Tag/TagCreateModal.vue';
import TagTableHeading from '@/Components/Common/Tag/TagTableHeading.vue';
const { tags } = storeToRefs(useTagsStore());
const createTag = ref(false);
</script>
<template>
<TagCreateModal v-model:show="createTag"></TagCreateModal>
<div class="flow-root">
<div class="inline-block min-w-full align-middle">
<div
data-testid="tag_table"
class="grid min-w-full divide-y divide-row-separator border-b border-row-separator"
style="grid-template-columns: 1fr 80px">
<TagTableHeading></TagTableHeading>
<div
class="col-span-5 py-24 text-center"
v-if="tags.length === 0">
<FolderPlusIcon
class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-white font-semibold">No tags found</h3>
<p class="pb-5">Create your first tag now!</p>
<SecondaryButton @click="createTag = true" :icon="PlusIcon"
>Create your First Tag</SecondaryButton
>
</div>
<template v-for="tag in tags" :key="tag.id">
<TagTableRow :tag="tag"></TagTableRow>
</template>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts"></script>
<template>
<div
class="py-1.5 pr-3 text-left text-sm font-semibold text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 bg-row-heading-background border-t border-row-heading-border">
Name
</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>
</template>
<style scoped></style>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { Tag } from '@/utils/api';
import { useTagsStore } from '@/utils/useTags';
import TagMoreOptionsDropdown from '@/Components/Common/Tag/TagMoreOptionsDropdown.vue';
const props = defineProps<{
tag: Tag;
}>();
function deleteTag() {
useTagsStore().deleteTag(props.tag.id);
}
</script>
<template>
<div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>
{{ tag.name }}
</span>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<TagMoreOptionsDropdown
:tag="tag"
@delete="deleteTag"></TagMoreOptionsDropdown>
</div>
</template>
<style scoped></style>

View File

@@ -56,7 +56,7 @@ watch(focused, (newValue, oldValue) => {
<template #content>
<div
ref="dropdownContent"
class="grid grid-cols-2 divide-x divide-card-background-seperator text-center py-1">
class="grid grid-cols-2 divide-x divide-card-background-separator text-center py-1">
<div>
<div class="font-bold text-white text-sm pb-1">
Start

View File

@@ -74,6 +74,11 @@ function deleteTimeEntry() {
function updateTimeEntryDescription(description: string) {
updateTimeEntry({ ...props.timeEntry, description });
}
function updateTimeEntryTags(tags: string[]) {
console.log(tags);
updateTimeEntry({ ...props.timeEntry, tags });
}
</script>
<template>
@@ -97,7 +102,7 @@ function updateTimeEntryDescription(description: string) {
</div>
<div class="flex items-center font-medium space-x-2">
<TimeEntryRowTagDropdown
@changed="updateTimeEntry(timeEntry)"
@changed="updateTimeEntryTags"
:modelValue="timeEntry.tags"></TimeEntryRowTagDropdown>
<div>
<TimeEntryRangeSelector

View File

@@ -19,7 +19,7 @@ const timeEntryTags = computed<Tag[]>(() => {
</script>
<template>
<TagDropdown @changed="emit('changed')" v-model="model">
<TagDropdown @changed="emit('changed', model)" v-model="model">
<template #trigger>
<button data-testid="time_entry_tag_dropdown">
<TagBadge

View File

@@ -25,7 +25,7 @@ const iconColorClasses = computed(() => {
:class="
twMerge(
iconColorClasses,
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-seperator hover:bg-card-background-seperator rounded-full w-11 h-11 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 w-11 h-11 flex items-center justify-center'
)
">
<TagIcon class="w-7 h-7"></TagIcon>

View File

@@ -10,7 +10,7 @@
</h3>
<div
class="rounded-lg bg-card-background border border-card-border flex-1 flex items-center">
<div class="divide-y divide-card-background-seperator w-full">
<div class="divide-y divide-card-background-separator w-full">
<slot></slot>
</div>
</div>

View File

@@ -39,7 +39,8 @@ const close = () => {
</div>
</div>
<div class="flex flex-row justify-end px-6 py-4 bg-gray-100 text-end">
<div
class="flex flex-row justify-end px-6 py-4 border-t border-card-background-separator bg-card-background text-end">
<slot name="footer" />
</div>
</Modal>

View File

@@ -34,7 +34,7 @@ const hasActions = computed(() => !!useSlots().actions);
<div
v-if="hasActions"
class="flex items-center justify-end px-4 py-3 bg-card-background border-t border-card-background-seperator text-end sm:px-6 shadow sm:rounded-bl-md sm:rounded-br-md">
class="flex items-center justify-end px-4 py-3 bg-card-background border-t border-card-background-separator text-end sm:px-6 shadow sm:rounded-bl-md sm:rounded-br-md">
<slot name="actions" />
</div>
</form>

View File

@@ -64,7 +64,7 @@ const maxWidthClass = computed(() => {
<transition leave-active-class="duration-200">
<div
v-show="show"
class="fixed inset-0 overflow-y-auto px-4 py-6 sm:px-0 z-50"
class="fixed inset-0 overflow-y-auto px-4 py-32 sm:px-0 z-50"
scroll-region>
<transition
enter-active-class="ease-out duration-300"
@@ -75,10 +75,10 @@ const maxWidthClass = computed(() => {
leave-to-class="opacity-0">
<div
v-show="show"
class="fixed inset-0 transform transition-all"
class="fixed inset-0 transform transition-all backdrop-blur-sm"
@click="close">
<div
class="absolute inset-0 bg-card-background opacity-75" />
class="absolute inset-0 bg-default-background opacity-30" />
</div>
</transition>
@@ -91,7 +91,7 @@ const maxWidthClass = computed(() => {
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<div
v-show="show"
class="mb-6 bg-card-background rounded-lg overflow-hidden shadow-xl transform transition-all sm:w-full sm:mx-auto"
class="mb-6 bg-card-background border border-card-border rounded-lg shadow-xl transform transition-all sm:w-full sm:mx-auto"
:class="maxWidthClass">
<slot v-if="show" />
</div>

View File

@@ -90,7 +90,7 @@ const switchToTeam = (team: Organization) => {
<!-- Organization Switcher -->
<template v-if="page.props.auth.user.all_teams.length > 1">
<div class="border-t border-card-background-seperator" />
<div class="border-t border-card-background-separator" />
<div class="block px-4 py-2 text-xs text-muted">
Switch Teams

View File

@@ -14,7 +14,7 @@ withDefaults(
<template>
<button
:type="type"
class="inline-flex items-center px-4 py-2 bg-white/90 border border-transparent rounded-md font-semibold text-xs text-black uppercase tracking-widest hover:bg-white focus:bg-white active:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition ease-in-out duration-150">
class="inline-flex items-center px-3 py-2 bg-accent-300/10 border border-accent-300/20 rounded-md font-medium text-sm text-white hover:bg-accent-300/20 focus:bg-white active:bg-accent-300/20 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition ease-in-out duration-150">
<slot />
</button>
</template>

View File

@@ -1,9 +1,12 @@
<script setup lang="ts">
import type { HtmlButtonType } from '@/types/dom';
import { twMerge } from 'tailwind-merge';
import { type Component } from 'vue';
withDefaults(
const props = withDefaults(
defineProps<{
type: HtmlButtonType;
icon?: Component;
}>(),
{
type: 'button',
@@ -14,7 +17,18 @@ withDefaults(
<template>
<button
:type="type"
class="inline-flex items-center px-4 py-2 bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover rounded-md font-semibold text-xs text-white uppercase tracking-widest shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25 transition ease-in-out duration-150">
class="bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover shadow-sm transition text-white text-sm px-3 py-2 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">
<span
:class="
twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')
">
<component
v-if="props.icon"
:is="props.icon"
class="w-5 h-5 -ml-1"></component>
<span>
<slot />
</span>
</span>
</button>
</template>

View File

@@ -1,7 +1,7 @@
<template>
<div class="hidden sm:block">
<div class="py-8">
<div class="border-t border-default-background-seperator" />
<div class="border-t border-default-background-separator" />
</div>
</div>
</template>

View File

@@ -81,7 +81,34 @@ function pauseLiveTimerUpdate() {
function updateTimerAndStartLiveTimerUpdate() {
const time = parse(temporaryCustomTimerEntry.value, 's');
if (time && time > 0) {
if (isNumeric(temporaryCustomTimerEntry.value)) {
const newStartDate = dayjs().subtract(
parseInt(temporaryCustomTimerEntry.value),
'm'
);
currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') {
currentTimeEntryStore.updateTimer();
} else {
currentTimeEntryStore.startTimer();
}
} else if (isHHMM(temporaryCustomTimerEntry.value)) {
const results = parseHHMM(temporaryCustomTimerEntry.value);
if (results) {
const newStartDate = dayjs()
.subtract(parseInt(results[1]), 'h')
.subtract(parseInt(results[2]), 'm');
currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') {
currentTimeEntryStore.updateTimer();
} else {
currentTimeEntryStore.startTimer();
}
}
}
// try to parse natural language like "1h 30m"
else if (time && time > 1) {
const newStartDate = dayjs().subtract(time, 's');
currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') {
@@ -90,10 +117,37 @@ function updateTimerAndStartLiveTimerUpdate() {
currentTimeEntryStore.startTimer();
}
}
// fallback to minutes if just a number is given
now.value = dayjs().utc();
temporaryCustomTimerEntry.value = '';
startLiveTimer();
}
function isNumeric(value: string) {
return /^-?\d+$/.test(value);
}
const HHMMtimeRegex = /^([0-9]{1,2}):([0-5]?[0-9])$/;
function isHHMM(value: string): boolean {
return HHMMtimeRegex.test(value);
}
function parseHHMM(value: string): string[] | null {
return value.match(HHMMtimeRegex);
}
function startTimerIfNotActive() {
if (!isActive.value) {
onToggleButtonPress(true);
}
}
function onTimeEntryEnterPress() {
updateTimerAndStartLiveTimerUpdate();
const activeElement = document.activeElement as HTMLElement;
activeElement?.blur();
}
</script>
<template>
@@ -106,6 +160,7 @@ function updateTimerAndStartLiveTimerUpdate() {
placeholder="What are you working on?"
data-testid="time_entry_description"
v-model="currentTimeEntry.description"
@keydown.enter="startTimerIfNotActive"
@blur="updateTimeEntry"
class="w-full rounded-l-lg py-3 px-5 text-lg text-white focus:bg-card-background-active font-medium bg-transparent border-none placeholder-muted focus:ring-0 transition"
type="text" />
@@ -127,7 +182,7 @@ function updateTimerAndStartLiveTimerUpdate() {
@focus="pauseLiveTimerUpdate"
data-testid="time_entry_time"
@blur="updateTimerAndStartLiveTimerUpdate"
@keydown.enter="updateTimerAndStartLiveTimerUpdate"
@keydown.enter="onTimeEntryEnterPress"
v-model="currentTime"
class="w-40 h-full text-white py-4 rounded-r-lg text-center px-4 text-lg font-bold bg-card-background border-none placeholder-muted focus:ring-0 transition focus:bg-card-background-active"
type="text" />

View File

@@ -6,12 +6,12 @@ import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
import {
ChartBarIcon,
ClockIcon,
Cog6ToothIcon,
FolderIcon,
HomeIcon,
TagIcon,
UserCircleIcon,
UserGroupIcon,
TagIcon,
Cog6ToothIcon,
} from '@heroicons/vue/20/solid';
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
@@ -19,14 +19,22 @@ import MainContainer from '@/Pages/MainContainer.vue';
import { onMounted } from 'vue';
import { useProjectsStore } from '@/utils/useProjects';
import { useTagsStore } from '@/utils/useTags';
import { useTasksStore } from '@/utils/useTasks';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { useClientsStore } from '@/utils/useClients';
import { useMembersStore } from '@/utils/useMembers';
defineProps({
title: String,
});
onMounted(async () => {
await useProjectsStore().fetchProjects();
await useTagsStore().fetchTags();
useProjectsStore().fetchProjects();
useTasksStore().fetchTasks();
useTagsStore().fetchTags();
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
useClientsStore().fetchClients();
useMembersStore().fetchMembers();
});
</script>
@@ -35,10 +43,10 @@ onMounted(async () => {
<div
class="flex-shrink-0 h-screen fixed w-[230px] 2xl:w-[270px] px-2.5 2xl:px-4 py-4 flex flex-col justify-between">
<div>
<div class="border-b border-default-background-seperator pb-2">
<div class="border-b border-default-background-separator pb-2">
<OrganizationSwitcher></OrganizationSwitcher>
</div>
<div class="border-b border-default-background-seperator">
<div class="border-b border-default-background-separator">
<CurrentSidebarTimer></CurrentSidebarTimer>
</div>
<nav>
@@ -58,7 +66,8 @@ onMounted(async () => {
<NavigationSidebarItem
title="Reporting"
:icon="ChartBarIcon"
:href="route('dashboard')"></NavigationSidebarItem>
:current="route().current('reporting')"
:href="route('reporting')"></NavigationSidebarItem>
</ul>
</nav>
@@ -69,28 +78,31 @@ onMounted(async () => {
<NavigationSidebarItem
title="Projects"
:icon="FolderIcon"
:href="route('dashboard')"
:href="route('projects')"
:current="
route().current('dashboard')
route().current('projects')
"></NavigationSidebarItem>
<NavigationSidebarItem
title="Clients"
:icon="UserCircleIcon"
:href="route('dashboard')"></NavigationSidebarItem>
:current="route().current('clients')"
:href="route('clients')"></NavigationSidebarItem>
<NavigationSidebarItem
title="Members"
:icon="UserGroupIcon"
:href="route('dashboard')"></NavigationSidebarItem>
:current="route().current('members')"
:href="route('members')"></NavigationSidebarItem>
<NavigationSidebarItem
title="Tags"
:icon="TagIcon"
:href="route('dashboard')"></NavigationSidebarItem>
:current="route().current('tags')"
:href="route('tags')"></NavigationSidebarItem>
</ul>
</nav>
</div>
<ul
class="border-t border-default-background-seperator pt-3 flex justify-between pr-4 items-center">
class="border-t border-default-background-separator pt-3 flex justify-between pr-4 items-center">
<NavigationSidebarItem
class="flex-1"
title="Settings"
@@ -106,11 +118,11 @@ onMounted(async () => {
<Banner />
<div
class="min-h-screen bg-default-background border-l border-default-background-seperator">
class="min-h-screen bg-default-background border-l border-default-background-separator">
<!-- Page Heading -->
<header
v-if="$slots.header"
class="bg-default-background border-b border-default-background-seperator shadow">
class="bg-default-background border-b border-default-background-separator shadow">
<div class="pt-8 pb-3">
<MainContainer>
<slot name="header" />

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import MainContainer from '@/Pages/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue';
import { PlusIcon } from '@heroicons/vue/16/solid';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import { UserCircleIcon } from '@heroicons/vue/20/solid';
import { onMounted, ref } from 'vue';
import { useClientsStore } from '@/utils/useClients';
import ClientTable from '@/Components/Common/Client/ClientTable.vue';
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
onMounted(() => {
useClientsStore().fetchClients();
});
const createClient = ref(false);
</script>
<template>
<AppLayout title="Clients" data-testid="clients_view">
<MainContainer
class="py-5 border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-6">
<h3
class="text-white font-bold text-base flex items-center space-x-2.5">
<UserCircleIcon
class="w-6 text-icon-default"></UserCircleIcon>
<span> Clients </span>
</h3>
<TabBar>
<TabBarItem>All</TabBarItem>
<TabBarItem active>Active</TabBarItem>
<TabBarItem>Archived</TabBarItem>
</TabBar>
</div>
<SecondaryButton :icon="PlusIcon" @click="createClient = true"
>Create Client</SecondaryButton
>
<ClientCreateModal v-model:show="createClient"></ClientCreateModal>
</MainContainer>
<ClientTable></ClientTable>
</AppLayout>
</template>

View File

@@ -50,11 +50,11 @@ const props = defineProps<{
<template>
<AppLayout title="Dashboard" data-testid="dashboard_view">
<MainContainer
class="pt-8 pb-6 border-b border-default-background-seperator">
class="pt-8 pb-6 border-b border-default-background-separator">
<TimeTracker></TimeTracker>
</MainContainer>
<MainContainer
class="grid gap-x-6 grid-cols-2 xl:grid-cols-4 pt-5 pb-6 border-b border-default-background-seperator items-stretch">
class="grid gap-x-6 grid-cols-2 xl:grid-cols-4 pt-5 pb-6 border-b border-default-background-separator items-stretch">
<RecentlyTrackedTasksCard
:latestTasks="props.latestTasks"></RecentlyTrackedTasksCard>
<LastSevenDaysCard

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"></script>
<template>
<div class="sm:px-6 lg:px-8 3xl:px-12 mx-auto">
<div class="px-4 sm:px-6 lg:px-8 3xl:px-12 mx-auto">
<slot></slot>
</div>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import MainContainer from '@/Pages/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { UserGroupIcon } from '@heroicons/vue/20/solid';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import { ref } from 'vue';
import MemberTable from '@/Components/Common/Member/MemberTable.vue';
import MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue';
import type { Role } from '@/types/jetstream';
const inviteMember = ref(false);
defineProps<{
availableRoles: Role[];
}>();
</script>
<template>
<AppLayout title="Members" data-testid="members_view">
<MainContainer
class="py-5 border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-6">
<h3
class="text-white font-bold text-base flex items-center space-x-2.5">
<UserGroupIcon
class="w-6 text-icon-default"></UserGroupIcon>
<span> Members </span>
</h3>
<TabBar>
<TabBarItem active>All</TabBarItem>
<TabBarItem>Active</TabBarItem>
<TabBarItem>Inactive</TabBarItem>
</TabBar>
</div>
<SecondaryButton :icon="PlusIcon" @click="inviteMember = true"
>Invite member</SecondaryButton
>
<MemberInviteModal
:available-roles="availableRoles"
v-model:show="inviteMember"></MemberInviteModal>
</MainContainer>
<MemberTable></MemberTable>
</AppLayout>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import MainContainer from '@/Pages/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import ProjectTable from '@/Components/Common/Project/ProjectTable.vue';
import { onMounted, ref } from 'vue';
import { useProjectsStore } from '@/utils/useProjects';
import ProjectCreateModal from '@/Components/Common/Project/ProjectCreateModal.vue';
onMounted(() => {
useProjectsStore().fetchProjects();
});
const createProject = ref(false);
</script>
<template>
<AppLayout title="Projects" data-testid="projects_view">
<MainContainer
class="py-5 border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-6">
<h3
class="text-white font-bold text-base flex items-center space-x-2.5">
<FolderIcon class="w-6 text-icon-default"></FolderIcon>
<span> Projects </span>
</h3>
<TabBar>
<TabBarItem>All</TabBarItem>
<TabBarItem active>Active</TabBarItem>
<TabBarItem>Archived</TabBarItem>
</TabBar>
</div>
<SecondaryButton :icon="PlusIcon" @click="createProject = true"
>Create Project</SecondaryButton
>
<ProjectCreateModal
v-model:show="createProject"></ProjectCreateModal>
</MainContainer>
<ProjectTable></ProjectTable>
</AppLayout>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import MainContainer from '@/Pages/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue';
</script>
<template>
<AppLayout title="Reporting" data-testid="reporting_view">
<MainContainer
class="py-8 border-b border-default-background-separator">
Reporting is coming soon
</MainContainer>
</AppLayout>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import MainContainer from '@/Pages/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import { ref } from 'vue';
import TagTable from '@/Components/Common/Tag/TagTable.vue';
import TagCreateModal from '@/Components/Common/Tag/TagCreateModal.vue';
const createTag = ref(false);
</script>
<template>
<AppLayout title="Tags" data-testid="tags_view">
<MainContainer
class="py-5 border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-6">
<h3
class="text-white font-bold text-base flex items-center space-x-2.5">
<FolderIcon class="w-6 text-icon-default"></FolderIcon>
<span> Tags </span>
</h3>
</div>
<SecondaryButton :icon="PlusIcon" @click="createTag = true"
>Create Tag</SecondaryButton
>
<TagCreateModal v-model:show="createTag"></TagCreateModal>
</MainContainer>
<TagTable></TagTable>
</AppLayout>
</template>

View File

@@ -82,7 +82,8 @@ const updateTeamName = () => {
class="mt-1 block w-full border-input-border bg-input-background text-white focus:border-input-border-active rounded-md shadow-sm">
<option value="" disabled>Select a currency</option>
<option
v-for="(currencyTranslated, currencyKey) in $page.props.currencies"
v-for="(currencyTranslated, currencyKey) in $page.props
.currencies"
:key="currencyKey"
:value="currencyKey">
{{ currencyKey }} - {{ currencyTranslated }}

View File

@@ -49,7 +49,7 @@ const groupedTimeEntries = computed(() => {
<template>
<AppLayout title="Dashboard" data-testid="dashboard_view">
<MainContainer
class="py-8 border-b border-default-background-seperator">
class="py-8 border-b border-default-background-separator">
<TimeTracker></TimeTracker>
</MainContainer>
<div v-for="(value, key) in groupedTimeEntries" :key="key">

View File

@@ -1,4 +1,8 @@
import type { ApiOf, ZodiosResponseByAlias } from '@zodios/core';
import type {
ApiOf,
ZodiosResponseByAlias,
ZodiosBodyByAlias,
} from '@zodios/core';
import { api } from '../../../openapi.json.client';
export type SolidTimeApi = ApiOf<typeof api>;
@@ -15,5 +19,29 @@ export type ProjectResponse = ZodiosResponseByAlias<
>;
export type Project = ProjectResponse['data'][0];
export type CreateProjectBody = ZodiosBodyByAlias<
SolidTimeApi,
'createProject'
>;
export type CreateClientBody = ZodiosBodyByAlias<SolidTimeApi, 'createClient'>;
export type TagIndexResponse = ZodiosResponseByAlias<SolidTimeApi, 'getTags'>;
export type Tag = TagIndexResponse['data'][0];
export type TaskIndexResponse = ZodiosResponseByAlias<SolidTimeApi, 'getTasks'>;
export type Task = TaskIndexResponse['data'][0];
export type ClientIndexResponse = ZodiosResponseByAlias<
SolidTimeApi,
'getClients'
>;
export type Client = ClientIndexResponse['data'][0];
export type MemberIndexResponse = ZodiosResponseByAlias<
SolidTimeApi,
'getMembers'
>;
export type Member = MemberIndexResponse['data'][0];
export type CreateTagBody = ZodiosBodyByAlias<SolidTimeApi, 'createTag'>;

View File

@@ -1,4 +1,4 @@
const colors = [
export const colors = [
'#ef5350',
'#ec407a',
'#ab47bc',

View File

@@ -0,0 +1,61 @@
import { defineStore } from 'pinia';
import { api } from '../../../openapi.json.client';
import { computed, ref } from 'vue';
import type {
CreateClientBody,
ClientIndexResponse,
Client,
} from '@/utils/api';
import { getCurrentOrganizationId } from '@/utils/useUser';
export const useClientsStore = defineStore('clients', () => {
const clientResponse = ref<ClientIndexResponse | null>(null);
async function fetchClients() {
const organization = getCurrentOrganizationId();
if (organization) {
clientResponse.value = await api.getClients({
params: {
organization: organization,
},
});
}
}
async function createClient(
clientBody: CreateClientBody
): Promise<Client | undefined> {
const organization = getCurrentOrganizationId();
if (organization) {
const response = await api.createClient(clientBody, {
params: {
organization: organization,
},
});
await fetchClients();
return response.data;
}
}
async function deleteClient(clientId: string) {
const organization = getCurrentOrganizationId();
if (organization) {
await api.deleteClient(
{},
{
params: {
organization: organization,
client: clientId,
},
}
);
await fetchClients();
}
}
const clients = computed<Client[]>(() => {
return clientResponse.value?.data || [];
});
return { clients, fetchClients, createClient, deleteClient };
});

View File

@@ -0,0 +1,26 @@
import { defineStore } from 'pinia';
import { api } from '../../../openapi.json.client';
import { computed, ref } from 'vue';
import type { Member, MemberIndexResponse } from '@/utils/api';
import { getCurrentOrganizationId } from '@/utils/useUser';
export const useMembersStore = defineStore('members', () => {
const membersResponse = ref<MemberIndexResponse | null>(null);
async function fetchMembers() {
const organization = getCurrentOrganizationId();
if (organization) {
membersResponse.value = await api.getMembers({
params: {
organization: organization,
},
});
}
}
const members = computed<Member[]>(() => {
return membersResponse.value?.data || [];
});
return { members, fetchMembers };
});

View File

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia';
import { api } from '../../../openapi.json.client';
import { computed, ref } from 'vue';
import type { Project, ProjectResponse } from '@/utils/api';
import type { CreateProjectBody, Project, ProjectResponse } from '@/utils/api';
import { getCurrentOrganizationId } from '@/utils/useUser';
export const useProjectsStore = defineStore('projects', () => {
@@ -18,9 +18,37 @@ export const useProjectsStore = defineStore('projects', () => {
}
}
async function createProject(projectBody: CreateProjectBody) {
const organization = getCurrentOrganizationId();
if (organization) {
await api.createProject(projectBody, {
params: {
organization: organization,
},
});
await fetchProjects();
}
}
async function deleteProject(projectId: string) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
await api.deleteProject(
{},
{
params: {
organization: organizationId,
project: projectId,
},
}
);
await fetchProjects();
}
}
const projects = computed<Project[]>(
() => projectResponse.value?.data || []
);
return { projects, fetchProjects };
return { projects, fetchProjects, createProject, deleteProject };
});

View File

@@ -23,6 +23,22 @@ export const useTagsStore = defineStore('tags', () => {
}
}
async function deleteTag(tagId: string) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
await api.deleteTag(
{},
{
params: {
organization: organizationId,
tag: tagId,
},
}
);
await fetchTags();
}
}
async function createTag(name: string) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
@@ -45,5 +61,5 @@ export const useTagsStore = defineStore('tags', () => {
}
}
return { tags, fetchTags, createTag };
return { tags, fetchTags, createTag, deleteTag };
});

View File

@@ -0,0 +1,69 @@
import { defineStore } from 'pinia';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '../../../openapi.json.client';
import { reactive, ref } from 'vue';
import type { Task } from '@/utils/api';
export const useTasksStore = defineStore('tasks', () => {
const tasks = ref<Task[]>(reactive([]));
async function fetchTasks() {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const tasksResponse = await api.getTasks({
params: {
organization: organizationId,
},
});
tasks.value = tasksResponse.data;
}
}
async function updateTask(task: Task) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
await api.updateTask(task, {
params: {
organization: organizationId,
task: task.id,
},
});
}
}
async function createTask(task: Task) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
await api.createTask(task, {
params: {
organization: organizationId,
},
});
await fetchTasks();
}
}
async function deleteTask(taskId: string) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
await api.deleteTask(
{},
{
params: {
organization: organizationId,
task: taskId,
},
}
);
await fetchTasks();
}
}
return {
tasks,
fetchTasks,
updateTask,
createTask,
deleteTask,
};
});

View File

@@ -18,7 +18,7 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
organization: organizationId,
},
queries: {
only_full_dates: true,
only_full_dates: 'true',
},
});
timeEntries.value = timeEntriesResponse.data;
@@ -37,7 +37,7 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
organization: organizationId,
},
queries: {
only_full_dates: true,
only_full_dates: 'true',
before: dayjs(latestTimeEntry.start).utc().format(),
},
});

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Web\DashboardController;
use App\Http\Controllers\Web\HomeController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Laravel\Jetstream\Jetstream;
/*
|--------------------------------------------------------------------------
@@ -30,4 +31,27 @@ Route::middleware([
Route::get('/time', function () {
return Inertia::render('Time');
})->name('time');
Route::get('/reporting', function () {
return Inertia::render('Reporting');
})->name('reporting');
Route::get('/projects', function () {
return Inertia::render('Projects');
})->name('projects');
Route::get('/clients', function () {
return Inertia::render('Clients');
})->name('clients');
Route::get('/members', function () {
return Inertia::render('Members', [
'availableRoles' => array_values(Jetstream::$roles),
]);
})->name('members');
Route::get('/tags', function () {
return Inertia::render('Tags');
})->name('tags');
});

View File

@@ -20,23 +20,34 @@ export default {
colors: {
'white': '#D9DCFB',
'default-background': '#040618',
'default-background-seperator': '#13152B',
'default-background-separator': '#13152B',
'card-background': 'var(--theme-color-card-background)',
'card-background-active': '#1C1E34',
'card-background-seperator': '#262A51',
'card-border': '#242940',
'card-border-active': '#2A3461',
'card-background-active':
'var(--theme-color-card-background-active)',
'card-background-separator': '#262A51',
'card-border': 'var(--theme-color-card-border)',
'card-border-active': 'var(--theme-color-card-border-active)',
'muted': '#8F93B7',
'icon-default': 'var(--theme-color-icon-default)',
'tab-background': 'var(--theme-color-tab-background)',
'tab-background-active':
'var(--theme-color-tab-background-active)',
'tab-border': 'var(--theme-color-tab-border)',
'icon-active': '#787DA8',
'menu-active': '#13152B',
'input-placeholder': '#42466C',
'input-border': '#242740',
'input-border-active': '#797EA8',
'input-background': '#030513',
'button-secondary-background': '#22243E',
'button-secondary-background-hover': '#292C4D',
'button-secondary-border': '#353961',
'button-secondary-background':
'var(--theme-color-card-background)',
'button-secondary-background-hover':
'var(--theme-color-card-background-active)',
'button-secondary-border': 'var(--theme-color-card-border)',
'row-separator': 'var(--theme-color-row-separator-background)',
'row-heading-background':
'var(--theme-color-row-heading-background)',
'row-heading-border': 'var(--theme-color-row-heading-border)',
'accent': {
'50': '#eff7ff',
'100': '#daecff',