mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
add basic crud frontend for clients, tags and projects
This commit is contained in:
@@ -67,7 +67,7 @@ The Zodius HTTP client is generated using the following command:
|
||||
|
||||
```bash
|
||||
|
||||
npm run generate:zod
|
||||
npm run zod:generate
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -29,6 +29,8 @@ class ClientController extends Controller
|
||||
* @return ClientCollection<ClientResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getClients
|
||||
*/
|
||||
public function index(Organization $organization): ClientCollection
|
||||
{
|
||||
@@ -46,6 +48,8 @@ class ClientController extends Controller
|
||||
* Create client
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId createClient
|
||||
*/
|
||||
public function store(Organization $organization, TagStoreRequest $request): ClientResource
|
||||
{
|
||||
@@ -63,6 +67,8 @@ class ClientController extends Controller
|
||||
* Update client
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId updateClient
|
||||
*/
|
||||
public function update(Organization $organization, Client $client, TagUpdateRequest $request): ClientResource
|
||||
{
|
||||
@@ -78,6 +84,8 @@ class ClientController extends Controller
|
||||
* Delete client
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId deleteClient
|
||||
*/
|
||||
public function destroy(Organization $organization, Client $client): JsonResponse
|
||||
{
|
||||
|
||||
@@ -23,6 +23,8 @@ class MemberController extends Controller
|
||||
* @return MemberCollection<MemberResource>>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getMembers
|
||||
*/
|
||||
public function index(Organization $organization, MemberIndexRequest $request): MemberCollection
|
||||
{
|
||||
@@ -38,6 +40,8 @@ class MemberController extends Controller
|
||||
* Invite a placeholder user to become a member of the organization
|
||||
*
|
||||
* @throws AuthorizationException|UserNotPlaceholderApiException
|
||||
*
|
||||
* @operationId invitePlaceholder
|
||||
*/
|
||||
public function invitePlaceholder(Organization $organization, User $user, Request $request): JsonResponse
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ import { expect, test } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
|
||||
async function registerNewUser(page, email, password) {
|
||||
//await page.getByRole('link', { name: 'Register' }).click();
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
||||
await page.getByLabel('Name').fill('John Doe');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
|
||||
51
e2e/clients.spec.ts
Normal file
51
e2e/clients.spec.ts
Normal 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
65
e2e/projects.spec.ts
Normal 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
48
e2e/tags.spec.ts
Normal 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);
|
||||
});
|
||||
@@ -428,12 +428,15 @@ test('test that deleting a time entry from the overview works', async ({
|
||||
test('test that load more works when the end of page is reached', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToTimeOverview(page);
|
||||
await page.waitForResponse(
|
||||
await Promise.all([
|
||||
goToTimeOverview(page),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.status() === 200
|
||||
);
|
||||
),
|
||||
]);
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
await Promise.all([
|
||||
page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)),
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
startOrStopTimerWithButton,
|
||||
stoppedTimeEntryResponse,
|
||||
} from './utils/currentTimeEntry';
|
||||
import { Page } from '@playwright/test';
|
||||
import { newTagResponse } from './utils/tags';
|
||||
|
||||
async function goToDashboard(page) {
|
||||
async function goToDashboard(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
}
|
||||
|
||||
@@ -37,46 +39,16 @@ test('test that starting and stopping a timer with a description works', async (
|
||||
.getByTestId('time_entry_description')
|
||||
.fill('New Time Entry Description');
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 201 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end === null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description ===
|
||||
'New Time Entry Description' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration === null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
newTimeEntryResponse(page, {
|
||||
description: 'New Time Entry Description',
|
||||
}),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
await page.waitForTimeout(1500);
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description ===
|
||||
'New Time Entry Description' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration !== null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
stoppedTimeEntryResponse(page, {
|
||||
description: 'New Time Entry Description',
|
||||
}),
|
||||
await startOrStopTimerWithButton(page),
|
||||
]);
|
||||
@@ -89,23 +61,7 @@ test('test that starting and updating the description while running works', asyn
|
||||
await goToDashboard(page);
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 201 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end === null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration === null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
}),
|
||||
newTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
@@ -115,47 +71,18 @@ test('test that starting and updating the description while running works', asyn
|
||||
.fill('New Time Entry Description');
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end === null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description ===
|
||||
'New Time Entry Description' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration === null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
newTimeEntryResponse(page, {
|
||||
status: 200,
|
||||
description: 'New Time Entry Description',
|
||||
}),
|
||||
page.getByTestId('time_entry_description').press('Tab'),
|
||||
]);
|
||||
await page.waitForTimeout(500);
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description ===
|
||||
'New Time Entry Description' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration !== null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
stoppedTimeEntryResponse(page, {
|
||||
description: 'New Time Entry Description',
|
||||
}),
|
||||
await startOrStopTimerWithButton(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
@@ -165,23 +92,7 @@ test('test that starting and updating the time while running works', async ({
|
||||
}) => {
|
||||
await goToDashboard(page);
|
||||
const [createResponse] = await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 201 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end === null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration === null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
}),
|
||||
newTimeEntryResponse(page),
|
||||
await startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
@@ -214,71 +125,26 @@ test('test that starting and updating the time while running works', async ({
|
||||
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/);
|
||||
await page.waitForTimeout(500);
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration !== null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
}),
|
||||
stoppedTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
test('test that entering a time starts the timer on blur', async ({ page }) => {
|
||||
test('test that entering a human readable time starts the timer on blur', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToDashboard(page);
|
||||
await page.getByTestId('time_entry_time').fill('20min');
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 201 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end === null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration === null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
}),
|
||||
newTimeEntryResponse(page),
|
||||
page.getByTestId('time_entry_time').press('Tab'),
|
||||
]);
|
||||
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20:/);
|
||||
await assertThatTimerHasStarted(page);
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration !== null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
}),
|
||||
stoppedTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await page.locator(
|
||||
@@ -286,50 +152,71 @@ test('test that entering a time starts the timer on blur', async ({ page }) => {
|
||||
);
|
||||
});
|
||||
|
||||
test('test that entering a number in the time range starts the timer on blur', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToDashboard(page);
|
||||
await page.getByTestId('time_entry_time').fill('5');
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page),
|
||||
page.getByTestId('time_entry_time').press('Tab'),
|
||||
]);
|
||||
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:05:/);
|
||||
await assertThatTimerHasStarted(page);
|
||||
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await page.locator(
|
||||
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
|
||||
);
|
||||
});
|
||||
|
||||
test('test that entering a value with the format hh:mm in the time range starts the timer on blur', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToDashboard(page);
|
||||
await page.getByTestId('time_entry_time').fill('12:30');
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page),
|
||||
page.getByTestId('time_entry_time').press('Tab'),
|
||||
]);
|
||||
await expect(page.getByTestId('time_entry_time')).toHaveValue(/12:30:/);
|
||||
await assertThatTimerHasStarted(page);
|
||||
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await page.locator(
|
||||
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
|
||||
);
|
||||
});
|
||||
|
||||
test('test that entering a random value in the time range does not start the timer on blur', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToDashboard(page);
|
||||
await page.getByTestId('time_entry_time').fill('asdasdasd');
|
||||
await page.getByTestId('time_entry_time').press('Tab'),
|
||||
await page.locator(
|
||||
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
|
||||
);
|
||||
});
|
||||
|
||||
test('test that entering a time starts the timer on enter', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToDashboard(page);
|
||||
await page.getByTestId('time_entry_time').fill('20min');
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 201 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end === null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration === null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
}),
|
||||
newTimeEntryResponse(page),
|
||||
page.getByTestId('time_entry_time').press('Enter'),
|
||||
]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration !== null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
}),
|
||||
stoppedTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
@@ -342,14 +229,7 @@ test('test that adding a new tag works', async ({ page }) => {
|
||||
await page.getByTestId('tag_dropdown_search').fill(newTagName);
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 201 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.name === newTagName
|
||||
);
|
||||
}),
|
||||
newTagResponse(page, { name: newTagName }),
|
||||
page.getByTestId('tag_dropdown_search').press('Enter'),
|
||||
]);
|
||||
|
||||
@@ -370,56 +250,18 @@ test('test that adding a new tag when the timer is running', async ({
|
||||
await page.getByTestId('tag_dropdown').click();
|
||||
await page.getByTestId('tag_dropdown_search').fill(newTagName);
|
||||
const [tagCreateResponse] = await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 201 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.name === newTagName
|
||||
);
|
||||
}),
|
||||
newTagResponse(page, { name: newTagName }),
|
||||
page.getByTestId('tag_dropdown_search').press('Enter'),
|
||||
]);
|
||||
await page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end === null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration === null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([(await tagCreateResponse.json()).data.id])
|
||||
);
|
||||
});
|
||||
const tagId = (await tagCreateResponse.json()).data.id;
|
||||
await newTimeEntryResponse(page, { status: 200, tags: [tagId] });
|
||||
await expect(page.getByTestId('tag_dropdown_search')).toHaveValue('');
|
||||
await expect(page.getByRole('option', { name: newTagName })).toBeVisible();
|
||||
await page.getByTestId('tag_dropdown_search').press('Escape');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration !== null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([(await tagCreateResponse.json()).data.id])
|
||||
);
|
||||
}),
|
||||
stoppedTimeEntryResponse(page, { tags: [tagId] }),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
|
||||
@@ -12,22 +12,25 @@ export async function assertThatTimerHasStarted(page: Page) {
|
||||
);
|
||||
}
|
||||
|
||||
export function newTimeEntryResponse(page: Page) {
|
||||
export function newTimeEntryResponse(
|
||||
page: Page,
|
||||
{ description = '', status = 201, tags = [] } = {}
|
||||
) {
|
||||
return page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 201 &&
|
||||
response.status() === status &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end === null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.description === description &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration === null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
JSON.stringify(tags)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -40,7 +43,10 @@ export async function assertThatTimerIsStopped(page: Page) {
|
||||
).toHaveClass(/bg-accent-300\/50/);
|
||||
}
|
||||
|
||||
export async function stoppedTimeEntryResponse(page: Page) {
|
||||
export async function stoppedTimeEntryResponse(
|
||||
page: Page,
|
||||
{ description = '', tags = [] } = {}
|
||||
) {
|
||||
return page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
@@ -50,12 +56,12 @@ export async function stoppedTimeEntryResponse(page: Page) {
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.description === description &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration !== null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
JSON.stringify(tags)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
12
e2e/utils/tags.ts
Normal file
12
e2e/utils/tags.ts
Normal 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
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -20,11 +20,23 @@ const MemberResource = z
|
||||
email: z.string(),
|
||||
role: z.string(),
|
||||
is_placeholder: z.boolean(),
|
||||
billable_rate: z.union([z.number(), z.null()]),
|
||||
})
|
||||
.passthrough();
|
||||
const MemberCollection = z.array(MemberResource);
|
||||
const OrganizationResource = z
|
||||
.object({ id: z.string(), name: z.string(), is_personal: z.string() })
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
is_personal: z.string(),
|
||||
billable_rate: z.union([z.number(), z.null()]),
|
||||
})
|
||||
.passthrough();
|
||||
const v1_organizations_update_Body = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
billable_rate: z.union([z.number(), z.null()]).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const ProjectResource = z
|
||||
.object({
|
||||
@@ -32,16 +44,35 @@ const ProjectResource = z
|
||||
name: z.string(),
|
||||
color: z.string(),
|
||||
client_id: z.union([z.string(), z.null()]),
|
||||
billable_rate: z.union([z.number(), z.null()]),
|
||||
})
|
||||
.passthrough();
|
||||
const ProjectCollection = z.array(ProjectResource);
|
||||
const createProject_Body = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
color: z.string(),
|
||||
billable_rate: z.union([z.number(), z.null()]).optional(),
|
||||
client_id: z.union([z.string(), z.null()]).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const ProjectMemberResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
billable_rate: z.union([z.number(), z.null()]),
|
||||
user_id: z.string(),
|
||||
project_id: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
const createProjectMember_Body = z
|
||||
.object({
|
||||
user_id: z.string().uuid(),
|
||||
billable_rate: z.union([z.number(), z.null()]).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const updateProjectMember_Body = z
|
||||
.object({ billable_rate: z.union([z.number(), z.null()]) })
|
||||
.partial()
|
||||
.passthrough();
|
||||
const TagResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -51,6 +82,18 @@ const TagResource = z
|
||||
})
|
||||
.passthrough();
|
||||
const TagCollection = z.array(TagResource);
|
||||
const TaskResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
project_id: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
const createTask_Body = z
|
||||
.object({ name: z.string(), project_id: z.string() })
|
||||
.passthrough();
|
||||
const before = z.union([z.string(), z.null()]).optional();
|
||||
const TimeEntryResource = z
|
||||
.object({
|
||||
@@ -98,11 +141,16 @@ export const schemas = {
|
||||
MemberResource,
|
||||
MemberCollection,
|
||||
OrganizationResource,
|
||||
v1_organizations_update_Body,
|
||||
ProjectResource,
|
||||
ProjectCollection,
|
||||
createProject_Body,
|
||||
ProjectMemberResource,
|
||||
createProjectMember_Body,
|
||||
updateProjectMember_Body,
|
||||
TagResource,
|
||||
TagCollection,
|
||||
TaskResource,
|
||||
createTask_Body,
|
||||
before,
|
||||
TimeEntryResource,
|
||||
TimeEntryCollection,
|
||||
@@ -146,7 +194,7 @@ const endpoints = makeApi([
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({ name: z.string() }).passthrough(),
|
||||
schema: v1_organizations_update_Body,
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
@@ -181,7 +229,7 @@ const endpoints = makeApi([
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/organizations/:organization/clients',
|
||||
alias: 'v1.clients.index',
|
||||
alias: 'getClients',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
@@ -207,7 +255,7 @@ const endpoints = makeApi([
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/organizations/:organization/clients',
|
||||
alias: 'v1.clients.store',
|
||||
alias: 'createClient',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
@@ -248,7 +296,7 @@ const endpoints = makeApi([
|
||||
{
|
||||
method: 'put',
|
||||
path: '/v1/organizations/:organization/clients/:client',
|
||||
alias: 'v1.clients.update',
|
||||
alias: 'updateClient',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
@@ -294,7 +342,7 @@ const endpoints = makeApi([
|
||||
{
|
||||
method: 'delete',
|
||||
path: '/v1/organizations/:organization/clients/:client',
|
||||
alias: 'v1.clients.destroy',
|
||||
alias: 'deleteClient',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
@@ -400,7 +448,7 @@ const endpoints = makeApi([
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/organizations/:organization/members',
|
||||
alias: 'v1.users.index',
|
||||
alias: 'getMembers',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
@@ -436,7 +484,7 @@ const endpoints = makeApi([
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/organizations/:organization/members/:user/invite-placeholder',
|
||||
alias: 'v1.users.invite-placeholder',
|
||||
alias: 'invitePlaceholder',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
@@ -480,6 +528,88 @@ const endpoints = makeApi([
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'put',
|
||||
path: '/v1/organizations/:organization/project-members/:projectMember',
|
||||
alias: 'updateProjectMember',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: updateProjectMember_Body,
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'projectMember',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: ProjectMemberResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'delete',
|
||||
path: '/v1/organizations/:organization/project-members/:projectMember',
|
||||
alias: 'deleteProjectMember',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({}).partial().passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'projectMember',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/organizations/:organization/projects',
|
||||
@@ -492,7 +622,39 @@ const endpoints = makeApi([
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: ProjectCollection }).passthrough(),
|
||||
response: z
|
||||
.object({
|
||||
data: z.array(ProjectResource),
|
||||
links: z
|
||||
.object({
|
||||
first: z.union([z.string(), z.null()]),
|
||||
last: z.union([z.string(), z.null()]),
|
||||
prev: z.union([z.string(), z.null()]),
|
||||
next: z.union([z.string(), z.null()]),
|
||||
})
|
||||
.passthrough(),
|
||||
meta: z
|
||||
.object({
|
||||
current_page: z.number().int(),
|
||||
from: z.union([z.number(), z.null()]),
|
||||
last_page: z.number().int(),
|
||||
links: z.array(
|
||||
z
|
||||
.object({
|
||||
url: z.union([z.string(), z.null()]),
|
||||
label: z.string(),
|
||||
active: z.boolean(),
|
||||
})
|
||||
.passthrough()
|
||||
),
|
||||
path: z.union([z.string(), z.null()]),
|
||||
per_page: z.number().int(),
|
||||
to: z.union([z.number(), z.null()]),
|
||||
total: z.number().int(),
|
||||
})
|
||||
.passthrough(),
|
||||
})
|
||||
.passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
@@ -660,6 +822,115 @@ const endpoints = makeApi([
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/organizations/:organization/projects/:project/project-members',
|
||||
alias: 'getProjectMembers',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'project',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z
|
||||
.object({
|
||||
data: z.array(ProjectMemberResource),
|
||||
links: z
|
||||
.object({
|
||||
first: z.union([z.string(), z.null()]),
|
||||
last: z.union([z.string(), z.null()]),
|
||||
prev: z.union([z.string(), z.null()]),
|
||||
next: z.union([z.string(), z.null()]),
|
||||
})
|
||||
.passthrough(),
|
||||
meta: z
|
||||
.object({
|
||||
current_page: z.number().int(),
|
||||
from: z.union([z.number(), z.null()]),
|
||||
last_page: z.number().int(),
|
||||
links: z.array(
|
||||
z
|
||||
.object({
|
||||
url: z.union([z.string(), z.null()]),
|
||||
label: z.string(),
|
||||
active: z.boolean(),
|
||||
})
|
||||
.passthrough()
|
||||
),
|
||||
path: z.union([z.string(), z.null()]),
|
||||
per_page: z.number().int(),
|
||||
to: z.union([z.number(), z.null()]),
|
||||
total: z.number().int(),
|
||||
})
|
||||
.passthrough(),
|
||||
})
|
||||
.passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/organizations/:organization/projects/:project/project-members',
|
||||
alias: 'createProjectMember',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: createProjectMember_Body,
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'project',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: ProjectMemberResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/organizations/:organization/tags',
|
||||
@@ -809,6 +1080,202 @@ const endpoints = makeApi([
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/organizations/:organization/tasks',
|
||||
alias: 'getTasks',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'project_id',
|
||||
type: 'Query',
|
||||
schema: z.string().uuid().optional(),
|
||||
},
|
||||
],
|
||||
response: z
|
||||
.object({
|
||||
data: z.array(TaskResource),
|
||||
links: z
|
||||
.object({
|
||||
first: z.union([z.string(), z.null()]),
|
||||
last: z.union([z.string(), z.null()]),
|
||||
prev: z.union([z.string(), z.null()]),
|
||||
next: z.union([z.string(), z.null()]),
|
||||
})
|
||||
.passthrough(),
|
||||
meta: z
|
||||
.object({
|
||||
current_page: z.number().int(),
|
||||
from: z.union([z.number(), z.null()]),
|
||||
last_page: z.number().int(),
|
||||
links: z.array(
|
||||
z
|
||||
.object({
|
||||
url: z.union([z.string(), z.null()]),
|
||||
label: z.string(),
|
||||
active: z.boolean(),
|
||||
})
|
||||
.passthrough()
|
||||
),
|
||||
path: z.union([z.string(), z.null()]),
|
||||
per_page: z.number().int(),
|
||||
to: z.union([z.number(), z.null()]),
|
||||
total: z.number().int(),
|
||||
})
|
||||
.passthrough(),
|
||||
})
|
||||
.passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/organizations/:organization/tasks',
|
||||
alias: 'createTask',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: createTask_Body,
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: TaskResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'put',
|
||||
path: '/v1/organizations/:organization/tasks/:task',
|
||||
alias: 'updateTask',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({ name: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'task',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: TaskResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'delete',
|
||||
path: '/v1/organizations/:organization/tasks/:task',
|
||||
alias: 'deleteTask',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({}).partial().passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'task',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/organizations/:organization/time-entries',
|
||||
@@ -848,7 +1315,7 @@ const endpoints = makeApi([
|
||||
{
|
||||
name: 'only_full_dates',
|
||||
type: 'Query',
|
||||
schema: z.boolean().optional(),
|
||||
schema: z.enum(['true', 'false']).optional(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: TimeEntryCollection }).passthrough(),
|
||||
@@ -951,6 +1418,17 @@ const endpoints = makeApi([
|
||||
],
|
||||
response: z.object({ data: TimeEntryResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -5,6 +5,8 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.6.0",
|
||||
"@floating-ui/vue": "^1.0.6",
|
||||
"@heroicons/vue": "^2.1.1",
|
||||
"@rushstack/eslint-patch": "^1.7.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"lint:fix": "eslint --fix --ext .js,.vue,.ts --ignore-path .gitignore .",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"test:e2e": "rm -rf test-results/.auth && npx playwright test",
|
||||
"generate:zod": "npx openapi-zod-client http://localhost:80/docs/api.json --output openapi.json.client.ts --base-url /api"
|
||||
"zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output openapi.json.client.ts --base-url /api"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inertiajs/vue3": "^1.0.0",
|
||||
@@ -32,6 +32,8 @@
|
||||
"vue-tsc": "^1.8.27"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.6.0",
|
||||
"@floating-ui/vue": "^1.0.6",
|
||||
"@heroicons/vue": "^2.1.1",
|
||||
"@rushstack/eslint-patch": "^1.7.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
|
||||
@@ -6,6 +6,17 @@
|
||||
:root{
|
||||
--theme-color-icon-default: #42466C;
|
||||
--theme-color-card-background: #13152B;
|
||||
--theme-color-card-background-active: #1C1E34;
|
||||
--theme-color-card-border: #242940;
|
||||
--theme-color-card-border-active: #2A3461;
|
||||
--theme-color-tab-background: var(--theme-color-card-background);
|
||||
--theme-color-tab-background-active: var(--theme-color-card-background-active);
|
||||
--theme-color-tab-border: var(--theme-color-card-border);
|
||||
--theme-color-row-separator-background: var(--theme-color-card-border);
|
||||
--theme-color-row-heading-background: var(--theme-color-card-background);
|
||||
--theme-color-row-border: var(--theme-color-card-border);
|
||||
--theme-color-row-heading-border: var(--theme-color-card-border);
|
||||
|
||||
}
|
||||
|
||||
*{
|
||||
|
||||
BIN
resources/js/Components/.DS_Store
vendored
Normal file
BIN
resources/js/Components/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -4,7 +4,6 @@ import { computed } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name: string;
|
||||
size: 'base' | 'large';
|
||||
tag: string;
|
||||
class?: string;
|
||||
|
||||
@@ -21,7 +21,7 @@ const iconColorClasses = computed(() => {
|
||||
:class="
|
||||
twMerge(
|
||||
iconColorClasses,
|
||||
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-seperator hover:bg-card-background-seperator rounded-full w-11 h-11 flex items-center justify-center'
|
||||
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-separator hover:bg-card-background-separator rounded-full w-11 h-11 flex items-center justify-center'
|
||||
)
|
||||
">
|
||||
<svg
|
||||
|
||||
66
resources/js/Components/Common/Client/ClientCreateModal.vue
Normal file
66
resources/js/Components/Common/Client/ClientCreateModal.vue
Normal 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>
|
||||
179
resources/js/Components/Common/Client/ClientDropdown.vue
Normal file
179
resources/js/Components/Common/Client/ClientDropdown.vue
Normal 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>
|
||||
28
resources/js/Components/Common/Client/ClientDropdownItem.vue
Normal file
28
resources/js/Components/Common/Client/ClientDropdownItem.vue
Normal 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>
|
||||
@@ -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>
|
||||
45
resources/js/Components/Common/Client/ClientTable.vue
Normal file
45
resources/js/Components/Common/Client/ClientTable.vue
Normal 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>
|
||||
18
resources/js/Components/Common/Client/ClientTableHeading.vue
Normal file
18
resources/js/Components/Common/Client/ClientTableHeading.vue
Normal 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>
|
||||
48
resources/js/Components/Common/Client/ClientTableRow.vue
Normal file
48
resources/js/Components/Common/Client/ClientTableRow.vue
Normal 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>
|
||||
148
resources/js/Components/Common/Member/MemberInviteModal.vue
Normal file
148
resources/js/Components/Common/Member/MemberInviteModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
28
resources/js/Components/Common/Member/MemberTable.vue
Normal file
28
resources/js/Components/Common/Member/MemberTable.vue
Normal 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>
|
||||
30
resources/js/Components/Common/Member/MemberTableHeading.vue
Normal file
30
resources/js/Components/Common/Member/MemberTableHeading.vue
Normal 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>
|
||||
55
resources/js/Components/Common/Member/MemberTableRow.vue
Normal file
55
resources/js/Components/Common/Member/MemberTableRow.vue
Normal 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>
|
||||
107
resources/js/Components/Common/Project/ProjectCreateModal.vue
Normal file
107
resources/js/Components/Common/Project/ProjectCreateModal.vue
Normal 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>
|
||||
@@ -116,7 +116,7 @@ function updateValue(project: Project) {
|
||||
:border
|
||||
tag="button"
|
||||
:name="selectedProjectName"
|
||||
class="focus:border-input-border-active focus:outline-0 focus:bg-card-background-seperator hover:bg-card-background-seperator"></ProjectBadge>
|
||||
class="focus:border-input-border-active focus:outline-0 focus:bg-card-background-separator hover:bg-card-background-separator"></ProjectBadge>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
@@ -131,7 +131,7 @@ function updateValue(project: Project) {
|
||||
<ComboboxInput
|
||||
@keydown.enter="addProjectIfNoneExists"
|
||||
ref="searchInput"
|
||||
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-seperator focus:border-card-background-seperator w-full"
|
||||
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||
placeholder="Search for a project..." />
|
||||
</ComboboxAnchor>
|
||||
<ComboboxContent>
|
||||
@@ -168,7 +168,7 @@ function updateValue(project: Project) {
|
||||
"
|
||||
class="bg-card-background-active">
|
||||
<div
|
||||
class="flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t rounded-b-lg border-card-background-seperator">
|
||||
class="flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t rounded-b-lg border-card-background-separator">
|
||||
<PlusCircleIcon
|
||||
class="w-5 flex-shrink-0"></PlusCircleIcon>
|
||||
<span
|
||||
|
||||
@@ -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>
|
||||
45
resources/js/Components/Common/Project/ProjectTable.vue
Normal file
45
resources/js/Components/Common/Project/ProjectTable.vue
Normal 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>
|
||||
@@ -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>
|
||||
87
resources/js/Components/Common/Project/ProjectTableRow.vue
Normal file
87
resources/js/Components/Common/Project/ProjectTableRow.vue
Normal 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>
|
||||
9
resources/js/Components/Common/TabBar/TabBar.vue
Normal file
9
resources/js/Components/Common/TabBar/TabBar.vue
Normal 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>
|
||||
29
resources/js/Components/Common/TabBar/TabBarItem.vue
Normal file
29
resources/js/Components/Common/TabBar/TabBarItem.vue
Normal 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>
|
||||
64
resources/js/Components/Common/Tag/TagCreateModal.vue
Normal file
64
resources/js/Components/Common/Tag/TagCreateModal.vue
Normal 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>
|
||||
@@ -102,7 +102,9 @@ function toggleTag(newValue: string) {
|
||||
} else {
|
||||
model.value.push(newValue);
|
||||
}
|
||||
nextTick(() => {
|
||||
emit('changed');
|
||||
});
|
||||
}
|
||||
|
||||
function moveHighlightUp() {
|
||||
@@ -155,14 +157,14 @@ const highlightedItem = computed(() => {
|
||||
@keydown.up.prevent="moveHighlightUp"
|
||||
@keydown.down.prevent="moveHighlightDown"
|
||||
ref="searchInput"
|
||||
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-seperator focus:border-card-background-seperator w-full"
|
||||
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||
placeholder="Search for a tag..." />
|
||||
<div ref="dropdownViewport" class="w-60">
|
||||
<div
|
||||
v-if="searchValue.length > 0 && filteredTags.length === 0"
|
||||
class="bg-card-background-active">
|
||||
<div
|
||||
class="flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t rounded-b-lg border-card-background-seperator">
|
||||
class="flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t rounded-b-lg border-card-background-separator">
|
||||
<PlusCircleIcon
|
||||
class="w-5 flex-shrink-0"></PlusCircleIcon>
|
||||
<span>Add "{{ searchValue }}" as a new Tag</span>
|
||||
|
||||
@@ -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>
|
||||
42
resources/js/Components/Common/Tag/TagTable.vue
Normal file
42
resources/js/Components/Common/Tag/TagTable.vue
Normal 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>
|
||||
14
resources/js/Components/Common/Tag/TagTableHeading.vue
Normal file
14
resources/js/Components/Common/Tag/TagTableHeading.vue
Normal 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>
|
||||
30
resources/js/Components/Common/Tag/TagTableRow.vue
Normal file
30
resources/js/Components/Common/Tag/TagTableRow.vue
Normal 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>
|
||||
@@ -56,7 +56,7 @@ watch(focused, (newValue, oldValue) => {
|
||||
<template #content>
|
||||
<div
|
||||
ref="dropdownContent"
|
||||
class="grid grid-cols-2 divide-x divide-card-background-seperator text-center py-1">
|
||||
class="grid grid-cols-2 divide-x divide-card-background-separator text-center py-1">
|
||||
<div>
|
||||
<div class="font-bold text-white text-sm pb-1">
|
||||
Start
|
||||
|
||||
@@ -74,6 +74,11 @@ function deleteTimeEntry() {
|
||||
function updateTimeEntryDescription(description: string) {
|
||||
updateTimeEntry({ ...props.timeEntry, description });
|
||||
}
|
||||
|
||||
function updateTimeEntryTags(tags: string[]) {
|
||||
console.log(tags);
|
||||
updateTimeEntry({ ...props.timeEntry, tags });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -97,7 +102,7 @@ function updateTimeEntryDescription(description: string) {
|
||||
</div>
|
||||
<div class="flex items-center font-medium space-x-2">
|
||||
<TimeEntryRowTagDropdown
|
||||
@changed="updateTimeEntry(timeEntry)"
|
||||
@changed="updateTimeEntryTags"
|
||||
:modelValue="timeEntry.tags"></TimeEntryRowTagDropdown>
|
||||
<div>
|
||||
<TimeEntryRangeSelector
|
||||
|
||||
@@ -19,7 +19,7 @@ const timeEntryTags = computed<Tag[]>(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagDropdown @changed="emit('changed')" v-model="model">
|
||||
<TagDropdown @changed="emit('changed', model)" v-model="model">
|
||||
<template #trigger>
|
||||
<button data-testid="time_entry_tag_dropdown">
|
||||
<TagBadge
|
||||
|
||||
@@ -25,7 +25,7 @@ const iconColorClasses = computed(() => {
|
||||
:class="
|
||||
twMerge(
|
||||
iconColorClasses,
|
||||
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-seperator hover:bg-card-background-seperator rounded-full w-11 h-11 flex items-center justify-center'
|
||||
'flex-shrink-0 ring-0 focus:outline-none focus:ring-0 transition focus:bg-card-background-separator hover:bg-card-background-separator rounded-full w-11 h-11 flex items-center justify-center'
|
||||
)
|
||||
">
|
||||
<TagIcon class="w-7 h-7"></TagIcon>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</h3>
|
||||
<div
|
||||
class="rounded-lg bg-card-background border border-card-border flex-1 flex items-center">
|
||||
<div class="divide-y divide-card-background-seperator w-full">
|
||||
<div class="divide-y divide-card-background-separator w-full">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,8 @@ const close = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-end px-6 py-4 bg-gray-100 text-end">
|
||||
<div
|
||||
class="flex flex-row justify-end px-6 py-4 border-t border-card-background-separator bg-card-background text-end">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -34,7 +34,7 @@ const hasActions = computed(() => !!useSlots().actions);
|
||||
|
||||
<div
|
||||
v-if="hasActions"
|
||||
class="flex items-center justify-end px-4 py-3 bg-card-background border-t border-card-background-seperator text-end sm:px-6 shadow sm:rounded-bl-md sm:rounded-br-md">
|
||||
class="flex items-center justify-end px-4 py-3 bg-card-background border-t border-card-background-separator text-end sm:px-6 shadow sm:rounded-bl-md sm:rounded-br-md">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -64,7 +64,7 @@ const maxWidthClass = computed(() => {
|
||||
<transition leave-active-class="duration-200">
|
||||
<div
|
||||
v-show="show"
|
||||
class="fixed inset-0 overflow-y-auto px-4 py-6 sm:px-0 z-50"
|
||||
class="fixed inset-0 overflow-y-auto px-4 py-32 sm:px-0 z-50"
|
||||
scroll-region>
|
||||
<transition
|
||||
enter-active-class="ease-out duration-300"
|
||||
@@ -75,10 +75,10 @@ const maxWidthClass = computed(() => {
|
||||
leave-to-class="opacity-0">
|
||||
<div
|
||||
v-show="show"
|
||||
class="fixed inset-0 transform transition-all"
|
||||
class="fixed inset-0 transform transition-all backdrop-blur-sm"
|
||||
@click="close">
|
||||
<div
|
||||
class="absolute inset-0 bg-card-background opacity-75" />
|
||||
class="absolute inset-0 bg-default-background opacity-30" />
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
@@ -91,7 +91,7 @@ const maxWidthClass = computed(() => {
|
||||
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
||||
<div
|
||||
v-show="show"
|
||||
class="mb-6 bg-card-background rounded-lg overflow-hidden shadow-xl transform transition-all sm:w-full sm:mx-auto"
|
||||
class="mb-6 bg-card-background border border-card-border rounded-lg shadow-xl transform transition-all sm:w-full sm:mx-auto"
|
||||
:class="maxWidthClass">
|
||||
<slot v-if="show" />
|
||||
</div>
|
||||
|
||||
@@ -90,7 +90,7 @@ const switchToTeam = (team: Organization) => {
|
||||
|
||||
<!-- Organization Switcher -->
|
||||
<template v-if="page.props.auth.user.all_teams.length > 1">
|
||||
<div class="border-t border-card-background-seperator" />
|
||||
<div class="border-t border-card-background-separator" />
|
||||
|
||||
<div class="block px-4 py-2 text-xs text-muted">
|
||||
Switch Teams
|
||||
|
||||
@@ -14,7 +14,7 @@ withDefaults(
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
class="inline-flex items-center px-4 py-2 bg-white/90 border border-transparent rounded-md font-semibold text-xs text-black uppercase tracking-widest hover:bg-white focus:bg-white active:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition ease-in-out duration-150">
|
||||
class="inline-flex items-center px-3 py-2 bg-accent-300/10 border border-accent-300/20 rounded-md font-medium text-sm text-white hover:bg-accent-300/20 focus:bg-white active:bg-accent-300/20 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition ease-in-out duration-150">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { HtmlButtonType } from '@/types/dom';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { type Component } from 'vue';
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
type: HtmlButtonType;
|
||||
icon?: Component;
|
||||
}>(),
|
||||
{
|
||||
type: 'button',
|
||||
@@ -14,7 +17,18 @@ withDefaults(
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
class="inline-flex items-center px-4 py-2 bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover rounded-md font-semibold text-xs text-white uppercase tracking-widest shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25 transition ease-in-out duration-150">
|
||||
class="bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover shadow-sm transition text-white text-sm px-3 py-2 rounded-lg font-semibold inline-flex items-center space-x-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25 ease-in-out">
|
||||
<span
|
||||
:class="
|
||||
twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')
|
||||
">
|
||||
<component
|
||||
v-if="props.icon"
|
||||
:is="props.icon"
|
||||
class="w-5 h-5 -ml-1"></component>
|
||||
<span>
|
||||
<slot />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="hidden sm:block">
|
||||
<div class="py-8">
|
||||
<div class="border-t border-default-background-seperator" />
|
||||
<div class="border-t border-default-background-separator" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -81,7 +81,34 @@ function pauseLiveTimerUpdate() {
|
||||
|
||||
function updateTimerAndStartLiveTimerUpdate() {
|
||||
const time = parse(temporaryCustomTimerEntry.value, 's');
|
||||
if (time && time > 0) {
|
||||
|
||||
if (isNumeric(temporaryCustomTimerEntry.value)) {
|
||||
const newStartDate = dayjs().subtract(
|
||||
parseInt(temporaryCustomTimerEntry.value),
|
||||
'm'
|
||||
);
|
||||
currentTimeEntry.value.start = newStartDate.utc().format();
|
||||
if (currentTimeEntry.value.id !== '') {
|
||||
currentTimeEntryStore.updateTimer();
|
||||
} else {
|
||||
currentTimeEntryStore.startTimer();
|
||||
}
|
||||
} else if (isHHMM(temporaryCustomTimerEntry.value)) {
|
||||
const results = parseHHMM(temporaryCustomTimerEntry.value);
|
||||
if (results) {
|
||||
const newStartDate = dayjs()
|
||||
.subtract(parseInt(results[1]), 'h')
|
||||
.subtract(parseInt(results[2]), 'm');
|
||||
currentTimeEntry.value.start = newStartDate.utc().format();
|
||||
if (currentTimeEntry.value.id !== '') {
|
||||
currentTimeEntryStore.updateTimer();
|
||||
} else {
|
||||
currentTimeEntryStore.startTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
// try to parse natural language like "1h 30m"
|
||||
else if (time && time > 1) {
|
||||
const newStartDate = dayjs().subtract(time, 's');
|
||||
currentTimeEntry.value.start = newStartDate.utc().format();
|
||||
if (currentTimeEntry.value.id !== '') {
|
||||
@@ -90,10 +117,37 @@ function updateTimerAndStartLiveTimerUpdate() {
|
||||
currentTimeEntryStore.startTimer();
|
||||
}
|
||||
}
|
||||
// fallback to minutes if just a number is given
|
||||
now.value = dayjs().utc();
|
||||
temporaryCustomTimerEntry.value = '';
|
||||
startLiveTimer();
|
||||
}
|
||||
|
||||
function isNumeric(value: string) {
|
||||
return /^-?\d+$/.test(value);
|
||||
}
|
||||
|
||||
const HHMMtimeRegex = /^([0-9]{1,2}):([0-5]?[0-9])$/;
|
||||
|
||||
function isHHMM(value: string): boolean {
|
||||
return HHMMtimeRegex.test(value);
|
||||
}
|
||||
|
||||
function parseHHMM(value: string): string[] | null {
|
||||
return value.match(HHMMtimeRegex);
|
||||
}
|
||||
|
||||
function startTimerIfNotActive() {
|
||||
if (!isActive.value) {
|
||||
onToggleButtonPress(true);
|
||||
}
|
||||
}
|
||||
|
||||
function onTimeEntryEnterPress() {
|
||||
updateTimerAndStartLiveTimerUpdate();
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
activeElement?.blur();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -106,6 +160,7 @@ function updateTimerAndStartLiveTimerUpdate() {
|
||||
placeholder="What are you working on?"
|
||||
data-testid="time_entry_description"
|
||||
v-model="currentTimeEntry.description"
|
||||
@keydown.enter="startTimerIfNotActive"
|
||||
@blur="updateTimeEntry"
|
||||
class="w-full rounded-l-lg py-3 px-5 text-lg text-white focus:bg-card-background-active font-medium bg-transparent border-none placeholder-muted focus:ring-0 transition"
|
||||
type="text" />
|
||||
@@ -127,7 +182,7 @@ function updateTimerAndStartLiveTimerUpdate() {
|
||||
@focus="pauseLiveTimerUpdate"
|
||||
data-testid="time_entry_time"
|
||||
@blur="updateTimerAndStartLiveTimerUpdate"
|
||||
@keydown.enter="updateTimerAndStartLiveTimerUpdate"
|
||||
@keydown.enter="onTimeEntryEnterPress"
|
||||
v-model="currentTime"
|
||||
class="w-40 h-full text-white py-4 rounded-r-lg text-center px-4 text-lg font-bold bg-card-background border-none placeholder-muted focus:ring-0 transition focus:bg-card-background-active"
|
||||
type="text" />
|
||||
|
||||
@@ -6,12 +6,12 @@ import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
|
||||
import {
|
||||
ChartBarIcon,
|
||||
ClockIcon,
|
||||
Cog6ToothIcon,
|
||||
FolderIcon,
|
||||
HomeIcon,
|
||||
TagIcon,
|
||||
UserCircleIcon,
|
||||
UserGroupIcon,
|
||||
TagIcon,
|
||||
Cog6ToothIcon,
|
||||
} from '@heroicons/vue/20/solid';
|
||||
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
|
||||
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
|
||||
@@ -19,14 +19,22 @@ import MainContainer from '@/Pages/MainContainer.vue';
|
||||
import { onMounted } from 'vue';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
import { useTasksStore } from '@/utils/useTasks';
|
||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import { useMembersStore } from '@/utils/useMembers';
|
||||
|
||||
defineProps({
|
||||
title: String,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await useProjectsStore().fetchProjects();
|
||||
await useTagsStore().fetchTags();
|
||||
useProjectsStore().fetchProjects();
|
||||
useTasksStore().fetchTasks();
|
||||
useTagsStore().fetchTags();
|
||||
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
|
||||
useClientsStore().fetchClients();
|
||||
useMembersStore().fetchMembers();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -35,10 +43,10 @@ onMounted(async () => {
|
||||
<div
|
||||
class="flex-shrink-0 h-screen fixed w-[230px] 2xl:w-[270px] px-2.5 2xl:px-4 py-4 flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="border-b border-default-background-seperator pb-2">
|
||||
<div class="border-b border-default-background-separator pb-2">
|
||||
<OrganizationSwitcher></OrganizationSwitcher>
|
||||
</div>
|
||||
<div class="border-b border-default-background-seperator">
|
||||
<div class="border-b border-default-background-separator">
|
||||
<CurrentSidebarTimer></CurrentSidebarTimer>
|
||||
</div>
|
||||
<nav>
|
||||
@@ -58,7 +66,8 @@ onMounted(async () => {
|
||||
<NavigationSidebarItem
|
||||
title="Reporting"
|
||||
:icon="ChartBarIcon"
|
||||
:href="route('dashboard')"></NavigationSidebarItem>
|
||||
:current="route().current('reporting')"
|
||||
:href="route('reporting')"></NavigationSidebarItem>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -69,28 +78,31 @@ onMounted(async () => {
|
||||
<NavigationSidebarItem
|
||||
title="Projects"
|
||||
:icon="FolderIcon"
|
||||
:href="route('dashboard')"
|
||||
:href="route('projects')"
|
||||
:current="
|
||||
route().current('dashboard')
|
||||
route().current('projects')
|
||||
"></NavigationSidebarItem>
|
||||
<NavigationSidebarItem
|
||||
title="Clients"
|
||||
:icon="UserCircleIcon"
|
||||
:href="route('dashboard')"></NavigationSidebarItem>
|
||||
:current="route().current('clients')"
|
||||
:href="route('clients')"></NavigationSidebarItem>
|
||||
<NavigationSidebarItem
|
||||
title="Members"
|
||||
:icon="UserGroupIcon"
|
||||
:href="route('dashboard')"></NavigationSidebarItem>
|
||||
:current="route().current('members')"
|
||||
:href="route('members')"></NavigationSidebarItem>
|
||||
<NavigationSidebarItem
|
||||
title="Tags"
|
||||
:icon="TagIcon"
|
||||
:href="route('dashboard')"></NavigationSidebarItem>
|
||||
:current="route().current('tags')"
|
||||
:href="route('tags')"></NavigationSidebarItem>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
class="border-t border-default-background-seperator pt-3 flex justify-between pr-4 items-center">
|
||||
class="border-t border-default-background-separator pt-3 flex justify-between pr-4 items-center">
|
||||
<NavigationSidebarItem
|
||||
class="flex-1"
|
||||
title="Settings"
|
||||
@@ -106,11 +118,11 @@ onMounted(async () => {
|
||||
<Banner />
|
||||
|
||||
<div
|
||||
class="min-h-screen bg-default-background border-l border-default-background-seperator">
|
||||
class="min-h-screen bg-default-background border-l border-default-background-separator">
|
||||
<!-- Page Heading -->
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="bg-default-background border-b border-default-background-seperator shadow">
|
||||
class="bg-default-background border-b border-default-background-separator shadow">
|
||||
<div class="pt-8 pb-3">
|
||||
<MainContainer>
|
||||
<slot name="header" />
|
||||
|
||||
45
resources/js/Pages/Clients.vue
Normal file
45
resources/js/Pages/Clients.vue
Normal 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>
|
||||
@@ -50,11 +50,11 @@ const props = defineProps<{
|
||||
<template>
|
||||
<AppLayout title="Dashboard" data-testid="dashboard_view">
|
||||
<MainContainer
|
||||
class="pt-8 pb-6 border-b border-default-background-seperator">
|
||||
class="pt-8 pb-6 border-b border-default-background-separator">
|
||||
<TimeTracker></TimeTracker>
|
||||
</MainContainer>
|
||||
<MainContainer
|
||||
class="grid gap-x-6 grid-cols-2 xl:grid-cols-4 pt-5 pb-6 border-b border-default-background-seperator items-stretch">
|
||||
class="grid gap-x-6 grid-cols-2 xl:grid-cols-4 pt-5 pb-6 border-b border-default-background-separator items-stretch">
|
||||
<RecentlyTrackedTasksCard
|
||||
:latestTasks="props.latestTasks"></RecentlyTrackedTasksCard>
|
||||
<LastSevenDaysCard
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div class="sm:px-6 lg:px-8 3xl:px-12 mx-auto">
|
||||
<div class="px-4 sm:px-6 lg:px-8 3xl:px-12 mx-auto">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
47
resources/js/Pages/Members.vue
Normal file
47
resources/js/Pages/Members.vue
Normal 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>
|
||||
44
resources/js/Pages/Projects.vue
Normal file
44
resources/js/Pages/Projects.vue
Normal 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>
|
||||
13
resources/js/Pages/Reporting.vue
Normal file
13
resources/js/Pages/Reporting.vue
Normal 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>
|
||||
31
resources/js/Pages/Tags.vue
Normal file
31
resources/js/Pages/Tags.vue
Normal 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>
|
||||
@@ -82,7 +82,8 @@ const updateTeamName = () => {
|
||||
class="mt-1 block w-full border-input-border bg-input-background text-white focus:border-input-border-active rounded-md shadow-sm">
|
||||
<option value="" disabled>Select a currency</option>
|
||||
<option
|
||||
v-for="(currencyTranslated, currencyKey) in $page.props.currencies"
|
||||
v-for="(currencyTranslated, currencyKey) in $page.props
|
||||
.currencies"
|
||||
:key="currencyKey"
|
||||
:value="currencyKey">
|
||||
{{ currencyKey }} - {{ currencyTranslated }}
|
||||
|
||||
@@ -49,7 +49,7 @@ const groupedTimeEntries = computed(() => {
|
||||
<template>
|
||||
<AppLayout title="Dashboard" data-testid="dashboard_view">
|
||||
<MainContainer
|
||||
class="py-8 border-b border-default-background-seperator">
|
||||
class="py-8 border-b border-default-background-separator">
|
||||
<TimeTracker></TimeTracker>
|
||||
</MainContainer>
|
||||
<div v-for="(value, key) in groupedTimeEntries" :key="key">
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ApiOf, ZodiosResponseByAlias } from '@zodios/core';
|
||||
import type {
|
||||
ApiOf,
|
||||
ZodiosResponseByAlias,
|
||||
ZodiosBodyByAlias,
|
||||
} from '@zodios/core';
|
||||
import { api } from '../../../openapi.json.client';
|
||||
|
||||
export type SolidTimeApi = ApiOf<typeof api>;
|
||||
@@ -15,5 +19,29 @@ export type ProjectResponse = ZodiosResponseByAlias<
|
||||
>;
|
||||
export type Project = ProjectResponse['data'][0];
|
||||
|
||||
export type CreateProjectBody = ZodiosBodyByAlias<
|
||||
SolidTimeApi,
|
||||
'createProject'
|
||||
>;
|
||||
|
||||
export type CreateClientBody = ZodiosBodyByAlias<SolidTimeApi, 'createClient'>;
|
||||
|
||||
export type TagIndexResponse = ZodiosResponseByAlias<SolidTimeApi, 'getTags'>;
|
||||
export type Tag = TagIndexResponse['data'][0];
|
||||
|
||||
export type TaskIndexResponse = ZodiosResponseByAlias<SolidTimeApi, 'getTasks'>;
|
||||
export type Task = TaskIndexResponse['data'][0];
|
||||
|
||||
export type ClientIndexResponse = ZodiosResponseByAlias<
|
||||
SolidTimeApi,
|
||||
'getClients'
|
||||
>;
|
||||
export type Client = ClientIndexResponse['data'][0];
|
||||
|
||||
export type MemberIndexResponse = ZodiosResponseByAlias<
|
||||
SolidTimeApi,
|
||||
'getMembers'
|
||||
>;
|
||||
export type Member = MemberIndexResponse['data'][0];
|
||||
|
||||
export type CreateTagBody = ZodiosBodyByAlias<SolidTimeApi, 'createTag'>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const colors = [
|
||||
export const colors = [
|
||||
'#ef5350',
|
||||
'#ec407a',
|
||||
'#ab47bc',
|
||||
|
||||
61
resources/js/utils/useClients.ts
Normal file
61
resources/js/utils/useClients.ts
Normal 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 };
|
||||
});
|
||||
26
resources/js/utils/useMembers.ts
Normal file
26
resources/js/utils/useMembers.ts
Normal 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 };
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { api } from '../../../openapi.json.client';
|
||||
import { computed, ref } from 'vue';
|
||||
import type { Project, ProjectResponse } from '@/utils/api';
|
||||
import type { CreateProjectBody, Project, ProjectResponse } from '@/utils/api';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
|
||||
export const useProjectsStore = defineStore('projects', () => {
|
||||
@@ -18,9 +18,37 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject(projectBody: CreateProjectBody) {
|
||||
const organization = getCurrentOrganizationId();
|
||||
if (organization) {
|
||||
await api.createProject(projectBody, {
|
||||
params: {
|
||||
organization: organization,
|
||||
},
|
||||
});
|
||||
await fetchProjects();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProject(projectId: string) {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId) {
|
||||
await api.deleteProject(
|
||||
{},
|
||||
{
|
||||
params: {
|
||||
organization: organizationId,
|
||||
project: projectId,
|
||||
},
|
||||
}
|
||||
);
|
||||
await fetchProjects();
|
||||
}
|
||||
}
|
||||
|
||||
const projects = computed<Project[]>(
|
||||
() => projectResponse.value?.data || []
|
||||
);
|
||||
|
||||
return { projects, fetchProjects };
|
||||
return { projects, fetchProjects, createProject, deleteProject };
|
||||
});
|
||||
|
||||
@@ -23,6 +23,22 @@ export const useTagsStore = defineStore('tags', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTag(tagId: string) {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId) {
|
||||
await api.deleteTag(
|
||||
{},
|
||||
{
|
||||
params: {
|
||||
organization: organizationId,
|
||||
tag: tagId,
|
||||
},
|
||||
}
|
||||
);
|
||||
await fetchTags();
|
||||
}
|
||||
}
|
||||
|
||||
async function createTag(name: string) {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId) {
|
||||
@@ -45,5 +61,5 @@ export const useTagsStore = defineStore('tags', () => {
|
||||
}
|
||||
}
|
||||
|
||||
return { tags, fetchTags, createTag };
|
||||
return { tags, fetchTags, createTag, deleteTag };
|
||||
});
|
||||
|
||||
69
resources/js/utils/useTasks.ts
Normal file
69
resources/js/utils/useTasks.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
@@ -18,7 +18,7 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
|
||||
organization: organizationId,
|
||||
},
|
||||
queries: {
|
||||
only_full_dates: true,
|
||||
only_full_dates: 'true',
|
||||
},
|
||||
});
|
||||
timeEntries.value = timeEntriesResponse.data;
|
||||
@@ -37,7 +37,7 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
|
||||
organization: organizationId,
|
||||
},
|
||||
queries: {
|
||||
only_full_dates: true,
|
||||
only_full_dates: 'true',
|
||||
before: dayjs(latestTimeEntry.start).utc().format(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Web\DashboardController;
|
||||
use App\Http\Controllers\Web\HomeController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -30,4 +31,27 @@ Route::middleware([
|
||||
Route::get('/time', function () {
|
||||
return Inertia::render('Time');
|
||||
})->name('time');
|
||||
|
||||
Route::get('/reporting', function () {
|
||||
return Inertia::render('Reporting');
|
||||
})->name('reporting');
|
||||
|
||||
Route::get('/projects', function () {
|
||||
return Inertia::render('Projects');
|
||||
})->name('projects');
|
||||
|
||||
Route::get('/clients', function () {
|
||||
return Inertia::render('Clients');
|
||||
})->name('clients');
|
||||
|
||||
Route::get('/members', function () {
|
||||
return Inertia::render('Members', [
|
||||
'availableRoles' => array_values(Jetstream::$roles),
|
||||
]);
|
||||
})->name('members');
|
||||
|
||||
Route::get('/tags', function () {
|
||||
return Inertia::render('Tags');
|
||||
})->name('tags');
|
||||
|
||||
});
|
||||
|
||||
@@ -20,23 +20,34 @@ export default {
|
||||
colors: {
|
||||
'white': '#D9DCFB',
|
||||
'default-background': '#040618',
|
||||
'default-background-seperator': '#13152B',
|
||||
'default-background-separator': '#13152B',
|
||||
'card-background': 'var(--theme-color-card-background)',
|
||||
'card-background-active': '#1C1E34',
|
||||
'card-background-seperator': '#262A51',
|
||||
'card-border': '#242940',
|
||||
'card-border-active': '#2A3461',
|
||||
'card-background-active':
|
||||
'var(--theme-color-card-background-active)',
|
||||
'card-background-separator': '#262A51',
|
||||
'card-border': 'var(--theme-color-card-border)',
|
||||
'card-border-active': 'var(--theme-color-card-border-active)',
|
||||
'muted': '#8F93B7',
|
||||
'icon-default': 'var(--theme-color-icon-default)',
|
||||
'tab-background': 'var(--theme-color-tab-background)',
|
||||
'tab-background-active':
|
||||
'var(--theme-color-tab-background-active)',
|
||||
'tab-border': 'var(--theme-color-tab-border)',
|
||||
'icon-active': '#787DA8',
|
||||
'menu-active': '#13152B',
|
||||
'input-placeholder': '#42466C',
|
||||
'input-border': '#242740',
|
||||
'input-border-active': '#797EA8',
|
||||
'input-background': '#030513',
|
||||
'button-secondary-background': '#22243E',
|
||||
'button-secondary-background-hover': '#292C4D',
|
||||
'button-secondary-border': '#353961',
|
||||
'button-secondary-background':
|
||||
'var(--theme-color-card-background)',
|
||||
'button-secondary-background-hover':
|
||||
'var(--theme-color-card-background-active)',
|
||||
'button-secondary-border': 'var(--theme-color-card-border)',
|
||||
'row-separator': 'var(--theme-color-row-separator-background)',
|
||||
'row-heading-background':
|
||||
'var(--theme-color-row-heading-background)',
|
||||
'row-heading-border': 'var(--theme-color-row-heading-border)',
|
||||
'accent': {
|
||||
'50': '#eff7ff',
|
||||
'100': '#daecff',
|
||||
|
||||
Reference in New Issue
Block a user