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 ```bash
npm run generate:zod npm run zod:generate
``` ```
## Contributing ## Contributing

View File

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

View File

@@ -23,6 +23,8 @@ class MemberController extends Controller
* @return MemberCollection<MemberResource>> * @return MemberCollection<MemberResource>>
* *
* @throws AuthorizationException * @throws AuthorizationException
*
* @operationId getMembers
*/ */
public function index(Organization $organization, MemberIndexRequest $request): MemberCollection 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 * Invite a placeholder user to become a member of the organization
* *
* @throws AuthorizationException|UserNotPlaceholderApiException * @throws AuthorizationException|UserNotPlaceholderApiException
*
* @operationId invitePlaceholder
*/ */
public function invitePlaceholder(Organization $organization, User $user, Request $request): JsonResponse 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'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
async function registerNewUser(page, email, password) { async function registerNewUser(page, email, password) {
//await page.getByRole('link', { name: 'Register' }).click();
await page.goto(PLAYWRIGHT_BASE_URL + '/register'); await page.goto(PLAYWRIGHT_BASE_URL + '/register');
await page.getByLabel('Name').fill('John Doe'); await page.getByLabel('Name').fill('John Doe');
await page.getByLabel('Email').fill(email); 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 ({ test('test that load more works when the end of page is reached', async ({
page, page,
}) => { }) => {
await goToTimeOverview(page); await Promise.all([
await page.waitForResponse( goToTimeOverview(page),
(response) => page.waitForResponse(
response.url().includes('/time-entries') && (response) =>
response.status() === 200 response.url().includes('/time-entries') &&
); response.status() === 200
),
]);
await page.waitForTimeout(200); await page.waitForTimeout(200);
await Promise.all([ await Promise.all([
page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)), page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)),

View File

@@ -7,8 +7,10 @@ import {
startOrStopTimerWithButton, startOrStopTimerWithButton,
stoppedTimeEntryResponse, stoppedTimeEntryResponse,
} from './utils/currentTimeEntry'; } 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'); 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') .getByTestId('time_entry_description')
.fill('New Time Entry Description'); .fill('New Time Entry Description');
await Promise.all([ await Promise.all([
page.waitForResponse(async (response) => { newTimeEntryResponse(page, {
return ( description: 'New Time Entry Description',
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([])
);
}), }),
startOrStopTimerWithButton(page), startOrStopTimerWithButton(page),
]); ]);
await assertThatTimerHasStarted(page); await assertThatTimerHasStarted(page);
await page.waitForTimeout(1500); await page.waitForTimeout(1500);
await Promise.all([ await Promise.all([
page.waitForResponse(async (response) => { stoppedTimeEntryResponse(page, {
return ( description: 'New Time Entry Description',
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([])
);
}), }),
await startOrStopTimerWithButton(page), await startOrStopTimerWithButton(page),
]); ]);
@@ -89,23 +61,7 @@ test('test that starting and updating the description while running works', asyn
await goToDashboard(page); await goToDashboard(page);
await Promise.all([ await Promise.all([
page.waitForResponse(async (response) => { newTimeEntryResponse(page),
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([])
);
}),
startOrStopTimerWithButton(page), startOrStopTimerWithButton(page),
]); ]);
await assertThatTimerHasStarted(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'); .fill('New Time Entry Description');
await Promise.all([ await Promise.all([
page.waitForResponse(async (response) => { newTimeEntryResponse(page, {
return ( status: 200,
response.status() === 200 && description: 'New Time Entry Description',
(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([])
);
}), }),
page.getByTestId('time_entry_description').press('Tab'), page.getByTestId('time_entry_description').press('Tab'),
]); ]);
await page.waitForTimeout(500); await page.waitForTimeout(500);
await Promise.all([ await Promise.all([
page.waitForResponse(async (response) => { stoppedTimeEntryResponse(page, {
return ( description: 'New Time Entry Description',
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([])
);
}), }),
await startOrStopTimerWithButton(page), startOrStopTimerWithButton(page),
]); ]);
await assertThatTimerIsStopped(page); await assertThatTimerIsStopped(page);
}); });
@@ -165,23 +92,7 @@ test('test that starting and updating the time while running works', async ({
}) => { }) => {
await goToDashboard(page); await goToDashboard(page);
const [createResponse] = await Promise.all([ const [createResponse] = await Promise.all([
page.waitForResponse(async (response) => { newTimeEntryResponse(page),
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([])
);
}),
await startOrStopTimerWithButton(page), await startOrStopTimerWithButton(page),
]); ]);
await assertThatTimerHasStarted(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 expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/);
await page.waitForTimeout(500); await page.waitForTimeout(500);
await Promise.all([ await Promise.all([
page.waitForResponse(async (response) => { stoppedTimeEntryResponse(page),
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([])
);
}),
startOrStopTimerWithButton(page), startOrStopTimerWithButton(page),
]); ]);
await assertThatTimerIsStopped(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 goToDashboard(page);
await page.getByTestId('time_entry_time').fill('20min'); await page.getByTestId('time_entry_time').fill('20min');
await Promise.all([ await Promise.all([
page.waitForResponse(async (response) => { newTimeEntryResponse(page),
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([])
);
}),
page.getByTestId('time_entry_time').press('Tab'), page.getByTestId('time_entry_time').press('Tab'),
]); ]);
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20:/);
await assertThatTimerHasStarted(page); await assertThatTimerHasStarted(page);
await Promise.all([ await Promise.all([
page.waitForResponse(async (response) => { stoppedTimeEntryResponse(page),
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([])
);
}),
startOrStopTimerWithButton(page), startOrStopTimerWithButton(page),
]); ]);
await page.locator( 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 ({ test('test that entering a time starts the timer on enter', async ({
page, page,
}) => { }) => {
await goToDashboard(page); await goToDashboard(page);
await page.getByTestId('time_entry_time').fill('20min'); await page.getByTestId('time_entry_time').fill('20min');
await Promise.all([ await Promise.all([
page.waitForResponse(async (response) => { newTimeEntryResponse(page),
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([])
);
}),
page.getByTestId('time_entry_time').press('Enter'), page.getByTestId('time_entry_time').press('Enter'),
]); ]);
await assertThatTimerHasStarted(page); await assertThatTimerHasStarted(page);
await Promise.all([ await Promise.all([
page.waitForResponse(async (response) => { stoppedTimeEntryResponse(page),
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([])
);
}),
startOrStopTimerWithButton(page), startOrStopTimerWithButton(page),
]); ]);
await assertThatTimerIsStopped(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 page.getByTestId('tag_dropdown_search').fill(newTagName);
await Promise.all([ await Promise.all([
page.waitForResponse(async (response) => { newTagResponse(page, { name: newTagName }),
return (
response.status() === 201 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.name === newTagName
);
}),
page.getByTestId('tag_dropdown_search').press('Enter'), 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').click();
await page.getByTestId('tag_dropdown_search').fill(newTagName); await page.getByTestId('tag_dropdown_search').fill(newTagName);
const [tagCreateResponse] = await Promise.all([ const [tagCreateResponse] = await Promise.all([
page.waitForResponse(async (response) => { newTagResponse(page, { name: newTagName }),
return (
response.status() === 201 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.name === newTagName
);
}),
page.getByTestId('tag_dropdown_search').press('Enter'), page.getByTestId('tag_dropdown_search').press('Enter'),
]); ]);
await page.waitForResponse(async (response) => { const tagId = (await tagCreateResponse.json()).data.id;
return ( await newTimeEntryResponse(page, { status: 200, tags: [tagId] });
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])
);
});
await expect(page.getByTestId('tag_dropdown_search')).toHaveValue(''); await expect(page.getByTestId('tag_dropdown_search')).toHaveValue('');
await expect(page.getByRole('option', { name: newTagName })).toBeVisible(); await expect(page.getByRole('option', { name: newTagName })).toBeVisible();
await page.getByTestId('tag_dropdown_search').press('Escape'); await page.getByTestId('tag_dropdown_search').press('Escape');
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
await Promise.all([ await Promise.all([
page.waitForResponse(async (response) => { stoppedTimeEntryResponse(page, { tags: [tagId] }),
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])
);
}),
startOrStopTimerWithButton(page), startOrStopTimerWithButton(page),
]); ]);
await assertThatTimerIsStopped(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 page.waitForResponse(async (response) => {
return ( return (
response.status() === 201 && response.status() === status &&
(await response.headerValue('Content-Type')) === (await response.headerValue('Content-Type')) ===
'application/json' && 'application/json' &&
(await response.json()).data.id !== null && (await response.json()).data.id !== null &&
(await response.json()).data.start !== null && (await response.json()).data.start !== null &&
(await response.json()).data.end === null && (await response.json()).data.end === null &&
(await response.json()).data.project_id === 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.task_id === null &&
(await response.json()).data.duration === null && (await response.json()).data.duration === null &&
(await response.json()).data.user_id !== null && (await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) === 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/); ).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 page.waitForResponse(async (response) => {
return ( return (
response.status() === 200 && response.status() === 200 &&
@@ -50,12 +56,12 @@ export async function stoppedTimeEntryResponse(page: Page) {
(await response.json()).data.start !== null && (await response.json()).data.start !== null &&
(await response.json()).data.end !== null && (await response.json()).data.end !== null &&
(await response.json()).data.project_id === 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.task_id === null &&
(await response.json()).data.duration !== null && (await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null && (await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) === 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(), email: z.string(),
role: z.string(), role: z.string(),
is_placeholder: z.boolean(), is_placeholder: z.boolean(),
billable_rate: z.union([z.number(), z.null()]),
}) })
.passthrough(); .passthrough();
const MemberCollection = z.array(MemberResource); const MemberCollection = z.array(MemberResource);
const OrganizationResource = z 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(); .passthrough();
const ProjectResource = z const ProjectResource = z
.object({ .object({
@@ -32,16 +44,35 @@ const ProjectResource = z
name: z.string(), name: z.string(),
color: z.string(), color: z.string(),
client_id: z.union([z.string(), z.null()]), client_id: z.union([z.string(), z.null()]),
billable_rate: z.union([z.number(), z.null()]),
}) })
.passthrough(); .passthrough();
const ProjectCollection = z.array(ProjectResource);
const createProject_Body = z const createProject_Body = z
.object({ .object({
name: z.string(), name: z.string(),
color: z.string(), color: z.string(),
billable_rate: z.union([z.number(), z.null()]).optional(),
client_id: z.union([z.string(), z.null()]).optional(), client_id: z.union([z.string(), z.null()]).optional(),
}) })
.passthrough(); .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 const TagResource = z
.object({ .object({
id: z.string(), id: z.string(),
@@ -51,6 +82,18 @@ const TagResource = z
}) })
.passthrough(); .passthrough();
const TagCollection = z.array(TagResource); 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 before = z.union([z.string(), z.null()]).optional();
const TimeEntryResource = z const TimeEntryResource = z
.object({ .object({
@@ -98,11 +141,16 @@ export const schemas = {
MemberResource, MemberResource,
MemberCollection, MemberCollection,
OrganizationResource, OrganizationResource,
v1_organizations_update_Body,
ProjectResource, ProjectResource,
ProjectCollection,
createProject_Body, createProject_Body,
ProjectMemberResource,
createProjectMember_Body,
updateProjectMember_Body,
TagResource, TagResource,
TagCollection, TagCollection,
TaskResource,
createTask_Body,
before, before,
TimeEntryResource, TimeEntryResource,
TimeEntryCollection, TimeEntryCollection,
@@ -146,7 +194,7 @@ const endpoints = makeApi([
{ {
name: 'body', name: 'body',
type: 'Body', type: 'Body',
schema: z.object({ name: z.string() }).passthrough(), schema: v1_organizations_update_Body,
}, },
{ {
name: 'organization', name: 'organization',
@@ -181,7 +229,7 @@ const endpoints = makeApi([
{ {
method: 'get', method: 'get',
path: '/v1/organizations/:organization/clients', path: '/v1/organizations/:organization/clients',
alias: 'v1.clients.index', alias: 'getClients',
requestFormat: 'json', requestFormat: 'json',
parameters: [ parameters: [
{ {
@@ -207,7 +255,7 @@ const endpoints = makeApi([
{ {
method: 'post', method: 'post',
path: '/v1/organizations/:organization/clients', path: '/v1/organizations/:organization/clients',
alias: 'v1.clients.store', alias: 'createClient',
requestFormat: 'json', requestFormat: 'json',
parameters: [ parameters: [
{ {
@@ -248,7 +296,7 @@ const endpoints = makeApi([
{ {
method: 'put', method: 'put',
path: '/v1/organizations/:organization/clients/:client', path: '/v1/organizations/:organization/clients/:client',
alias: 'v1.clients.update', alias: 'updateClient',
requestFormat: 'json', requestFormat: 'json',
parameters: [ parameters: [
{ {
@@ -294,7 +342,7 @@ const endpoints = makeApi([
{ {
method: 'delete', method: 'delete',
path: '/v1/organizations/:organization/clients/:client', path: '/v1/organizations/:organization/clients/:client',
alias: 'v1.clients.destroy', alias: 'deleteClient',
requestFormat: 'json', requestFormat: 'json',
parameters: [ parameters: [
{ {
@@ -400,7 +448,7 @@ const endpoints = makeApi([
{ {
method: 'get', method: 'get',
path: '/v1/organizations/:organization/members', path: '/v1/organizations/:organization/members',
alias: 'v1.users.index', alias: 'getMembers',
requestFormat: 'json', requestFormat: 'json',
parameters: [ parameters: [
{ {
@@ -436,7 +484,7 @@ const endpoints = makeApi([
{ {
method: 'post', method: 'post',
path: '/v1/organizations/:organization/members/:user/invite-placeholder', path: '/v1/organizations/:organization/members/:user/invite-placeholder',
alias: 'v1.users.invite-placeholder', alias: 'invitePlaceholder',
requestFormat: 'json', requestFormat: 'json',
parameters: [ 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', method: 'get',
path: '/v1/organizations/:organization/projects', path: '/v1/organizations/:organization/projects',
@@ -492,7 +622,39 @@ const endpoints = makeApi([
schema: z.string().uuid(), 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: [ errors: [
{ {
status: 403, 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', method: 'get',
path: '/v1/organizations/:organization/tags', 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', method: 'get',
path: '/v1/organizations/:organization/time-entries', path: '/v1/organizations/:organization/time-entries',
@@ -848,7 +1315,7 @@ const endpoints = makeApi([
{ {
name: 'only_full_dates', name: 'only_full_dates',
type: 'Query', type: 'Query',
schema: z.boolean().optional(), schema: z.enum(['true', 'false']).optional(),
}, },
], ],
response: z.object({ data: TimeEntryCollection }).passthrough(), response: z.object({ data: TimeEntryCollection }).passthrough(),
@@ -951,6 +1418,17 @@ const endpoints = makeApi([
], ],
response: z.object({ data: TimeEntryResource }).passthrough(), response: z.object({ data: TimeEntryResource }).passthrough(),
errors: [ errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{ {
status: 403, status: 403,
description: `Authorization error`, description: `Authorization error`,

2
package-lock.json generated
View File

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

View File

@@ -8,7 +8,7 @@
"lint:fix": "eslint --fix --ext .js,.vue,.ts --ignore-path .gitignore .", "lint:fix": "eslint --fix --ext .js,.vue,.ts --ignore-path .gitignore .",
"type-check": "vue-tsc --noEmit", "type-check": "vue-tsc --noEmit",
"test:e2e": "rm -rf test-results/.auth && npx playwright test", "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": { "devDependencies": {
"@inertiajs/vue3": "^1.0.0", "@inertiajs/vue3": "^1.0.0",
@@ -32,6 +32,8 @@
"vue-tsc": "^1.8.27" "vue-tsc": "^1.8.27"
}, },
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/vue": "^1.0.6",
"@heroicons/vue": "^2.1.1", "@heroicons/vue": "^2.1.1",
"@rushstack/eslint-patch": "^1.7.0", "@rushstack/eslint-patch": "^1.7.0",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",

View File

@@ -6,6 +6,17 @@
:root{ :root{
--theme-color-icon-default: #42466C; --theme-color-icon-default: #42466C;
--theme-color-card-background: #13152B; --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( const props = withDefaults(
defineProps<{ defineProps<{
name: string;
size: 'base' | 'large'; size: 'base' | 'large';
tag: string; tag: string;
class?: string; class?: string;

View File

@@ -21,7 +21,7 @@ const iconColorClasses = computed(() => {
:class=" :class="
twMerge( twMerge(
iconColorClasses, iconColorClasses,
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-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 <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 :border
tag="button" tag="button"
:name="selectedProjectName" :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>
<template #content> <template #content>
@@ -131,7 +131,7 @@ function updateValue(project: Project) {
<ComboboxInput <ComboboxInput
@keydown.enter="addProjectIfNoneExists" @keydown.enter="addProjectIfNoneExists"
ref="searchInput" 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..." /> placeholder="Search for a project..." />
</ComboboxAnchor> </ComboboxAnchor>
<ComboboxContent> <ComboboxContent>
@@ -168,7 +168,7 @@ function updateValue(project: Project) {
" "
class="bg-card-background-active"> class="bg-card-background-active">
<div <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 <PlusCircleIcon
class="w-5 flex-shrink-0"></PlusCircleIcon> class="w-5 flex-shrink-0"></PlusCircleIcon>
<span <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 { } else {
model.value.push(newValue); model.value.push(newValue);
} }
emit('changed'); nextTick(() => {
emit('changed');
});
} }
function moveHighlightUp() { function moveHighlightUp() {
@@ -155,14 +157,14 @@ const highlightedItem = computed(() => {
@keydown.up.prevent="moveHighlightUp" @keydown.up.prevent="moveHighlightUp"
@keydown.down.prevent="moveHighlightDown" @keydown.down.prevent="moveHighlightDown"
ref="searchInput" 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..." /> placeholder="Search for a tag..." />
<div ref="dropdownViewport" class="w-60"> <div ref="dropdownViewport" class="w-60">
<div <div
v-if="searchValue.length > 0 && filteredTags.length === 0" v-if="searchValue.length > 0 && filteredTags.length === 0"
class="bg-card-background-active"> class="bg-card-background-active">
<div <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 <PlusCircleIcon
class="w-5 flex-shrink-0"></PlusCircleIcon> class="w-5 flex-shrink-0"></PlusCircleIcon>
<span>Add "{{ searchValue }}" as a new Tag</span> <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> <template #content>
<div <div
ref="dropdownContent" 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>
<div class="font-bold text-white text-sm pb-1"> <div class="font-bold text-white text-sm pb-1">
Start Start

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@
</h3> </h3>
<div <div
class="rounded-lg bg-card-background border border-card-border flex-1 flex items-center"> 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> <slot></slot>
</div> </div>
</div> </div>

View File

@@ -39,7 +39,8 @@ const close = () => {
</div> </div>
</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" /> <slot name="footer" />
</div> </div>
</Modal> </Modal>

View File

@@ -34,7 +34,7 @@ const hasActions = computed(() => !!useSlots().actions);
<div <div
v-if="hasActions" 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" /> <slot name="actions" />
</div> </div>
</form> </form>

View File

@@ -64,7 +64,7 @@ const maxWidthClass = computed(() => {
<transition leave-active-class="duration-200"> <transition leave-active-class="duration-200">
<div <div
v-show="show" 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> scroll-region>
<transition <transition
enter-active-class="ease-out duration-300" enter-active-class="ease-out duration-300"
@@ -75,10 +75,10 @@ const maxWidthClass = computed(() => {
leave-to-class="opacity-0"> leave-to-class="opacity-0">
<div <div
v-show="show" v-show="show"
class="fixed inset-0 transform transition-all" class="fixed inset-0 transform transition-all backdrop-blur-sm"
@click="close"> @click="close">
<div <div
class="absolute inset-0 bg-card-background opacity-75" /> class="absolute inset-0 bg-default-background opacity-30" />
</div> </div>
</transition> </transition>
@@ -91,7 +91,7 @@ const maxWidthClass = computed(() => {
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"> leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<div <div
v-show="show" v-show="show"
class="mb-6 bg-card-background 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"> :class="maxWidthClass">
<slot v-if="show" /> <slot v-if="show" />
</div> </div>

View File

@@ -90,7 +90,7 @@ const switchToTeam = (team: Organization) => {
<!-- Organization Switcher --> <!-- Organization Switcher -->
<template v-if="page.props.auth.user.all_teams.length > 1"> <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"> <div class="block px-4 py-2 text-xs text-muted">
Switch Teams Switch Teams

View File

@@ -14,7 +14,7 @@ withDefaults(
<template> <template>
<button <button
:type="type" :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 /> <slot />
</button> </button>
</template> </template>

View File

@@ -1,9 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HtmlButtonType } from '@/types/dom'; import type { HtmlButtonType } from '@/types/dom';
import { twMerge } from 'tailwind-merge';
import { type Component } from 'vue';
withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
type: HtmlButtonType; type: HtmlButtonType;
icon?: Component;
}>(), }>(),
{ {
type: 'button', type: 'button',
@@ -14,7 +17,18 @@ withDefaults(
<template> <template>
<button <button
:type="type" :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">
<slot /> <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> </button>
</template> </template>

View File

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

View File

@@ -81,7 +81,34 @@ function pauseLiveTimerUpdate() {
function updateTimerAndStartLiveTimerUpdate() { function updateTimerAndStartLiveTimerUpdate() {
const time = parse(temporaryCustomTimerEntry.value, 's'); 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'); const newStartDate = dayjs().subtract(time, 's');
currentTimeEntry.value.start = newStartDate.utc().format(); currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') { if (currentTimeEntry.value.id !== '') {
@@ -90,10 +117,37 @@ function updateTimerAndStartLiveTimerUpdate() {
currentTimeEntryStore.startTimer(); currentTimeEntryStore.startTimer();
} }
} }
// fallback to minutes if just a number is given
now.value = dayjs().utc(); now.value = dayjs().utc();
temporaryCustomTimerEntry.value = ''; temporaryCustomTimerEntry.value = '';
startLiveTimer(); 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> </script>
<template> <template>
@@ -106,6 +160,7 @@ function updateTimerAndStartLiveTimerUpdate() {
placeholder="What are you working on?" placeholder="What are you working on?"
data-testid="time_entry_description" data-testid="time_entry_description"
v-model="currentTimeEntry.description" v-model="currentTimeEntry.description"
@keydown.enter="startTimerIfNotActive"
@blur="updateTimeEntry" @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" 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" /> type="text" />
@@ -127,7 +182,7 @@ function updateTimerAndStartLiveTimerUpdate() {
@focus="pauseLiveTimerUpdate" @focus="pauseLiveTimerUpdate"
data-testid="time_entry_time" data-testid="time_entry_time"
@blur="updateTimerAndStartLiveTimerUpdate" @blur="updateTimerAndStartLiveTimerUpdate"
@keydown.enter="updateTimerAndStartLiveTimerUpdate" @keydown.enter="onTimeEntryEnterPress"
v-model="currentTime" 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" 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" /> type="text" />

View File

@@ -6,12 +6,12 @@ import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
import { import {
ChartBarIcon, ChartBarIcon,
ClockIcon, ClockIcon,
Cog6ToothIcon,
FolderIcon, FolderIcon,
HomeIcon, HomeIcon,
TagIcon,
UserCircleIcon, UserCircleIcon,
UserGroupIcon, UserGroupIcon,
TagIcon,
Cog6ToothIcon,
} from '@heroicons/vue/20/solid'; } from '@heroicons/vue/20/solid';
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue'; import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue'; import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
@@ -19,14 +19,22 @@ import MainContainer from '@/Pages/MainContainer.vue';
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
import { useTagsStore } from '@/utils/useTags'; 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({ defineProps({
title: String, title: String,
}); });
onMounted(async () => { onMounted(async () => {
await useProjectsStore().fetchProjects(); useProjectsStore().fetchProjects();
await useTagsStore().fetchTags(); useTasksStore().fetchTasks();
useTagsStore().fetchTags();
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
useClientsStore().fetchClients();
useMembersStore().fetchMembers();
}); });
</script> </script>
@@ -35,10 +43,10 @@ onMounted(async () => {
<div <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"> 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>
<div class="border-b border-default-background-seperator pb-2"> <div class="border-b border-default-background-separator pb-2">
<OrganizationSwitcher></OrganizationSwitcher> <OrganizationSwitcher></OrganizationSwitcher>
</div> </div>
<div class="border-b border-default-background-seperator"> <div class="border-b border-default-background-separator">
<CurrentSidebarTimer></CurrentSidebarTimer> <CurrentSidebarTimer></CurrentSidebarTimer>
</div> </div>
<nav> <nav>
@@ -58,7 +66,8 @@ onMounted(async () => {
<NavigationSidebarItem <NavigationSidebarItem
title="Reporting" title="Reporting"
:icon="ChartBarIcon" :icon="ChartBarIcon"
:href="route('dashboard')"></NavigationSidebarItem> :current="route().current('reporting')"
:href="route('reporting')"></NavigationSidebarItem>
</ul> </ul>
</nav> </nav>
@@ -69,28 +78,31 @@ onMounted(async () => {
<NavigationSidebarItem <NavigationSidebarItem
title="Projects" title="Projects"
:icon="FolderIcon" :icon="FolderIcon"
:href="route('dashboard')" :href="route('projects')"
:current=" :current="
route().current('dashboard') route().current('projects')
"></NavigationSidebarItem> "></NavigationSidebarItem>
<NavigationSidebarItem <NavigationSidebarItem
title="Clients" title="Clients"
:icon="UserCircleIcon" :icon="UserCircleIcon"
:href="route('dashboard')"></NavigationSidebarItem> :current="route().current('clients')"
:href="route('clients')"></NavigationSidebarItem>
<NavigationSidebarItem <NavigationSidebarItem
title="Members" title="Members"
:icon="UserGroupIcon" :icon="UserGroupIcon"
:href="route('dashboard')"></NavigationSidebarItem> :current="route().current('members')"
:href="route('members')"></NavigationSidebarItem>
<NavigationSidebarItem <NavigationSidebarItem
title="Tags" title="Tags"
:icon="TagIcon" :icon="TagIcon"
:href="route('dashboard')"></NavigationSidebarItem> :current="route().current('tags')"
:href="route('tags')"></NavigationSidebarItem>
</ul> </ul>
</nav> </nav>
</div> </div>
<ul <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 <NavigationSidebarItem
class="flex-1" class="flex-1"
title="Settings" title="Settings"
@@ -106,11 +118,11 @@ onMounted(async () => {
<Banner /> <Banner />
<div <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 --> <!-- Page Heading -->
<header <header
v-if="$slots.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"> <div class="pt-8 pb-3">
<MainContainer> <MainContainer>
<slot name="header" /> <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> <template>
<AppLayout title="Dashboard" data-testid="dashboard_view"> <AppLayout title="Dashboard" data-testid="dashboard_view">
<MainContainer <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> <TimeTracker></TimeTracker>
</MainContainer> </MainContainer>
<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 <RecentlyTrackedTasksCard
:latestTasks="props.latestTasks"></RecentlyTrackedTasksCard> :latestTasks="props.latestTasks"></RecentlyTrackedTasksCard>
<LastSevenDaysCard <LastSevenDaysCard

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"></script> <script setup lang="ts"></script>
<template> <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> <slot></slot>
</div> </div>
</template> </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"> 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 value="" disabled>Select a currency</option>
<option <option
v-for="(currencyTranslated, currencyKey) in $page.props.currencies" v-for="(currencyTranslated, currencyKey) in $page.props
.currencies"
:key="currencyKey" :key="currencyKey"
:value="currencyKey"> :value="currencyKey">
{{ currencyKey }} - {{ currencyTranslated }} {{ currencyKey }} - {{ currencyTranslated }}

View File

@@ -49,7 +49,7 @@ const groupedTimeEntries = computed(() => {
<template> <template>
<AppLayout title="Dashboard" data-testid="dashboard_view"> <AppLayout title="Dashboard" data-testid="dashboard_view">
<MainContainer <MainContainer
class="py-8 border-b border-default-background-seperator"> class="py-8 border-b border-default-background-separator">
<TimeTracker></TimeTracker> <TimeTracker></TimeTracker>
</MainContainer> </MainContainer>
<div v-for="(value, key) in groupedTimeEntries" :key="key"> <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'; import { api } from '../../../openapi.json.client';
export type SolidTimeApi = ApiOf<typeof api>; export type SolidTimeApi = ApiOf<typeof api>;
@@ -15,5 +19,29 @@ export type ProjectResponse = ZodiosResponseByAlias<
>; >;
export type Project = ProjectResponse['data'][0]; 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 TagIndexResponse = ZodiosResponseByAlias<SolidTimeApi, 'getTags'>;
export type Tag = TagIndexResponse['data'][0]; 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', '#ef5350',
'#ec407a', '#ec407a',
'#ab47bc', '#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 { defineStore } from 'pinia';
import { api } from '../../../openapi.json.client'; import { api } from '../../../openapi.json.client';
import { computed, ref } from 'vue'; 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'; import { getCurrentOrganizationId } from '@/utils/useUser';
export const useProjectsStore = defineStore('projects', () => { 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[]>( const projects = computed<Project[]>(
() => projectResponse.value?.data || [] () => 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) { async function createTag(name: string) {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (organizationId) { 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, organization: organizationId,
}, },
queries: { queries: {
only_full_dates: true, only_full_dates: 'true',
}, },
}); });
timeEntries.value = timeEntriesResponse.data; timeEntries.value = timeEntriesResponse.data;
@@ -37,7 +37,7 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
organization: organizationId, organization: organizationId,
}, },
queries: { queries: {
only_full_dates: true, only_full_dates: 'true',
before: dayjs(latestTimeEntry.start).utc().format(), 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 App\Http\Controllers\Web\HomeController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Inertia\Inertia; use Inertia\Inertia;
use Laravel\Jetstream\Jetstream;
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -30,4 +31,27 @@ Route::middleware([
Route::get('/time', function () { Route::get('/time', function () {
return Inertia::render('Time'); return Inertia::render('Time');
})->name('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: { colors: {
'white': '#D9DCFB', 'white': '#D9DCFB',
'default-background': '#040618', 'default-background': '#040618',
'default-background-seperator': '#13152B', 'default-background-separator': '#13152B',
'card-background': 'var(--theme-color-card-background)', 'card-background': 'var(--theme-color-card-background)',
'card-background-active': '#1C1E34', 'card-background-active':
'card-background-seperator': '#262A51', 'var(--theme-color-card-background-active)',
'card-border': '#242940', 'card-background-separator': '#262A51',
'card-border-active': '#2A3461', 'card-border': 'var(--theme-color-card-border)',
'card-border-active': 'var(--theme-color-card-border-active)',
'muted': '#8F93B7', 'muted': '#8F93B7',
'icon-default': 'var(--theme-color-icon-default)', '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', 'icon-active': '#787DA8',
'menu-active': '#13152B', 'menu-active': '#13152B',
'input-placeholder': '#42466C', 'input-placeholder': '#42466C',
'input-border': '#242740', 'input-border': '#242740',
'input-border-active': '#797EA8', 'input-border-active': '#797EA8',
'input-background': '#030513', 'input-background': '#030513',
'button-secondary-background': '#22243E', 'button-secondary-background':
'button-secondary-background-hover': '#292C4D', 'var(--theme-color-card-background)',
'button-secondary-border': '#353961', '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': { 'accent': {
'50': '#eff7ff', '50': '#eff7ff',
'100': '#daecff', '100': '#daecff',