add dashboard frontend

This commit is contained in:
Gregor Vostrak
2024-03-11 18:02:54 +01:00
parent e8912650c0
commit 20fc123c36
86 changed files with 5124 additions and 849 deletions

View File

@@ -2,11 +2,7 @@
require("@rushstack/eslint-patch/modern-module-resolution")
module.exports = {
extends: [
'plugin:vue/vue3-essential',
'@vue/eslint-config-typescript/recommended',
'@vue/eslint-config-prettier'
],
extends: ['plugin:vue/vue3-essential', '@vue/eslint-config-typescript/recommended', '@vue/eslint-config-prettier'],
rules: {
'vue/multi-word-component-names': 'off',
}

View File

@@ -18,6 +18,8 @@ cp .env.example .env
./vendor/bin/sail artisan migrate:fresh --seed
./vendor/bin/sail php artisan passport:install
./vendor/bin/sail npm install
./vendor/bin/sail npm run build
@@ -52,6 +54,19 @@ npx playwright install
npx playwright codegen solidtime.test
```
## E2E Troubleshooting
If the E2E tests are not working consistently and fail with a timeout during the authentication, you might want to delete the `test-results/.auth` directory to force new test accounts to be created.
## Generate ZOD Client
The Zodius HTTP client is generated using the following command:
```bash
npm run generate:zod
```
## Contributing
This project is in a very early stage. The structure and APIs are still subject to change and not stable.

View File

@@ -28,6 +28,8 @@ class ProjectController extends Controller
* Get projects
*
* @throws AuthorizationException
*
* @operationId getProjects
*/
public function index(Organization $organization): JsonResource
{
@@ -43,6 +45,8 @@ class ProjectController extends Controller
* Get project
*
* @throws AuthorizationException
*
* @operationId getProject
*/
public function show(Organization $organization, Project $project): JsonResource
{
@@ -57,6 +61,8 @@ class ProjectController extends Controller
* Create project
*
* @throws AuthorizationException
*
* @operationId createProject
*/
public function store(Organization $organization, ProjectStoreRequest $request): JsonResource
{
@@ -75,6 +81,8 @@ class ProjectController extends Controller
* Update project
*
* @throws AuthorizationException
*
* @operationId updateProject
*/
public function update(Organization $organization, Project $project, ProjectUpdateRequest $request): JsonResource
{
@@ -90,6 +98,8 @@ class ProjectController extends Controller
* Delete project
*
* @throws AuthorizationException
*
* @operationId deleteProject
*/
public function destroy(Organization $organization, Project $project): JsonResponse
{

View File

@@ -27,6 +27,8 @@ class TagController extends Controller
* Get tags
*
* @throws AuthorizationException
*
* @operationId getTags
*/
public function index(Organization $organization): TagCollection
{
@@ -44,6 +46,8 @@ class TagController extends Controller
* Create tag
*
* @throws AuthorizationException
*
* @operationId createTag
*/
public function store(Organization $organization, TagStoreRequest $request): TagResource
{
@@ -61,6 +65,8 @@ class TagController extends Controller
* Update tag
*
* @throws AuthorizationException
*
* @operationId updateTag
*/
public function update(Organization $organization, Tag $tag, TagUpdateRequest $request): TagResource
{
@@ -76,6 +82,8 @@ class TagController extends Controller
* Delete tag
*
* @throws AuthorizationException
*
* @operationId deleteTag
*/
public function destroy(Organization $organization, Tag $tag): JsonResponse
{

View File

@@ -32,6 +32,8 @@ class TimeEntryController extends Controller
* Get time entries
*
* @throws AuthorizationException
*
* @operationId getTimeEntries
*/
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
{
@@ -103,6 +105,8 @@ class TimeEntryController extends Controller
* Create time entry
*
* @throws AuthorizationException|TimeEntryStillRunning
*
* @operationId createTimeEntry
*/
public function store(Organization $organization, TimeEntryStoreRequest $request): JsonResource
{
@@ -120,7 +124,7 @@ class TimeEntryController extends Controller
$timeEntry = new TimeEntry();
$timeEntry->fill($request->validated());
$timeEntry->description = $request->get('description', '');
$timeEntry->description = $request->get('description') ?? '';
$timeEntry->organization()->associate($organization);
$timeEntry->save();
@@ -131,6 +135,8 @@ class TimeEntryController extends Controller
* Update time entry
*
* @throws AuthorizationException
*
* @operationId updateTimeEntry
*/
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
{
@@ -141,6 +147,7 @@ class TimeEntryController extends Controller
}
$timeEntry->fill($request->validated());
$timeEntry->description = $request->get('description', $timeEntry->description) ?? '';
$timeEntry->save();
return new TimeEntryResource($timeEntry);
@@ -150,6 +157,8 @@ class TimeEntryController extends Controller
* Delete time entry
*
* @throws AuthorizationException
*
* @operationId deleteTimeEntry
*/
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
{

View File

@@ -42,7 +42,7 @@ class TimeEntryUpdateRequest extends FormRequest
],
// End of time entry (ISO 8601 format, UTC timezone)
'end' => [
'required',
'present',
'nullable',
'date', // TODO
'after:start',

View File

@@ -30,8 +30,8 @@ class TimeEntryResource extends BaseResource
/**
* @var string|null $end End of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z)
*/
'end' => $this->formatDateTime($this->resource->start),
/** @var int $duration Duration of time entry in seconds */
'end' => $this->formatDateTime($this->resource->end),
/** @var int|null $duration Duration of time entry in seconds */
'duration' => $this->resource->getDuration()?->seconds,
/** @var string|null $description Description of time entry */
'description' => $this->resource->description,
@@ -42,7 +42,7 @@ class TimeEntryResource extends BaseResource
/** @var string $user_id ID of user */
'user_id' => $this->resource->user_id,
/** @var array<string> $tags List of tag IDs */
'tags' => $this->resource->tags,
'tags' => $this->resource->tags ?? [],
];
}
}

View File

@@ -94,7 +94,7 @@ class JetstreamServiceProvider extends ServiceProvider
'tags:update',
'tags:delete',
'organizations:view',
])->description('Editor users have the ability to read, create, and update.');
])->description('Managers have the ability to read, create, and update their own time entries as well as those of their team.');
Jetstream::role('employee', 'Employee', [
'projects:view',
@@ -104,6 +104,6 @@ class JetstreamServiceProvider extends ServiceProvider
'time-entries:update:own',
'time-entries:delete:own',
'organizations:view',
])->description('Editor users have the ability to read, create, and update.');
])->description('Employees have the ability to read, create, and update their own time entries.');
}
}

View File

@@ -64,9 +64,9 @@ return [
* ```
*/
'servers' => [
'Production' => 'https://app.solidtime.io',
'Staging' => 'https://app.staging.solidtime.io',
'Local' => 'https://soldtime.test',
'Production' => 'https://app.solidtime.io/api',
'Staging' => 'https://app.staging.solidtime.io/api',
'Local' => 'https://soldtime.test/api',
],
'middleware' => [

View File

@@ -65,6 +65,8 @@ services:
image: mcr.microsoft.com/playwright:v1.41.1-jammy
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
working_dir: /src
extra_hosts:
- "solidtime.test:${REVERSE_PROXY_IP:-10.100.100.10}"
labels:
- "traefik.enable=true"
- "traefik.docker.network=${NETWORK_NAME}"

View File

@@ -8,9 +8,7 @@ async function registerNewUser(page, email, password) {
await page.getByLabel('Password', { exact: true }).fill(password);
await page.getByLabel('Confirm Password').fill(password);
await page.getByRole('button', { name: 'Register' }).click();
await expect(
page.getByRole('heading', { name: 'Dashboard' })
).toBeVisible();
await expect(page.getByTestId('dashboard_view')).toBeVisible();
}
test('can register, logout and log back in', async ({ page }) => {
@@ -18,20 +16,15 @@ test('can register, logout and log back in', async ({ page }) => {
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
const password = 'suchagreatpassword123';
await registerNewUser(page, email, password);
await expect(
page.getByRole('button', { name: "John's Organization" })
).toBeVisible();
await page.locator('#currentUserButton').click();
await page.getByRole('button', { name: 'Log Out' }).click();
await expect(page.getByTestId('dashboard_view')).toBeVisible();
await page.getByTestId('current_user_button').click();
await page.getByText('Log Out').click();
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/');
await page.goto(PLAYWRIGHT_BASE_URL + '/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Log in' }).click();
await expect(
page.getByRole('heading', { name: 'Dashboard' })
).toBeVisible();
await expect(page.getByTestId('dashboard_view')).toBeVisible();
});
test('can register and delete account', async ({ page }) => {

View File

@@ -3,8 +3,8 @@ import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
async function goToOrganizationSettings(page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await page.locator('#currentTeamButton').click();
await page.getByRole('link', { name: 'Team Settings' }).click();
await page.getByTestId('organization_switcher').click();
await page.getByText('Team Settings').click();
}
test('test that organization name can be updated', async ({ page }) => {
@@ -12,14 +12,28 @@ test('test that organization name can be updated', async ({ page }) => {
await page.getByLabel('Team Name').fill('NEW ORG NAME');
await page.getByLabel('Team Name').press('Enter');
await page.getByLabel('Team Name').press('Meta+r');
await expect(page.getByRole('navigation')).toContainText('NEW ORG NAME');
await expect(page.getByTestId('organization_switcher')).toContainText(
'NEW ORG NAME'
);
});
test('test that new editor can be invited', async ({ page }) => {
test('test that new manager can be invited', async ({ page }) => {
await goToOrganizationSettings(page);
const editorId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
await page.getByRole('button', { name: 'Editor' }).click();
await page.getByRole('button', { name: 'Manager' }).click();
await page.getByRole('button', { name: 'Add' }).click();
await page.reload();
await expect(page.getByRole('main')).toContainText(
`new+${editorId}@editor.test`
);
});
test('test that new employee can be invited', async ({ page }) => {
await goToOrganizationSettings(page);
const editorId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
await page.getByRole('button', { name: 'Employee' }).click();
await page.getByRole('button', { name: 'Add' }).click();
await page.reload();
await expect(page.getByRole('main')).toContainText(

507
e2e/timetracker.spec.ts Normal file
View File

@@ -0,0 +1,507 @@
import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
async function goToDashboard(page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
}
async function startOrStopTimerWithButton(page) {
await page
.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
.click();
}
async function assertThatTimerHasStarted(page) {
await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-red-400/80'
);
}
async function assertNewTimeEntryResponse(page) {
await 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([])
);
});
}
async function assertThatTimerIsStoped(page) {
await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
);
}
test('test that starting and stopping a timer without description and project works', async ({
page,
}) => {
await goToDashboard(page);
await startOrStopTimerWithButton(page);
await assertNewTimeEntryResponse(page);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(1500);
await startOrStopTimerWithButton(page);
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 assertThatTimerIsStoped(page);
});
test('test that starting and stopping a timer with a description works', async ({
page,
}) => {
await goToDashboard(page);
await page
.getByTestId('time_entry_description')
.fill('New Time Entry Description');
await startOrStopTimerWithButton(page);
await 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([])
);
});
await assertThatTimerHasStarted(page);
await page.waitForTimeout(500);
await startOrStopTimerWithButton(page);
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 ===
'New Time Entry Description' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
});
await assertThatTimerIsStoped(page);
});
test('test that starting and updating the description while running works', async ({
page,
}) => {
await goToDashboard(page);
await startOrStopTimerWithButton(page);
await 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([])
);
});
await assertThatTimerHasStarted(page);
await page.waitForTimeout(500);
await page
.getByTestId('time_entry_description')
.fill('New Time Entry Description');
await page.getByTestId('time_entry_description').press('Tab');
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 ===
'New Time Entry Description' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration === null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
});
await page.waitForTimeout(500);
await startOrStopTimerWithButton(page);
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 ===
'New Time Entry Description' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
});
await assertThatTimerIsStoped(page);
});
test('test that starting and updating the description while running works', async ({
page,
}) => {
await goToDashboard(page);
await startOrStopTimerWithButton(page);
await 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([])
);
});
await assertThatTimerHasStarted(page);
await page.waitForTimeout(500);
await page
.getByTestId('time_entry_description')
.fill('New Time Entry Description');
await page.getByTestId('time_entry_description').press('Tab');
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 ===
'New Time Entry Description' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration === null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
});
await page.waitForTimeout(500);
await startOrStopTimerWithButton(page);
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 ===
'New Time Entry Description' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
);
});
await assertThatTimerIsStoped(page);
});
test('test that starting and updating the time while running works', async ({
page,
}) => {
await goToDashboard(page);
await startOrStopTimerWithButton(page);
const createResponse = await 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([])
);
});
await assertThatTimerHasStarted(page);
await page.waitForTimeout(500);
await page.getByTestId('time_entry_time').fill('20min');
await page.getByTestId('time_entry_time').press('Tab');
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.start !==
(await createResponse.json()).data.start &&
(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 expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/);
await page.waitForTimeout(500);
await startOrStopTimerWithButton(page);
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 assertThatTimerIsStoped(page);
});
test('test that entering a time starts the timer on blur', async ({ page }) => {
await goToDashboard(page);
await page.getByTestId('time_entry_time').fill('20min');
await page.getByTestId('time_entry_time').press('Tab');
await 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([])
);
});
await assertThatTimerHasStarted(page);
await startOrStopTimerWithButton(page);
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 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 page.getByTestId('time_entry_time').press('Enter');
await 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([])
);
});
await assertThatTimerHasStarted(page);
await startOrStopTimerWithButton(page);
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 assertThatTimerIsStoped(page);
});
test('test that adding a new tag works', async ({ page }) => {
const newTagName = 'New Tag' + Math.floor(Math.random() * 10000);
await goToDashboard(page);
await page.getByTestId('tag_dropdown').click();
await page.getByTestId('tag_dropdown_search').fill(newTagName);
await page.getByTestId('tag_dropdown_search').press('Enter');
await page.waitForResponse(async (response) => {
return (
response.status() === 201 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.name === newTagName
);
});
await expect(page.getByTestId('tag_dropdown_search')).toHaveValue('');
await expect(page.getByTestId('tag_dropdown_entries')).toHaveText(
newTagName
);
});
test('test that adding a new tag when the timer is running', async ({
page,
}) => {
const newTagName = 'New Tag' + Math.floor(Math.random() * 10000);
await goToDashboard(page);
await startOrStopTimerWithButton(page);
await assertNewTimeEntryResponse(page);
await assertThatTimerHasStarted(page);
await page.getByTestId('tag_dropdown').click();
await page.getByTestId('tag_dropdown_search').fill(newTagName);
await page.getByTestId('tag_dropdown_search').press('Enter');
const tagCreateResponse = await page.waitForResponse(async (response) => {
return (
response.status() === 201 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.json()).data.name === newTagName
);
});
await expect(page.getByTestId('tag_dropdown_search')).toHaveValue('');
await expect(page.getByTestId('tag_dropdown_entries')).toHaveText(
newTagName
);
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])
);
});
await page.getByTestId('tag_dropdown_search').press('Escape');
await page.waitForTimeout(1000);
await startOrStopTimerWithButton(page);
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])
);
});
await assertThatTimerIsStoped(page);
});
// test that adding a new tag when the timer is running
// test that search is working

1
openapi.json Normal file

File diff suppressed because one or more lines are too long

833
openapi.json.client.ts Normal file
View File

@@ -0,0 +1,833 @@
import { makeApi, Zodios, type ZodiosOptions } from '@zodios/core';
import { z } from 'zod';
const ClientResource = z
.object({
id: z.string(),
name: z.string(),
created_at: z.string(),
updated_at: z.string(),
})
.passthrough();
const ClientCollection = z.array(ClientResource);
const OrganizationResource = z
.object({ id: z.string(), name: z.string(), is_personal: z.string() })
.passthrough();
const ProjectResource = z
.object({
id: z.string(),
name: z.string(),
color: z.string(),
client_id: z.union([z.string(), z.null()]),
})
.passthrough();
const ProjectCollection = z.array(ProjectResource);
const createProject_Body = z
.object({
name: z.string(),
color: z.string(),
client_id: z.union([z.string(), z.null()]).optional(),
})
.passthrough();
const TagResource = z
.object({
id: z.string(),
name: z.string(),
created_at: z.string(),
updated_at: z.string(),
})
.passthrough();
const TagCollection = z.array(TagResource);
const before = z.union([z.string(), z.null()]).optional();
const TimeEntryResource = z
.object({
id: z.string(),
start: z.string(),
end: z.union([z.string(), z.null()]),
duration: z.union([z.number(), z.null()]),
description: z.union([z.string(), z.null()]),
task_id: z.union([z.string(), z.null()]),
project_id: z.union([z.string(), z.null()]),
user_id: z.string(),
tags: z.array(z.string()),
})
.passthrough();
const TimeEntryCollection = z.array(TimeEntryResource);
const createTimeEntry_Body = z
.object({
user_id: z.string().uuid(),
task_id: z.union([z.string(), z.null()]).optional(),
start: z.string(),
end: z.union([z.string(), z.null()]).optional(),
description: z.union([z.string(), z.null()]).optional(),
tags: z.union([z.array(z.string()), z.null()]).optional(),
})
.passthrough();
const updateTimeEntry_Body = z
.object({
task_id: z.union([z.string(), z.null()]).optional(),
start: z.string(),
end: z.union([z.string(), z.null()]).optional(),
description: z.union([z.string(), z.null()]).optional(),
tags: z.union([z.array(z.string()), z.null()]).optional(),
})
.passthrough();
export const schemas = {
ClientResource,
ClientCollection,
OrganizationResource,
ProjectResource,
ProjectCollection,
createProject_Body,
TagResource,
TagCollection,
before,
TimeEntryResource,
TimeEntryCollection,
createTimeEntry_Body,
updateTimeEntry_Body,
};
const endpoints = makeApi([
{
method: 'get',
path: '/v1/organizations/:organization',
alias: 'v1.organizations.show',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.object({ data: OrganizationResource }).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: 'put',
path: '/v1/organizations/:organization',
alias: 'v1.organizations.update',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({ name: z.string() }).passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.object({ data: OrganizationResource }).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/clients',
alias: 'v1.clients.index',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.object({ data: ClientCollection }).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/clients',
alias: 'v1.clients.store',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({ name: z.string() }).passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.object({ data: ClientResource }).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/clients/:client',
alias: 'v1.clients.update',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({ name: z.string() }).passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
{
name: 'client',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.object({ data: ClientResource }).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/clients/:client',
alias: 'v1.clients.destroy',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({}).partial().passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
{
name: 'client',
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',
alias: 'getProjects',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.object({ data: ProjectCollection }).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',
alias: 'createProject',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: createProject_Body,
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.object({ data: ProjectResource }).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/projects/:project',
alias: 'getProject',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
{
name: 'project',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.object({ data: ProjectResource }).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: 'put',
path: '/v1/organizations/:organization/projects/:project',
alias: 'updateProject',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: createProject_Body,
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
{
name: 'project',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.object({ data: ProjectResource }).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/projects/:project',
alias: 'deleteProject',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({}).partial().passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
{
name: 'project',
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/tags',
alias: 'getTags',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.object({ data: TagCollection }).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/tags',
alias: 'createTag',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({ name: z.string() }).passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.object({ data: TagResource }).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/tags/:tag',
alias: 'updateTag',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({ name: z.string() }).passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
{
name: 'tag',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.object({ data: TagResource }).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/tags/:tag',
alias: 'deleteTag',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({}).partial().passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
{
name: 'tag',
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',
alias: 'getTimeEntries',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
{
name: 'user_id',
type: 'Query',
schema: z.string().uuid().optional(),
},
{
name: 'before',
type: 'Query',
schema: before,
},
{
name: 'after',
type: 'Query',
schema: before,
},
{
name: 'active',
type: 'Query',
schema: z.string().optional(),
},
{
name: 'limit',
type: 'Query',
schema: z.number().int().gte(1).lte(500).optional(),
},
{
name: 'only_full_dates',
type: 'Query',
schema: z.boolean().optional(),
},
],
response: z.object({ data: TimeEntryCollection }).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/time-entries',
alias: 'createTimeEntry',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: createTimeEntry_Body,
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.object({ data: TimeEntryResource }).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/time-entries/:timeEntry',
alias: 'updateTimeEntry',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: updateTimeEntry_Body,
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
{
name: 'timeEntry',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.object({ data: TimeEntryResource }).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/time-entries/:timeEntry',
alias: 'deleteTimeEntry',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({}).partial().passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
{
name: 'timeEntry',
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(),
},
],
},
]);
export const api = new Zodios('http://solidtime.test/api', endpoints);
export function createApiClient(baseUrl: string, options?: ZodiosOptions) {
return new Zodios(baseUrl, endpoints, options);
}

1086
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,8 @@
"lint": "eslint --ext .js,.vue,.ts --ignore-path .gitignore .",
"lint:fix": "eslint --fix --ext .js,.vue,.ts --ignore-path .gitignore .",
"type-check": "vue-tsc --noEmit",
"test:e2e": "npx playwright test"
"test:e2e": "npx playwright test",
"generate:zod": "npx openapi-zod-client http://localhost:80/docs/api.json --output openapi.json.client.ts --base-url http://solidtime.test/api"
},
"devDependencies": {
"@inertiajs/vue3": "^1.0.0",
@@ -20,6 +21,7 @@
"autoprefixer": "^10.4.7",
"axios": "^1.6.4",
"laravel-vite-plugin": "^1.0.0",
"openapi-zod-client": "^1.16.2",
"postcss": "^8.4.14",
"tailwindcss": "^3.1.0",
"typescript": "^5.3.3",
@@ -30,8 +32,16 @@
"ziggy-js": "^1.8.1"
},
"dependencies": {
"@heroicons/vue": "^2.1.1",
"@rushstack/eslint-patch": "^1.7.0",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^12.0.0"
"@vue/eslint-config-typescript": "^12.0.0",
"dayjs": "^1.11.10",
"echarts": "^5.5.0",
"parse-duration": "^1.1.0",
"pinia": "^2.1.7",
"radix-vue": "^1.4.9",
"tailwind-merge": "^2.2.1",
"vue-echarts": "^6.6.9"
}
}

View File

@@ -1,2 +1,2 @@
export const PLAYWRIGHT_BASE_URL =
process.env.PLAYWRIGHT_BASE_URL ?? 'http://laravel.test';
process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test';

View File

@@ -1,4 +1,4 @@
import { expect, test as baseTest } from '@playwright/test';
import { test as baseTest } from '@playwright/test';
import fs from 'fs';
import path from 'path';
import { PLAYWRIGHT_BASE_URL } from './config';
@@ -55,11 +55,6 @@ export const test = baseTest.extend<object, { workerStorageState: string }>({
// Wait for the final URL to ensure that the cookies are actually set.
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/dashboard');
// Alternatively, you can wait until the page reaches a state where all cookies are set.
await expect(
page.getByRole('heading', { name: 'Dashboard' })
).toBeVisible();
// End of authentication steps.
await page.context().storageState({ path: fileName });

Binary file not shown.

View File

@@ -2,6 +2,18 @@
@tailwind components;
@tailwind utilities;
:root{
--theme-color-icon-default: #42466C;
--theme-color-card-background: #13152B;
}
[x-cloak] {
display: none;
}
@font-face {
font-family: 'Outfit';
src: url('/fonts/Outfit-VariableFont_wght.ttf');
}

View File

@@ -10,7 +10,7 @@ defineProps({
leave-active-class="transition ease-in duration-1000"
leave-from-class="opacity-100"
leave-to-class="opacity-0">
<div v-show="on" class="text-sm text-gray-600 dark:text-gray-400">
<div v-show="on" class="text-sm text-muted">
<slot />
</div>
</transition>

View File

@@ -15,7 +15,7 @@ import SectionTitle from './SectionTitle.vue';
<div class="mt-5 md:mt-0 md:col-span-2">
<div
class="px-4 py-5 sm:p-6 bg-white dark:bg-gray-800 shadow sm:rounded-lg">
class="px-4 py-5 sm:p-6 bg-card-background shadow sm:rounded-lg">
<slot name="content" />
</div>
</div>

View File

@@ -48,12 +48,11 @@ const close = () => {
</div>
<div class="mt-3 text-center sm:mt-0 sm:ms-4 sm:text-start">
<h3
class="text-lg font-medium text-gray-900 dark:text-gray-100">
<h3 class="text-lg font-medium text-white">
<slot name="title" />
</h3>
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400">
<div class="mt-4 text-sm text-muted">
<slot name="content" />
</div>
</div>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import TimeTrackerStartStop from '@/Components/common/TimeTrackerStartStop.vue';
</script>
<template>
<div class="py-4 px-2 flex justify-between items-center">
<div>
<div class="text-muted font-extrabold text-xs">Current Timer</div>
<div class="text-white font-medium text-lg py-1">1h 23min</div>
</div>
<TimeTrackerStartStop size="base"></TimeTrackerStartStop>
</div>
</template>

View File

@@ -0,0 +1,87 @@
<script lang="ts" setup>
import VChart, { THEME_KEY } from 'vue-echarts';
import { provide, ref } from 'vue';
import { use } from 'echarts/core';
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import { BoltIcon } from '@heroicons/vue/20/solid';
import { HeatmapChart } from 'echarts/charts';
import {
CalendarComponent,
TitleComponent,
TooltipComponent,
VisualMapComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import dayjs from 'dayjs';
const props = defineProps<{
dailyHoursTracked: [string, number][];
}>();
use([
TitleComponent,
TooltipComponent,
VisualMapComponent,
CalendarComponent,
HeatmapChart,
CanvasRenderer,
]);
provide(THEME_KEY, 'dark');
const max = Math.max(...props.dailyHoursTracked.map((el) => el[1]));
const option = ref({
tooltip: {},
visualMap: {
min: 0,
max: max,
type: 'piecewise',
orient: 'horizontal',
left: 'center',
top: 'center',
inRange: {
color: ['#242940', '#2DBE45'],
},
show: false,
},
calendar: {
top: 40,
bottom: 20,
left: 40,
right: 10,
cellSize: [40, 40],
splitLine: {
show: false,
},
range: [
dayjs().format('YYYY-MM-DD'),
dayjs().subtract(50, 'day').startOf('week').format('YYYY-MM-DD'),
],
itemStyle: {
borderWidth: 8,
borderColor: '#13152B',
},
yearLabel: { show: false },
},
series: {
type: 'heatmap',
coordinateSystem: 'calendar',
data: props.dailyHoursTracked,
itemStyle: {
borderRadius: 5,
},
},
backgroundColor: 'transparent',
});
</script>
<template>
<DashboardCard title="Activity Graph" :icon="BoltIcon">
<div class="px-2">
<v-chart class="chart" :option="option" style="height: 310px" />
</div>
</DashboardCard>
</template>
<style></style>

View File

@@ -0,0 +1,25 @@
<template>
<section class="">
<h3
class="text-white font-bold pb-4 text-lg flex items-center space-x-2.5">
<component
v-if="icon"
:is="icon"
class="w-6 text-icon-default"></component>
<span>{{ title }}</span>
</h3>
<div
class="rounded-lg bg-card-background border border-card-border divide-y divide-card-background-seperator">
<slot></slot>
</div>
</section>
</template>
<script setup lang="ts">
import type { Component } from 'vue';
defineProps<{
title: string;
icon?: Component;
}>();
</script>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import dayjs from 'dayjs';
defineProps<{
date: string;
duration: number;
}>();
import relativeTime from 'dayjs/plugin/relativeTime';
import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday';
import { formatHumanReadableDuration } from '@/utils/time';
dayjs.extend(relativeTime);
dayjs.extend(isToday);
dayjs.extend(isYesterday);
function dayFormat(date: string) {
if (dayjs(date).isToday()) {
return 'Today';
} else if (dayjs(date).isYesterday()) {
return 'Yesterday';
}
return dayjs(date).fromNow();
}
</script>
<template>
<div class="px-4 py-2 grid grid-cols-3">
<div class="flex items-center">
<p class="font-semibold text-white pb-1">
{{ dayFormat(date) }}
</p>
</div>
<div class="flex items-center">
<svg
class="w-20"
viewBox="0 0 42 10"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect
y="9.28572"
width="4.18367"
height="0.714286"
rx="0.357143"
fill="#B0D7FF"
fill-opacity="0.9" />
<rect
x="5.37939"
y="7.85715"
width="4.18367"
height="2.14286"
rx="0.714286"
fill="#B0D7FF"
fill-opacity="0.9" />
<rect
x="10.7578"
y="4.28572"
width="4.18367"
height="5.71429"
rx="0.714286"
fill="#B0D7FF"
fill-opacity="0.9" />
<rect
x="16.1372"
width="4.18367"
height="10"
rx="0.714286"
fill="#B0D7FF"
fill-opacity="0.9" />
<rect
width="4.18367"
height="6.42857"
rx="0.714286"
transform="matrix(1 0 0 -1 21.5161 10)"
fill="#B0D7FF"
fill-opacity="0.9" />
<rect
width="4.18367"
height="5"
rx="0.714286"
transform="matrix(1 0 0 -1 26.8955 10)"
fill="#B0D7FF"
fill-opacity="0.9" />
<rect
width="4.18367"
height="0.714286"
rx="0.357143"
transform="matrix(1 0 0 -1 32.2739 10)"
fill="#B0D7FF"
fill-opacity="0.9" />
<rect
width="4.18367"
height="0.714286"
rx="0.357143"
transform="matrix(1 0 0 -1 37.6533 10)"
fill="#B0D7FF"
fill-opacity="0.9" />
</svg>
</div>
<div class="flex items-center justify-center text-muted font-semibold">
{{ formatHumanReadableDuration(duration) }}
</div>
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import DayOverviewCardEntry from '@/Components/Dashboard/DayOverviewCardEntry.vue';
import { CalendarIcon } from '@heroicons/vue/20/solid';
defineProps<{
last7Days: {
date: string;
duration: number; // Total duration in seconds
history: number[]; // Array representing the duration in seconds of the 3h windows for the day
}[];
}>();
</script>
<template>
<DashboardCard title="Last 7 Days" :icon="CalendarIcon">
<DayOverviewCardEntry
v-for="day in last7Days"
:key="day.date"
:date="day.date"
:duration="day.duration"></DayOverviewCardEntry>
</DashboardCard>
</template>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import VChart, { THEME_KEY } from 'vue-echarts';
import { provide, ref } from 'vue';
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { PieChart } from 'echarts/charts';
import {
GridComponent,
LegendComponent,
TitleComponent,
TooltipComponent,
} from 'echarts/components';
use([
CanvasRenderer,
PieChart,
TitleComponent,
GridComponent,
TooltipComponent,
LegendComponent,
]);
provide(THEME_KEY, 'dark');
function hexToRGBA(hex: string, opacity = 1) {
// Remove the hash at the start if it's there
hex = hex.replace(/^#/, '');
// Parse the hex color
let r, g, b;
if (hex.length === 3) {
r = parseInt(hex.charAt(0) + hex.charAt(0), 16);
g = parseInt(hex.charAt(1) + hex.charAt(1), 16);
b = parseInt(hex.charAt(2) + hex.charAt(2), 16);
} else if (hex.length === 6) {
r = parseInt(hex.substring(0, 2), 16);
g = parseInt(hex.substring(2, 4), 16);
b = parseInt(hex.substring(4, 6), 16);
} else {
throw new Error('Invalid HEX color.');
}
// Return the RGBA color string
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
}
const props = defineProps<{
weeklyProjectOverview: {
value: number;
name: string;
color: string;
}[];
}>();
const seriesData = props.weeklyProjectOverview.map((el) => {
return {
...el,
...{
itemStyle: {
borderRadius: 15,
// TODO: Fix dynamic color
borderColor: '#040618',
borderWidth: 18,
color: new LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: hexToRGBA(el.color, 0.8),
},
{
offset: 1,
color: hexToRGBA(el.color, 0.4),
},
]),
},
},
};
});
const option = ref({
backgroundColor: 'transparent',
series: [
{
label: {
// TODO: Muted color make dynamic
color: '#D9DCFB',
fontWeight: 'bold',
},
data: seriesData,
radius: ['30%', '65%'],
type: 'pie',
},
],
});
</script>
<template>
<v-chart class="chart" :option="option" />
</template>
<style scoped>
.chart {
height: 300px;
background: transparent;
}
</style>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import RecentlyTrackedTasksCardEntry from '@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue';
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
const props = defineProps<{
latestTasks: {
id: string;
name: string;
project_name: string;
project_id: string;
}[];
}>();
</script>
<template>
<DashboardCard title="Recently Tracked Tasks" :icon="CheckCircleIcon">
<RecentlyTrackedTasksCardEntry
v-for="lastTask in props.latestTasks"
:key="lastTask.id"
:project="lastTask.project_name"
:title="lastTask.name"></RecentlyTrackedTasksCardEntry>
</DashboardCard>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import ProjectBadge from '@/Components/common/ProjectBadge.vue';
import TimeTrackerStartStop from '@/Components/common/TimeTrackerStartStop.vue';
defineProps<{
title: string;
project: string;
}>();
</script>
<template>
<div class="px-4 py-2.5 grid grid-cols-5">
<div class="col-span-4">
<p class="font-semibold text-white pb-1">
{{ title }}
</p>
<ProjectBadge :name="project"></ProjectBadge>
</div>
<div class="flex items-center justify-center">
<TimeTrackerStartStop></TimeTrackerStartStop>
</div>
</div>
</template>

View File

@@ -0,0 +1,27 @@
<script lang="ts" setup>
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import TeamActivityCardEntry from '@/Components/Dashboard/TeamActivityCardEntry.vue';
import { UserGroupIcon } from '@heroicons/vue/20/solid';
defineProps<{
latestTeamActivity: {
user_id: string;
name: string;
description: string;
time_entry_id: string;
task_id: string;
status: boolean;
}[];
}>();
</script>
<template>
<DashboardCard title="Team Activity" :icon="UserGroupIcon">
<TeamActivityCardEntry
v-for="activity in latestTeamActivity"
:key="activity.user_id"
:name="activity.name"
:description="activity.description"
:working="activity.status"></TeamActivityCardEntry>
</DashboardCard>
</template>

View File

@@ -0,0 +1,31 @@
<script lang="ts" setup>
defineProps<{
name: string;
description: string;
working?: boolean;
}>();
</script>
<template>
<div class="px-4 py-3 grid grid-cols-3">
<div class="col-span-2">
<p class="font-semibold text-white pb-1">
{{ name }}
</p>
<div class="text-muted font-medium">
{{ description }}
</div>
</div>
<div v-if="working" class="flex space-x-1.5 items-center justify-end">
<span class="relative flex h-3 w-3 justify-center items-center">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
<span
class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span class="text-green-500 font-medium block pb-0.5">
working
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { BarChart } from 'echarts/charts';
import {
GridComponent,
LegendComponent,
TitleComponent,
TooltipComponent,
} from 'echarts/components';
import VChart, { THEME_KEY } from 'vue-echarts';
import { provide, ref } from 'vue';
import StatCard from '@/Components/common/StatCard.vue';
import { ClockIcon } from '@heroicons/vue/20/solid';
import CardTitle from '@/Components/common/CardTitle.vue';
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
import { formatHumanReadableDuration } from '@/utils/time';
import { formatMoney } from '@/utils/money';
use([
CanvasRenderer,
BarChart,
TitleComponent,
GridComponent,
TooltipComponent,
LegendComponent,
]);
provide(THEME_KEY, 'dark');
const props = defineProps<{
weeklyProjectOverview: {
value: number;
name: string;
color: string;
}[];
totalWeeklyTime: number;
totalWeeklyBillableTime: number;
totalWeeklyBillableAmount: {
value: number;
currency: string;
};
weeklyHistory: {
date: string;
duration: number;
}[];
}>();
const seriesData = props.weeklyHistory.map((el) => {
return {
value: el.duration,
...{
itemStyle: {
borderColor: new LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: 'rgba(125,156,188,1)',
},
{
offset: 1,
color: 'rgba(125,156,188,0.7)',
},
]),
borderWidth: 3,
borderRadius: [12, 12, 0, 0],
color: new LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: 'rgba(125,156,188,0.9)',
},
{
offset: 1,
color: 'rgba(125,156,188,0.4)',
},
]),
},
},
};
});
const option = ref({
grid: {
top: 0,
right: 0,
bottom: 50,
left: 0,
},
backgroundColor: 'transparent',
xAxis: {
type: 'category',
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
markLine: {
lineStyle: {
color: 'rgba(125,156,188,0.1)',
type: 'dashed',
},
},
axisLine: {
lineStyle: {
color: 'transparent', // Set desired color here
},
},
axisLabel: {
fontSize: 16,
fontWeight: 600,
margin: 24,
fontFamily: 'Outfit, sans-serif',
},
axisTick: {
lineStyle: {
color: 'transparent', // Set desired color here
},
},
},
yAxis: {
type: 'value',
splitLine: {
lineStyle: {
color: 'rgba(125,156,188,0.2)', // Set desired color here
},
},
},
series: [
{
data: seriesData,
type: 'bar',
},
],
});
</script>
<template>
<div class="grid gap-x-6 grid-cols-4">
<div class="col-span-3">
<CardTitle
title="This Week"
class="pb-8"
:icon="ClockIcon"></CardTitle>
<v-chart class="chart" :option="option" />
</div>
<div class="space-y-6">
<StatCard
title="Total Time"
:value="formatHumanReadableDuration(props.totalWeeklyTime)" />
<StatCard
title="Billable Time"
:value="
formatHumanReadableDuration(props.totalWeeklyBillableTime)
" />
<StatCard
title="Billable Amount"
:value="
formatMoney(
props.totalWeeklyBillableAmount.value,
props.totalWeeklyBillableAmount.currency
)
" />
<ProjectsChartCard
:weekly-project-overview="
props.weeklyProjectOverview
"></ProjectsChartCard>
</div>
</div>
</template>
<style scoped>
.chart {
height: 300px;
background: transparent;
}
</style>

View File

@@ -30,11 +30,11 @@ const close = () => {
:closeable="closeable"
@close="close">
<div class="px-6 py-4">
<div class="text-lg font-medium text-gray-900 dark:text-gray-100">
<div class="text-lg font-medium text-white">
<slot name="title" />
</div>
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400">
<div class="mt-4 text-sm text-muted">
<slot name="content" />
</div>
</div>

View File

@@ -1,22 +1,28 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, onMounted, onUnmounted } from 'vue';
const props = defineProps({
align: {
type: String,
default: 'right',
},
width: {
type: String,
default: '48',
},
contentClasses: {
type: Array,
default: () => ['py-1', 'bg-white dark:bg-gray-700'],
},
});
const props = withDefaults(
defineProps<{
align: string;
width: string;
contentClasses?: string[];
closeOnContentClick: boolean;
}>(),
{
align: 'right',
width: '48',
contentClasses: () => [
'overflow-none',
'bg-card-background',
'border',
'border-card-border',
],
closeOnContentClick: true,
}
);
const open = ref(false);
const emit = defineEmits(['open']);
const open = defineModel({ default: false });
const closeOnEscape = (e: KeyboardEvent) => {
if (open.value && e.key === 'Escape') {
@@ -27,6 +33,12 @@ const closeOnEscape = (e: KeyboardEvent) => {
onMounted(() => document.addEventListener('keydown', closeOnEscape));
onUnmounted(() => document.removeEventListener('keydown', closeOnEscape));
function onContentClick() {
if (props.closeOnContentClick === true) {
open.value = false;
}
}
const widthClass = computed(() => {
return {
48: 'w-48',
@@ -42,13 +54,24 @@ const alignmentClasses = computed(() => {
return 'ltr:origin-top-right rtl:origin-top-left end-0';
}
if (props.align === 'bottom-right') {
return 'bottom-[calc(100%+15px)] ltr:origin-top-right rtl:origin-top-left end-0';
}
return 'origin-top';
});
function toggleOpen() {
open.value = !open.value;
if (open.value === true) {
emit('open');
}
}
</script>
<template>
<div class="relative">
<div @click="open = !open">
<div @click="toggleOpen">
<slot name="trigger" />
</div>
@@ -67,9 +90,9 @@ const alignmentClasses = computed(() => {
class="absolute z-50 mt-2 rounded-md shadow-lg"
:class="[widthClass, alignmentClasses]"
style="display: none"
@click="open = false">
@click="onContentClick">
<div
class="rounded-md ring-1 ring-black ring-opacity-5"
class="rounded-lg ring-1 relative ring-black ring-opacity-5"
:class="contentClasses">
<slot name="content" />
</div>

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
defineProps({
href: String,
as: String,
});
defineProps<{
href?: string;
as?: string;
}>();
</script>
<template>
@@ -12,21 +12,21 @@ defineProps({
<button
v-if="as == 'button'"
type="submit"
class="block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-800 transition duration-150 ease-in-out">
v-bind="$attrs"
class="block w-full px-4 py-2 text-start text-sm leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<slot />
</button>
<a
v-else-if="as == 'a'"
:href="href"
class="block px-4 py-2 text-sm leading-5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-800 transition duration-150 ease-in-out">
class="block px-4 py-2 text-sm leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<slot />
</a>
<Link
v-else
:href="href ?? ''"
class="block px-4 py-2 text-sm leading-5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-800 transition duration-150 ease-in-out">
class="block px-4 py-2 text-sm leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<slot />
</Link>
</div>

View File

@@ -21,7 +21,7 @@ const hasActions = computed(() => !!useSlots().actions);
<div class="mt-5 md:mt-0 md:col-span-2">
<form @submit.prevent="$emit('submitted')">
<div
class="px-4 py-5 bg-white dark:bg-gray-800 sm:p-6 shadow"
class="px-4 py-5 bg-card-background sm:p-6 shadow"
:class="
hasActions
? 'sm:rounded-tl-md sm:rounded-tr-md'
@@ -34,7 +34,7 @@ const hasActions = computed(() => !!useSlots().actions);
<div
v-if="hasActions"
class="flex items-center justify-end px-4 py-3 bg-gray-50 dark:bg-gray-800 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-seperator text-end sm:px-6 shadow sm:rounded-bl-md sm:rounded-br-md">
<slot name="actions" />
</div>
</form>

View File

@@ -5,7 +5,7 @@ defineProps({
</script>
<template>
<label class="block font-medium text-sm text-gray-700 dark:text-gray-300">
<label class="block font-medium text-sm text-white">
<span v-if="value">{{ value }}</span>
<span v-else><slot /></span>
</label>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { Component } from 'vue';
defineProps<{
title: string;
icon: Component;
current?: boolean;
href: string;
}>();
</script>
<template>
<li>
<a
:href="href"
:class="[
current
? 'bg-menu-active text-white'
: 'text-indigo-200 hover:text-white hover:bg-menu-active',
'group flex gap-x-3 rounded-md px-3 py-2 transition leading-6 font-medium',
]">
<component
:is="icon"
:class="[
current
? 'text-icon-active'
: 'text-icon-default group-hover:text-icon-active',
'transition h-6 w-6 shrink-0',
]"
aria-hidden="true" />
{{ title }}
</a>
</li>
</template>

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
import route from 'ziggy-js';
import Dropdown from '@/Components/Dropdown.vue';
import DropdownLink from '@/Components/DropdownLink.vue';
import { router, usePage } from '@inertiajs/vue3';
import type { Organization, User } from '@/types/models';
const page = usePage<{
jetstream: {
canCreateTeams: boolean;
hasTeamFeatures: boolean;
managesProfilePhotos: boolean;
hasApiFeatures: boolean;
};
auth: {
user: User & {
all_teams: Organization[];
};
};
}>();
const switchToTeam = (team: Organization) => {
router.put(
route('current-team.update'),
{
team_id: team.id,
},
{
preserveState: false,
}
);
};
</script>
<template>
<Dropdown
v-if="page.props.jetstream.hasTeamFeatures"
align="right"
width="60">
<template #trigger>
<div
data-testid="organization_switcher"
class="flex hover:bg-white/10 cursor-pointer transition px-2 py-1 rounded-lg w-full items-center justify-between font-medium">
<div class="flex flex-1 space-x-3 items-center w-4/5">
<div
class="rounded-lg bg-blue-900 font-semibold flex-shrink-0 text-white w-7 h-7 flex items-center justify-center">
{{
page.props.auth.user.current_team.name
.slice(0, 1)
.toUpperCase()
}}
</div>
<span class="text-lg flex-1 truncate font-semibold">
{{ page.props.auth.user.current_team.name }}
</span>
</div>
<div class="w-1/5">
<button
class="p-1 transition hover:bg-white/10 rounded-full flex items-center w-9 h-9">
<ChevronDownIcon
class="w-full mt-[1px]"></ChevronDownIcon>
</button>
</div>
</div>
</template>
<template #content>
<div class="w-60">
<!-- Organization Management -->
<div class="block px-4 py-2 text-xs text-muted">
Manage Team
</div>
<!-- Organization Settings -->
<DropdownLink
:href="
route(
'teams.show',
page.props.auth.user.current_team.id
)
">
Team Settings
</DropdownLink>
<DropdownLink
v-if="page.props.jetstream.canCreateTeams"
:href="route('teams.create')">
Create New Team
</DropdownLink>
<!-- Organization Switcher -->
<template v-if="page.props.auth.user.all_teams.length > 1">
<div class="border-t border-card-background-seperator" />
<div class="block px-4 py-2 text-xs text-muted">
Switch Teams
</div>
<template
v-for="team in page.props.auth.user.all_teams"
:key="team.id">
<form @submit.prevent="switchToTeam(team)">
<DropdownLink as="button">
<div class="flex items-center">
<svg
v-if="
team.id ==
page.props.auth.user.current_team_id
"
class="me-2 h-5 w-5 text-green-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
{{ team.name }}
</div>
</div>
</DropdownLink>
</form>
</template>
</template>
</div>
</template>
</Dropdown>
</template>

View File

@@ -11,7 +11,7 @@ const props = defineProps<{
const classes = computed(() => {
return props.active
? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 dark:border-indigo-600 text-start text-base font-medium text-indigo-700 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/50 focus:outline-none focus:text-indigo-800 dark:focus:text-indigo-200 focus:bg-indigo-100 dark:focus:bg-indigo-900 focus:border-indigo-700 dark:focus:border-indigo-300 transition duration-150 ease-in-out'
: 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-600 focus:outline-none focus:text-gray-800 dark:focus:text-gray-200 focus:bg-gray-50 dark:focus:bg-gray-700 focus:border-gray-300 dark:focus:border-gray-600 transition duration-150 ease-in-out';
: 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-muted hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-600 focus:outline-none focus:text-gray-800 dark:focus:text-gray-200 focus:bg-gray-50 dark:focus:bg-gray-700 focus:border-gray-300 dark:focus:border-gray-600 transition duration-150 ease-in-out';
});
</script>

View File

@@ -14,8 +14,7 @@ withDefaults(
<template>
<button
:type="type"
ResponsiveNavLink.vue
class="inline-flex items-center px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-500 rounded-md font-semibold text-xs text-gray-700 dark:text-gray-300 uppercase tracking-widest shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-25 transition ease-in-out duration-150">
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 dark:focus:ring-offset-gray-800 disabled:opacity-25 transition ease-in-out duration-150">
<slot />
</button>
</template>

View File

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

View File

@@ -1,11 +1,11 @@
<template>
<div class="md:col-span-1 flex justify-between">
<div class="px-4 sm:px-0">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
<h3 class="text-lg font-medium text-white">
<slot name="title" />
</h3>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
<p class="mt-1 text-sm text-muted">
<slot name="description" />
</p>
</div>

View File

@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue';
defineProps({
modelValue: String,
name: String,
});
const input = ref<HTMLInputElement | null>(null);
@@ -27,7 +28,8 @@ function updateValue(event: Event) {
<template>
<input
ref="input"
class="border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm"
class="border-input-border bg-input-background text-white focus:border-input-border-active rounded-md shadow-sm"
:value="modelValue"
:name="name"
@input="updateValue" />
</template>

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import { ClockIcon } from '@heroicons/vue/20/solid';
import CardTitle from '@/Components/common/CardTitle.vue';
import BillableToggleButton from '@/Components/common/BillableToggleButton.vue';
import TimeTrackerStartStop from '@/Components/common/TimeTrackerStartStop.vue';
import TagDropdown from '@/Components/common/TagDropdown.vue';
import ProjectDropdown from '@/Components/common/ProjectDropdown.vue';
import { usePage } from '@inertiajs/vue3';
import { type User } from '@/types/models';
import { computed, onMounted, ref, watch } from 'vue';
import dayjs, { Dayjs } from 'dayjs';
import utc from 'dayjs/plugin/utc';
import duration from 'dayjs/plugin/duration';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { storeToRefs } from 'pinia';
import type { Project } from '@/utils/useProjects';
import parse from 'parse-duration';
const page = usePage<{
auth: {
user: User;
};
}>();
dayjs.extend(duration);
dayjs.extend(utc);
const currentTimeEntryStore = useCurrentTimeEntryStore();
const { currentTimeEntry, isActive } = storeToRefs(currentTimeEntryStore);
const now = ref<null | Dayjs>(null);
const interval = ref<ReturnType<typeof setInterval> | null>(null);
function startLiveTimer() {
stopLiveTimer();
now.value = dayjs().utc();
interval.value = setInterval(() => {
now.value = dayjs().utc();
}, 1000);
}
function stopLiveTimer() {
if (interval.value !== null) {
clearInterval(interval.value);
}
}
watch(isActive, () => {
if (isActive.value) {
startLiveTimer();
} else {
stopLiveTimer();
}
});
const temporaryCustomTimerEntry = ref<string>('');
const currentTime = computed({
get() {
if (temporaryCustomTimerEntry.value !== '') {
return temporaryCustomTimerEntry.value;
}
if (now.value && currentTimeEntry.value.start) {
const startTime = dayjs(currentTimeEntry.value.start);
const diff = now.value.diff(startTime);
return dayjs(diff).utc().format('HH:mm:ss');
}
return null;
},
// setter
set(newValue) {
if (newValue) {
temporaryCustomTimerEntry.value = newValue;
} else {
temporaryCustomTimerEntry.value = '';
}
},
});
onMounted(async () => {
if (page.props.auth.user.current_team_id) {
await currentTimeEntryStore.fetchCurrentTimeEntry();
now.value = dayjs().utc();
}
});
async function onToggleButtonPress(newState: boolean) {
if (page.props.auth.user.current_team_id) {
if (newState) {
startLiveTimer();
await useCurrentTimeEntryStore().startTimer();
} else {
stopLiveTimer();
await useCurrentTimeEntryStore().stopTimer();
}
}
}
const currentProject = ref<Project | null>(null);
watch(currentProject, () => {
if (currentProject.value) {
currentTimeEntry.value.project_id = currentProject.value.id;
if (isActive.value) {
useCurrentTimeEntryStore().updateTimer();
}
}
});
function updateTimeEntry() {
if (currentTimeEntry.value.id) {
useCurrentTimeEntryStore().updateTimer();
}
}
function pauseLiveTimerUpdate() {
stopLiveTimer();
}
function updateTimerAndStartLiveTimerUpdate() {
const time = parse(temporaryCustomTimerEntry.value, 's');
if (time && time > 0) {
const newStartDate = dayjs().subtract(time, 's');
currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') {
currentTimeEntryStore.updateTimer();
} else {
currentTimeEntryStore.startTimer();
}
}
now.value = dayjs().utc();
temporaryCustomTimerEntry.value = '';
startLiveTimer();
}
</script>
<template>
<CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle>
<div class="flex items-center" data-testid="dashboard_timer">
<div
class="flex w-full rounded-lg bg-card-background border-card-border border transition">
<div class="flex-1 flex items-center pr-6">
<input
placeholder="What are you working on?"
data-testid="time_entry_description"
v-model="currentTimeEntry.description"
@blur="updateTimeEntry"
class="w-full rounded-l-lg py-4 px-6 text-xl text-white focus:bg-card-background-active font-medium bg-transparent border-none placeholder-muted focus:ring-0 transition"
type="text" />
</div>
<div class="flex items-center">
<ProjectDropdown v-model="currentProject"></ProjectDropdown>
</div>
<div class="flex items-center space-x-2 px-4">
<TagDropdown
@changed="updateTimeEntry"
v-model="currentTimeEntry.tags"></TagDropdown>
<BillableToggleButton></BillableToggleButton>
</div>
<div class="border-l border-card-border">
<input
placeholder="00:00:00"
@focus="pauseLiveTimerUpdate"
data-testid="time_entry_time"
@blur="updateTimerAndStartLiveTimerUpdate"
@keydown.enter="updateTimerAndStartLiveTimerUpdate"
v-model="currentTime"
class="w-40 h-full text-white py-4 rounded-r-lg text-center px-4 text-xl font-bold bg-card-background border-none placeholder-muted focus:ring-0 transition focus:bg-card-background-active"
type="text" />
</div>
</div>
<div class="pl-6 pr-3">
<TimeTrackerStartStop
:active="isActive"
@changed="onToggleButtonPress"
size="large"></TimeTrackerStartStop>
</div>
</div>
</template>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import route from 'ziggy-js';
import { router, usePage } from '@inertiajs/vue3';
import type { Organization, User } from '@/types/models';
import DropdownLink from '@/Components/DropdownLink.vue';
import Dropdown from '@/Components/Dropdown.vue';
const page = usePage<{
jetstream: {
canCreateTeams: boolean;
hasTeamFeatures: boolean;
managesProfilePhotos: boolean;
hasApiFeatures: boolean;
};
auth: {
user: User & {
all_teams: Organization[];
};
};
}>();
const logout = () => {
router.post(route('logout'));
};
</script>
<template>
<div class="ms-3 relative">
<Dropdown align="bottom-right" width="48">
<template #trigger>
<button
v-if="page.props.jetstream.managesProfilePhotos"
data-testid="current_user_button"
class="flex text-sm border-2 border-transparent rounded-full focus:outline-none focus:border-gray-300 transition">
<img
class="h-8 w-8 rounded-full object-cover"
:src="page.props.auth.user.profile_photo_url"
:alt="page.props.auth.user.name" />
</button>
<span v-else class="inline-flex rounded-md">
<button
type="button"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none focus:bg-gray-50 dark:focus:bg-gray-700 active:bg-gray-50 dark:active:bg-gray-700 transition ease-in-out duration-150">
{{ page.props.auth.user.name }}
<svg
class="ms-2 -me-0.5 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
</span>
</template>
<template #content>
<!-- Account Management -->
<div class="block px-4 py-2 text-xs text-gray-400">
Manage Account
</div>
<DropdownLink :href="route('profile.show')">
Profile
</DropdownLink>
<DropdownLink
v-if="page.props.jetstream.hasApiFeatures"
:href="route('api-tokens.index')">
API Tokens
</DropdownLink>
<div class="border-t border-gray-200 dark:border-gray-600" />
<!-- Authentication -->
<form @submit.prevent="logout">
<DropdownLink as="button" data-testid="logout_button">
Log Out
</DropdownLink>
</form>
</template>
</Dropdown>
</div>
</template>

View File

@@ -1,177 +0,0 @@
<script setup lang="ts">
import ApplicationLogo from '@/Components/ApplicationLogo.vue';
</script>
<template>
<div>
<div
class="p-6 lg:p-8 bg-white dark:bg-gray-800 dark:bg-gradient-to-bl dark:from-gray-700/50 dark:via-transparent border-b border-gray-200 dark:border-gray-700">
<ApplicationLogo class="block h-12 w-auto" />
<h1 class="mt-8 text-2xl font-medium text-gray-900 dark:text-white">
Welcome to your Jetstream application!
</h1>
<p class="mt-6 text-gray-500 dark:text-gray-400 leading-relaxed">
Laravel Jetstream provides a beautiful, robust starting point
for your next Laravel application. Laravel is designed to help
you build your application using a development environment that
is simple, powerful, and enjoyable. We believe you should love
expressing your creativity through programming, so we have spent
time carefully crafting the Laravel ecosystem to be a breath of
fresh air. We hope you love it.
</p>
</div>
<div
class="bg-gray-200 dark:bg-gray-800 bg-opacity-25 grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8 p-6 lg:p-8">
<div>
<div class="flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
class="w-6 h-6 stroke-gray-400">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
<h2
class="ms-3 text-xl font-semibold text-gray-900 dark:text-white">
<a href="https://laravel.com/docs">Documentation</a>
</h2>
</div>
<p
class="mt-4 text-gray-500 dark:text-gray-400 text-sm leading-relaxed">
Laravel has wonderful documentation covering every aspect of
the framework. Whether you're new to the framework or have
previous experience, we recommend reading all of the
documentation from beginning to end.
</p>
<p class="mt-4 text-sm">
<a
href="https://laravel.com/docs"
class="inline-flex items-center font-semibold text-indigo-700 dark:text-indigo-300">
Explore the documentation
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
class="ms-1 w-5 h-5 fill-indigo-500 dark:fill-indigo-200">
<path
fill-rule="evenodd"
d="M5 10a.75.75 0 01.75-.75h6.638L10.23 7.29a.75.75 0 111.04-1.08l3.5 3.25a.75.75 0 010 1.08l-3.5 3.25a.75.75 0 11-1.04-1.08l2.158-1.96H5.75A.75.75 0 015 10z"
clip-rule="evenodd" />
</svg>
</a>
</p>
</div>
<div>
<div class="flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
class="w-6 h-6 stroke-gray-400">
<path
stroke-linecap="round"
d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
</svg>
<h2
class="ms-3 text-xl font-semibold text-gray-900 dark:text-white">
<a href="https://laracasts.com">Laracasts</a>
</h2>
</div>
<p
class="mt-4 text-gray-500 dark:text-gray-400 text-sm leading-relaxed">
Laracasts offers thousands of video tutorials on Laravel,
PHP, and JavaScript development. Check them out, see for
yourself, and massively level up your development skills in
the process.
</p>
<p class="mt-4 text-sm">
<a
href="https://laracasts.com"
class="inline-flex items-center font-semibold text-indigo-700 dark:text-indigo-300">
Start watching Laracasts
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
class="ms-1 w-5 h-5 fill-indigo-500 dark:fill-indigo-200">
<path
fill-rule="evenodd"
d="M5 10a.75.75 0 01.75-.75h6.638L10.23 7.29a.75.75 0 111.04-1.08l3.5 3.25a.75.75 0 010 1.08l-3.5 3.25a.75.75 0 11-1.04-1.08l2.158-1.96H5.75A.75.75 0 015 10z"
clip-rule="evenodd" />
</svg>
</a>
</p>
</div>
<div>
<div class="flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
class="w-6 h-6 stroke-gray-400">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
</svg>
<h2
class="ms-3 text-xl font-semibold text-gray-900 dark:text-white">
<a href="https://tailwindcss.com/">Tailwind</a>
</h2>
</div>
<p
class="mt-4 text-gray-500 dark:text-gray-400 text-sm leading-relaxed">
Laravel Jetstream is built with Tailwind, an amazing utility
first CSS framework that doesn't get in your way. You'll be
amazed how easily you can build and maintain fresh, modern
designs with this wonderful framework at your fingertips.
</p>
</div>
<div>
<div class="flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
class="w-6 h-6 stroke-gray-400">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
<h2
class="ms-3 text-xl font-semibold text-gray-900 dark:text-white">
Authentication
</h2>
</div>
<p
class="mt-4 text-gray-500 dark:text-gray-400 text-sm leading-relaxed">
Authentication and registration views are included with
Laravel Jetstream, as well as support for user email
verification and resetting forgotten passwords. So, you're
free to get started with what matters most: building your
application.
</p>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue';
import { twMerge } from 'tailwind-merge';
const active = defineModel({ default: false });
function toggleBillable() {
active.value = !active.value;
}
const iconColorClasses = computed(() => {
if (active.value) {
return 'text-accent-200/80 focus:text-accent-200 hover:text-accent-200';
} else {
return 'text-icon-default focus:text-icon-active hover:text-icon-active';
}
});
</script>
<template>
<button
@click="toggleBillable"
: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'
)
">
<svg
class="h-7"
viewBox="0 0 8 14"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M4 1V13M1 10.182L1.879 10.841C3.05 11.72 4.949 11.72 6.121 10.841C7.293 9.962 7.293 8.538 6.121 7.659C5.536 7.219 4.768 7 4 7C3.275 7 2.55 6.78 1.997 6.341C0.891 5.462 0.891 4.038 1.997 3.159C3.103 2.28 4.897 2.28 6.003 3.159L6.418 3.489"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</button>
</template>
<style scoped></style>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { Component } from 'vue';
defineProps<{
title: string;
icon?: Component;
}>();
</script>
<template>
<h3 class="text-white font-bold pb-4 text-lg flex items-center space-x-2.5">
<component
v-if="icon"
:is="icon"
class="w-6 text-icon-default"></component>
<span>
{{ title }}
</span>
</h3>
</template>
<style scoped></style>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
const props = withDefaults(
defineProps<{
name: string;
size: 'base' | 'large';
tag: string;
class?: string;
color: string;
}>(),
{
size: 'base',
tag: 'div',
color: 'var(--theme-color-icon-default)',
}
);
const indicatorClasses = {
base: 'w-2.5 h-2.5',
large: 'w-3 h-3',
};
const badgeClasses = {
base: 'py-1 px-2 space-x-1.5 text-sm',
large: 'py-1.5 px-3 space-x-2 text-base text-muted',
};
</script>
<template>
<component
:is="tag"
:class="
twMerge(
props.class,
badgeClasses[size],
'border-input-border border rounded inline-flex items-center font-semibold text-white'
)
">
<div
:style="{ backgroundColor: color }"
:class="
twMerge(indicatorClasses[size], 'inline-block rounded-full')
"></div>
<span>
{{ name }}
</span>
</component>
</template>
<style scoped></style>

View File

@@ -0,0 +1,162 @@
<script setup lang="ts">
import ProjectBadge from '@/Components/common/ProjectBadge.vue';
import { computed, nextTick, ref, watch } from 'vue';
import { type Project, useProjectsStore } from '@/utils/useProjects';
import Dropdown from '@/Components/Dropdown.vue';
import {
ComboboxAnchor,
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxRoot,
ComboboxViewport,
} from 'radix-vue';
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import ProjectDropdownItem from '@/Components/common/ProjectDropdownItem.vue';
import { storeToRefs } from 'pinia';
import { api } from '../../../../openapi.json.client';
import { usePage } from '@inertiajs/vue3';
import { getRandomColor } from '@/utils/color';
const searchValue = ref('');
const searchInput = ref<HTMLElement | null>(null);
const model = defineModel<Project | null>({
default: null,
});
const open = ref(false);
const projectsStore = useProjectsStore();
const { projects } = storeToRefs(projectsStore);
const projectDropdownTrigger = ref<HTMLElement | null>(null);
const shownProjects = computed(() => {
return projects.value.filter((project) => {
return project.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
});
});
const page = usePage<{
auth: {
user: {
current_team_id: string;
};
};
}>();
async function addProjectIfNoneExists() {
if (searchValue.value.length > 0 && shownProjects.value.length === 0) {
const response = await api.createProject(
{
name: searchValue.value,
color: getRandomColor(),
},
{ params: { organization: page.props.auth.user.current_team_id } }
);
projects.value.unshift(response.data);
model.value = response.data;
searchValue.value = '';
open.value = false;
}
}
watch(open, (isOpen) => {
if (isOpen) {
nextTick(() => {
searchInput.value?.$el?.focus();
});
projects.value.sort((a) => {
return model.value === a ? -1 : 1;
});
}
});
function isProjectSelected(project: Project) {
return model.value?.id === project.id;
}
const selectedProjectName = computed(() => {
return model.value?.name || 'No Project';
});
const selectedProjectColor = computed(() => {
return model.value?.color || 'var(--theme-color-icon-default)';
});
</script>
<template>
<Dropdown v-model="open" align="right" width="60">
<template #trigger>
<ProjectBadge
ref="projectDropdownTrigger"
:color="selectedProjectColor"
size="large"
tag="button"
:name="selectedProjectName"
class="focus:border-input-border-active focus:outline-0 focus:bg-card-background-seperator hover:bg-card-background-seperator"></ProjectBadge>
</template>
<template #content>
<ComboboxRoot
:open="open"
v-model="model"
v-model:searchTerm="searchValue"
class="relative">
<ComboboxAnchor>
<ComboboxInput
@keydown.enter="addProjectIfNoneExists"
ref="searchInput"
class="bg-card-background border-0 placeholder-muted text-white py-2.5 focus:ring-0 border-b border-card-background-seperator focus:border-card-background-seperator w-full"
placeholder="Search for a project..." />
</ComboboxAnchor>
<ComboboxContent>
<ComboboxViewport ref="dropdownViewport" class="w-60">
<ComboboxItem
v-if="searchValue === ''"
class="data-[highlighted]:bg-card-background-active"
:data-project-id="null"
:value="{
id: null,
name: '',
}">
<ProjectDropdownItem
name="No Project"
color="var(--theme-color-icon-default)"
selected></ProjectDropdownItem>
</ComboboxItem>
<ComboboxItem
v-for="project in shownProjects"
:key="project.id"
:value="project"
class="data-[highlighted]:bg-card-background-active"
:data-project-id="project.id">
<ProjectDropdownItem
:selected="isProjectSelected(project)"
:color="project.color"
:name="project.name"></ProjectDropdownItem>
</ComboboxItem>
<div
v-if="
searchValue.length > 0 &&
shownProjects.length === 0
"
class="bg-card-background-active">
<div
class="flex space-x-3 items-center px-4 py-3 text-sm font-medium border-t rounded-b-lg border-card-background-seperator">
<PlusCircleIcon
class="w-5 flex-shrink-0"></PlusCircleIcon>
<span
>Add "{{ searchValue }}" as a new
Project</span
>
</div>
</div>
</ComboboxViewport>
</ComboboxContent>
</ComboboxRoot>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
defineProps<{
name: string;
selected: boolean;
color: string;
}>();
</script>
<template>
<div
class="flex items-center space-x-3 w-full px-4 py-2.5 text-start text-base 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">
<div
:style="{ backgroundColor: color }"
class="w-4 h-4 rounded-full"></div>
<span>{{ name }}</span>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
defineProps<{
title: string;
value: string;
}>();
</script>
<template>
<div
class="rounded-lg bg-card-background border-card-border border px-4 py-3">
<dt class="font-bold text-muted">{{ title }}</dt>
<dd class="text-3xl text-white pt-1 font-bold">
{{ value }}
</dd>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,220 @@
<script setup lang="ts">
import { PlusCircleIcon, TagIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/Components/Dropdown.vue';
import {
type Component,
computed,
nextTick,
onMounted,
ref,
watch,
watchEffect,
} from 'vue';
import TagDropdownItem from '@/Components/common/TagDropdownItem.vue';
import { twMerge } from 'tailwind-merge';
import {
ComboboxAnchor,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxRoot,
ComboboxViewport,
} from 'radix-vue';
import { useTagsStore } from '@/utils/useTags';
import { storeToRefs } from 'pinia';
const tagsStore = useTagsStore();
const { tags } = storeToRefs(tagsStore);
const emit = defineEmits(['changed']);
const model = defineModel<string[]>({
default: [],
});
onMounted(async () => {
await tagsStore.fetchTags();
});
const searchInput = ref<Component | null>(null);
const open = ref(false);
const dropdownViewport = ref<Component | null>(null);
const searchValue = ref('');
function isTagSelected(id: string) {
return model.value.includes(id);
}
function addOrRemoveTagFromSelection(id: string) {
if (model.value.includes(id)) {
model.value = model.value.filter((tagId) => tagId !== id);
} else {
model.value.push(id);
}
emit('changed');
}
const iconColorClasses = computed(() => {
if (model.value.length > 0) {
return 'text-accent-200/80 focus:text-accent-200 hover:text-accent-200';
} else {
return 'text-icon-default hover:text-icon-active focus:text-icon-active';
}
});
watch(open, (isOpen) => {
if (isOpen) {
nextTick(() => {
// @ts-expect-error We need to access the actual HTML Element to focus as radix-vue does not support any other way right now
searchInput.value?.$el?.focus();
});
tags.value.sort((a) => {
return model.value.includes(a.id) ? -1 : 1;
});
}
});
const filteredTags = computed(() => {
return tags.value.filter((tag) => {
return tag.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
});
});
const showAllTags = ref(false);
const shownTags = computed(() => {
if (showAllTags.value) {
return filteredTags.value;
} else {
return filteredTags.value.slice(0, 5);
}
});
const moreTagsAvailable = computed(() => {
return filteredTags.value.length - shownTags.value.length;
});
async function addTagIfNoneExists() {
if (searchValue.value.length > 0 && filteredTags.value.length === 0) {
const newTag = await tagsStore.createTag(searchValue.value);
addOrRemoveTagFromSelection(newTag.id);
searchValue.value = '';
}
}
function removeTagLimit() {
showAllTags.value = true;
}
watchEffect(() => {
if (searchValue.value === ' ') {
nextTick(() => {
searchValue.value = '';
const currentSelectedItem =
// @ts-expect-error We need to access the actual HTML Element to focus as radix-vue does not support any other way right now
dropdownViewport.value?.$el?.querySelector(
'[data-highlighted]'
);
const highlightedTagId = currentSelectedItem?.getAttribute(
'data-tag-id'
) as string;
if (highlightedTagId) {
const highlightedTag = tags.value.find(
(tag) => tag.id === highlightedTagId
);
if (highlightedTag) {
addOrRemoveTagFromSelection(highlightedTag.id);
}
}
});
}
});
function updateValue(e: string[]) {
model.value = e;
emit('changed');
}
</script>
<template>
<Dropdown width="120" v-model="open" :closeOnContentClick="false">
<template #trigger>
<button
data-testid="tag_dropdown"
: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'
)
">
<TagIcon class="w-7 h-7"></TagIcon>
<div
v-if="model.length > 1"
class="font-extrabold absolute rounded-full text-xs w-3 h-3 block top-[15px] rotate-[45deg] right-[14px] text-card-background">
{{ model.length }}
</div>
</button>
</template>
<template #content>
<ComboboxRoot
multiple
:open="open"
@update:modelValue="updateValue"
v-model:searchTerm="searchValue"
class="relative">
<ComboboxAnchor>
<ComboboxInput
@keydown.enter="addTagIfNoneExists"
data-testid="tag_dropdown_search"
ref="searchInput"
class="bg-card-background border-0 placeholder-muted text-white py-2.5 focus:ring-0 border-b border-card-background-seperator focus:border-card-background-seperator w-full"
placeholder="Search for a tag..." />
</ComboboxAnchor>
<ComboboxContent>
<ComboboxViewport ref="dropdownViewport" class="w-60">
<ComboboxEmpty>
<div
v-if="searchValue.length > 0"
class="bg-card-background-active">
<div
class="flex space-x-3 items-center px-4 py-3 text-sm font-medium border-t rounded-b-lg border-card-background-seperator">
<PlusCircleIcon
class="w-5 flex-shrink-0"></PlusCircleIcon>
<span
>Add "{{ searchValue }}" as a new
Tag</span
>
</div>
</div>
<div v-else></div>
</ComboboxEmpty>
<ComboboxItem
v-for="tag in shownTags"
:key="tag.id"
:value="tag.id"
class="data-[highlighted]:bg-card-background-active"
data-testid="tag_dropdown_entries"
:data-tag-id="tag.id">
<TagDropdownItem
:selected="isTagSelected(tag.id)"
:name="tag.name"></TagDropdownItem>
</ComboboxItem>
</ComboboxViewport>
<button
@click="removeTagLimit"
v-if="moreTagsAvailable > 0"
class="border-t hover:text-white hover:bg-card-background-active px-2 text-center font-semibold py-2 border-t-card-background-seperator">
Show all
</button>
</ComboboxContent>
</ComboboxRoot>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import { computed } from 'vue';
import { twMerge } from 'tailwind-merge';
const props = defineProps<{
name: string;
selected: boolean;
}>();
const iconClasses = computed(() => {
if (props.selected) {
return 'text-accent-200';
} else {
return 'text-card-border';
}
});
</script>
<template>
<div
class="flex items-center space-x-3 w-full px-4 py-2.5 text-start text-base 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-6')"></CheckCircleIcon>
<span>{{ name }}</span>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
import { computed } from 'vue';
const emit = defineEmits(['changed']);
const props = withDefaults(
defineProps<{
size: 'base' | 'large';
active: boolean;
}>(),
{
size: 'base',
active: false,
}
);
const buttonSizeClasses = {
base: 'w-8 h-8 !bg-accent-200/30 ',
large: 'w-11 h-11 ring-accent-200/10 focus:ring-accent-200/20 ring-8 hover:scale-110',
};
const iconClass = {
base: 'w-3.5 h-3.5',
large: 'w-4 h-4',
};
const buttonColorClasses = computed(() => {
if (props.active) {
return 'bg-red-400/80 hover:bg-red-500/80 focus:bg-red-500/80';
} else {
return 'bg-accent-300/70 hover:bg-accent-400/80 focus:bg-accent-400/80';
}
});
function toggleState() {
emit('changed', !props.active);
}
</script>
<template>
<button
@click="toggleState"
data-testid="timer_button"
:class="
twMerge(
buttonSizeClasses[size],
buttonColorClasses,
'flex items-center justify-center py-1 transition focus:outline-0 rounded-full text-white '
)
">
<Transition name="fade" mode="out-in">
<svg
v-if="props.active"
:class="iconClass[size]"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0.461426 2.74913C0.461426 1.48677 1.48666 0.461538 2.75076 0.461538H11.249C12.5131 0.461538 13.5383 1.48677 13.5383 2.75087V11.2491C13.5383 12.5132 12.5131 13.5385 11.249 13.5385H2.7525C2.4518 13.5387 2.154 13.4796 1.87614 13.3647C1.59828 13.2497 1.34582 13.0811 1.13319 12.8684C0.920559 12.6558 0.751936 12.4033 0.636968 12.1255C0.521999 11.8476 0.462941 11.5498 0.46317 11.2491V2.75262L0.461426 2.74913Z"
fill="currentColor" />
</svg>
<svg
v-else
:class="iconClass[size]"
viewBox="0 0 7 8"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.56167 3.18089C6.70764 3.26214 6.82926 3.38092 6.91393 3.52494C6.99859 3.66896 7.04324 3.83299 7.04324 4.00005C7.04324 4.16712 6.99859 4.33115 6.91393 4.47517C6.82926 4.61919 6.70764 4.73797 6.56167 4.81922L1.8925 7.41339C1.74982 7.49259 1.58895 7.53317 1.42578 7.53113C1.26261 7.52909 1.1028 7.48449 0.962147 7.40175C0.821497 7.31901 0.704879 7.20099 0.623826 7.05937C0.542772 6.91774 0.50009 6.7574 0.5 6.59422V1.40589C0.5 0.691721 1.2675 0.239221 1.8925 0.586721L6.56167 3.18089Z"
fill="currentColor" />
</svg>
</Transition>
</button>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,484 +1,119 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Head, Link, router, usePage } from '@inertiajs/vue3';
import ApplicationMark from '@/Components/ApplicationMark.vue';
import { Head } from '@inertiajs/vue3';
import Banner from '@/Components/Banner.vue';
import Dropdown from '@/Components/Dropdown.vue';
import DropdownLink from '@/Components/DropdownLink.vue';
import NavLink from '@/Components/NavLink.vue';
import ResponsiveNavLink from '@/Components/ResponsiveNavLink.vue';
import route from 'ziggy-js';
import type { Organization, User } from '@/types/models';
import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
import {
ChartBarIcon,
ClockIcon,
FolderIcon,
HomeIcon,
UserCircleIcon,
UserGroupIcon,
TagIcon,
Cog6ToothIcon,
} from '@heroicons/vue/20/solid';
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
defineProps({
title: String,
});
const showingNavigationDropdown = ref(false);
const page = usePage<{
jetstream: {
canCreateTeams: boolean;
hasTeamFeatures: boolean;
managesProfilePhotos: boolean;
hasApiFeatures: boolean;
};
auth: {
user: User & { all_teams: Organization[] };
};
}>();
const switchToTeam = (team: Organization) => {
router.put(
route('current-team.update'),
{
team_id: team.id,
},
{
preserveState: false,
}
);
};
const logout = () => {
router.post(route('logout'));
};
</script>
<template>
<div>
<Head :title="title" />
<Banner />
<div class="min-h-screen bg-gray-100 dark:bg-gray-900">
<nav
class="bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700">
<!-- Primary Navigation Menu -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<!-- Logo -->
<div class="shrink-0 flex items-center">
<Link :href="route('dashboard')">
<ApplicationMark class="block h-9 w-auto" />
</Link>
</div>
<!-- Navigation Links -->
<div
class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<NavLink
:href="route('dashboard')"
:active="route().current('dashboard')">
Dashboard
</NavLink>
</div>
</div>
<div class="hidden sm:flex sm:items-center sm:ms-6">
<div class="ms-3 relative">
<!-- Teams Dropdown -->
<Dropdown
v-if="page.props.jetstream.hasTeamFeatures"
align="right"
width="60">
<template #trigger>
<span class="inline-flex rounded-md">
<button
type="button"
id="currentTeamButton"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none focus:bg-gray-50 dark:focus:bg-gray-700 active:bg-gray-50 dark:active:bg-gray-700 transition ease-in-out duration-150">
{{
page.props.auth.user
.current_team.name
}}
<svg
class="ms-2 -me-0.5 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
</svg>
</button>
</span>
</template>
<template #content>
<div class="w-60">
<!-- Organization Management -->
<div
class="block px-4 py-2 text-xs text-gray-400">
Manage Team
</div>
<!-- Organization Settings -->
<DropdownLink
:href="
route(
'teams.show',
page.props.auth.user
.current_team.id
)
">
Team Settings
</DropdownLink>
<DropdownLink
v-if="
page.props.jetstream
.canCreateTeams
"
:href="route('teams.create')">
Create New Team
</DropdownLink>
<!-- Organization Switcher -->
<template
v-if="
page.props.auth.user
.all_teams.length > 1
">
<div
class="border-t border-gray-200 dark:border-gray-600" />
<div
class="block px-4 py-2 text-xs text-gray-400">
Switch Teams
</div>
<template
v-for="team in page.props
.auth.user.all_teams"
:key="team.id">
<form
@submit.prevent="
switchToTeam(team)
">
<DropdownLink
as="button">
<div
class="flex items-center">
<svg
v-if="
team.id ==
page
.props
.auth
.user
.current_team_id
"
class="me-2 h-5 w-5 text-green-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
{{
team.name
}}
</div>
</div>
</DropdownLink>
</form>
</template>
</template>
</div>
</template>
</Dropdown>
</div>
<!-- Settings Dropdown -->
<div class="ms-3 relative">
<Dropdown align="right" width="48">
<template #trigger>
<button
v-if="
page.props.jetstream
.managesProfilePhotos
"
id="currentUserButton"
class="flex text-sm border-2 border-transparent rounded-full focus:outline-none focus:border-gray-300 transition">
<img
class="h-8 w-8 rounded-full object-cover"
:src="
page.props.auth.user
.profile_photo_url
"
:alt="
page.props.auth.user.name
" />
</button>
<span
v-else
class="inline-flex rounded-md">
<button
type="button"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none focus:bg-gray-50 dark:focus:bg-gray-700 active:bg-gray-50 dark:active:bg-gray-700 transition ease-in-out duration-150">
{{ page.props.auth.user.name }}
<svg
class="ms-2 -me-0.5 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
</span>
</template>
<template #content>
<!-- Account Management -->
<div
class="block px-4 py-2 text-xs text-gray-400">
Manage Account
</div>
<DropdownLink
:href="route('profile.show')">
Profile
</DropdownLink>
<DropdownLink
v-if="
page.props.jetstream
.hasApiFeatures
"
:href="route('api-tokens.index')">
API Tokens
</DropdownLink>
<div
class="border-t border-gray-200 dark:border-gray-600" />
<!-- Authentication -->
<form @submit.prevent="logout">
<DropdownLink as="button">
Log Out
</DropdownLink>
</form>
</template>
</Dropdown>
</div>
</div>
<!-- Hamburger -->
<div class="-me-2 flex items-center sm:hidden">
<button
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-900 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-900 focus:text-gray-500 dark:focus:text-gray-400 transition duration-150 ease-in-out"
@click="
showingNavigationDropdown =
!showingNavigationDropdown
">
<svg
class="h-6 w-6"
stroke="currentColor"
fill="none"
viewBox="0 0 24 24">
<path
:class="{
hidden: showingNavigationDropdown,
'inline-flex':
!showingNavigationDropdown,
}"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16" />
<path
:class="{
hidden: !showingNavigationDropdown,
'inline-flex':
showingNavigationDropdown,
}"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="flex flex-wrap bg-default-background text-muted">
<div
class="flex-shrink-0 h-screen fixed w-[250px] px-1.5 py-4 flex flex-col justify-between">
<div>
<div class="border-b border-default-background-seperator pb-2">
<OrganizationSwitcher></OrganizationSwitcher>
</div>
<!-- Responsive Navigation Menu -->
<div
:class="{
block: showingNavigationDropdown,
hidden: !showingNavigationDropdown,
}"
class="sm:hidden">
<div class="pt-2 pb-3 space-y-1">
<ResponsiveNavLink
<div class="border-b border-default-background-seperator">
<CurrentSidebarTimer></CurrentSidebarTimer>
</div>
<nav>
<ul class="space-y-1">
<NavigationSidebarItem
title="Dashboard"
:icon="HomeIcon"
:href="route('dashboard')"
:active="route().current('dashboard')">
Dashboard
</ResponsiveNavLink>
</div>
:current="
route().current('dashboard')
"></NavigationSidebarItem>
<NavigationSidebarItem
title="Time"
:icon="ClockIcon"
:href="route('dashboard')"></NavigationSidebarItem>
<NavigationSidebarItem
title="Reporting"
:icon="ChartBarIcon"
:href="route('dashboard')"></NavigationSidebarItem>
</ul>
</nav>
<!-- Responsive Settings Options -->
<div
class="pt-4 pb-1 border-t border-gray-200 dark:border-gray-600">
<div class="flex items-center px-4">
<div
v-if="page.props.jetstream.managesProfilePhotos"
class="shrink-0 me-3">
<img
class="h-10 w-10 rounded-full object-cover"
:src="
page.props.auth.user.profile_photo_url
"
:alt="page.props.auth.user.name" />
</div>
<div>
<div
class="font-medium text-base text-gray-800 dark:text-gray-200">
{{ page.props.auth.user.name }}
</div>
<div class="font-medium text-sm text-gray-500">
{{ page.props.auth.user.email }}
</div>
</div>
</div>
<div class="mt-3 space-y-1">
<ResponsiveNavLink
:href="route('profile.show')"
:active="route().current('profile.show')">
Profile
</ResponsiveNavLink>
<ResponsiveNavLink
v-if="page.props.jetstream.hasApiFeatures"
:href="route('api-tokens.index')"
:active="route().current('api-tokens.index')">
API Tokens
</ResponsiveNavLink>
<!-- Authentication -->
<form method="POST" @submit.prevent="logout">
<ResponsiveNavLink as="button">
Log Out
</ResponsiveNavLink>
</form>
<!-- Organization Management -->
<template
v-if="page.props.jetstream.hasTeamFeatures">
<div
class="border-t border-gray-200 dark:border-gray-600" />
<div
class="block px-4 py-2 text-xs text-gray-400">
Manage Team
</div>
<!-- Organization Settings -->
<ResponsiveNavLink
:href="
route(
'teams.show',
page.props.auth.user.current_team.id
)
"
:active="route().current('teams.show')">
Team Settings
</ResponsiveNavLink>
<ResponsiveNavLink
v-if="page.props.jetstream.canCreateTeams"
:href="route('teams.create')"
:active="route().current('teams.create')">
Create New Team
</ResponsiveNavLink>
<!-- Organization Switcher -->
<template
v-if="
page.props.auth.user.all_teams.length >
1
">
<div
class="border-t border-gray-200 dark:border-gray-600" />
<div
class="block px-4 py-2 text-xs text-gray-400">
Switch Teams
</div>
<template
v-for="team in page.props.auth.user
.all_teams"
:key="team.id">
<form
@submit.prevent="
switchToTeam(team)
">
<ResponsiveNavLink as="button">
<div class="flex items-center">
<svg
v-if="
team.id ==
page.props.auth.user
.current_team_id
"
class="me-2 h-5 w-5 text-green-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>{{ team.name }}</div>
</div>
</ResponsiveNavLink>
</form>
</template>
</template>
</template>
</div>
</div>
<div class="text-muted font-semibold text-sm pt-6 pb-4">
Manage
</div>
</nav>
<!-- Page Heading -->
<header
v-if="$slots.header"
class="bg-white dark:bg-gray-800 shadow">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<slot name="header" />
</div>
</header>
<nav>
<ul class="space-y-1">
<NavigationSidebarItem
title="Projects"
:icon="FolderIcon"
:href="route('dashboard')"
:current="
route().current('dashboard')
"></NavigationSidebarItem>
<NavigationSidebarItem
title="Clients"
:icon="UserCircleIcon"
:href="route('dashboard')"></NavigationSidebarItem>
<NavigationSidebarItem
title="Team"
:icon="UserGroupIcon"
:href="route('dashboard')"></NavigationSidebarItem>
<NavigationSidebarItem
title="Tags"
:icon="TagIcon"
:href="route('dashboard')"></NavigationSidebarItem>
</ul>
</nav>
</div>
<!-- Page Content -->
<main>
<slot />
</main>
<ul
class="border-t border-default-background-seperator pt-3 flex justify-between pr-4 items-center">
<NavigationSidebarItem
class="flex-1"
title="Settings"
:icon="Cog6ToothIcon"
:href="route('dashboard')"></NavigationSidebarItem>
<UserSettingsIcon></UserSettingsIcon>
</ul>
</div>
<div class="flex-1 ml-[250px]">
<Head :title="title" />
<Banner />
<div
class="min-h-screen bg-default-background border-l border-default-background-seperator">
<!-- Page Heading -->
<header
v-if="$slots.header"
class="bg-default-background border-b border-default-background-seperator shadow">
<div class="py-6 px-4 sm:px-6 lg:px-8">
<slot name="header" />
</div>
</header>
<!-- Page Content -->
<main>
<slot />
</main>
</div>
</div>
</div>
</template>

View File

@@ -129,10 +129,9 @@ const deleteApiToken = () => {
createApiTokenForm.permissions
"
:value="permission" />
<span
class="ms-2 text-sm text-gray-600 dark:text-gray-400"
>{{ permission }}</span
>
<span class="ms-2 text-sm text-muted">{{
permission
}}</span>
</label>
</div>
</div>
@@ -246,10 +245,9 @@ const deleteApiToken = () => {
<Checkbox
v-model:checked="updateApiTokenForm.permissions"
:value="permission" />
<span
class="ms-2 text-sm text-gray-600 dark:text-gray-400"
>{{ permission }}</span
>
<span class="ms-2 text-sm text-muted">{{
permission
}}</span>
</label>
</div>
</div>

View File

@@ -33,7 +33,7 @@ const submit = () => {
<AuthenticationCardLogo />
</template>
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
<div class="mb-4 text-sm text-muted">
This is a secure area of the application. Please confirm your
password before continuing.
</div>

View File

@@ -28,7 +28,7 @@ const submit = () => {
<AuthenticationCardLogo />
</template>
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
<div class="mb-4 text-sm text-muted">
Forgot your password? No problem. Just let us know your email
address and we will email you a password reset link that will allow
you to choose a new one.

View File

@@ -73,9 +73,7 @@ const submit = () => {
<div class="block mt-4">
<label class="flex items-center">
<Checkbox v-model:checked="form.remember" name="remember" />
<span class="ms-2 text-sm text-gray-600 dark:text-gray-400"
>Remember me</span
>
<span class="ms-2 text-sm text-muted">Remember me</span>
</label>
</div>
@@ -83,7 +81,7 @@ const submit = () => {
<Link
v-if="canResetPassword"
:href="route('password.request')"
class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800">
class="underline text-sm text-muted hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800">
Forgot your password?
</Link>

View File

@@ -108,14 +108,14 @@ const page = usePage<{
<a
target="_blank"
:href="route('terms.show')"
class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800"
class="underline text-sm text-muted hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800"
>Terms of Service</a
>
and
<a
target="_blank"
:href="route('policy.show')"
class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800"
class="underline text-sm text-muted hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800"
>Privacy Policy</a
>
</div>
@@ -127,7 +127,7 @@ const page = usePage<{
<div class="flex items-center justify-end mt-4">
<Link
:href="route('login')"
class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800">
class="underline text-sm text-muted hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800">
Already registered?
</Link>

View File

@@ -45,7 +45,7 @@ const submit = () => {
<AuthenticationCardLogo />
</template>
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
<div class="mb-4 text-sm text-muted">
<template v-if="!recovery">
Please confirm access to your account by entering the
authentication code provided by your authenticator application.
@@ -87,7 +87,7 @@ const submit = () => {
<div class="flex items-center justify-end mt-4">
<button
type="button"
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 underline cursor-pointer"
class="text-sm text-muted hover:text-gray-900 underline cursor-pointer"
@click.prevent="toggleRecovery">
<template v-if="!recovery"> Use a recovery code</template>

View File

@@ -28,7 +28,7 @@ const verificationLinkSent = computed(
<AuthenticationCardLogo />
</template>
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
<div class="mb-4 text-sm text-muted">
Before continuing, could you verify your email address by clicking
on the link we just emailed to you? If you didn't receive the email,
we will gladly send you another.
@@ -52,7 +52,7 @@ const verificationLinkSent = computed(
<div>
<Link
:href="route('profile.show')"
class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800">
class="underline text-sm text-muted hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800">
Edit Profile</Link
>
@@ -60,7 +60,7 @@ const verificationLinkSent = computed(
:href="route('logout')"
method="post"
as="button"
class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800 ms-2">
class="underline text-sm text-muted hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800 ms-2">
Log Out
</Link>
</div>

View File

@@ -1,24 +1,98 @@
<script setup lang="ts">
import AppLayout from '@/Layouts/AppLayout.vue';
import Welcome from '@/Components/Welcome.vue';
import TimeTracker from '@/Components/TimeTracker.vue';
import RecentlyTrackedTasksCard from '@/Components/Dashboard/RecentlyTrackedTasksCard.vue';
import LastSevenDaysCard from '@/Components/Dashboard/LastSevenDaysCard.vue';
import TeamActivityCard from '@/Components/Dashboard/TeamActivityCard.vue';
import ThisWeekOverview from '@/Components/Dashboard/ThisWeekOverview.vue';
import { usePage } from '@inertiajs/vue3';
import type { Organization, User } from '@/types/models';
import { onMounted } from 'vue';
import { useProjectsStore } from '@/utils/useProjects';
import ActivityGraphCard from '@/Components/Dashboard/ActivityGraphCard.vue';
const page = usePage<{
auth: {
user: User & {
all_teams: Organization[];
};
};
}>();
onMounted(async () => {
if (page.props.auth.user.current_team_id) {
await useProjectsStore().fetchProjects(
page.props.auth.user.current_team_id
);
}
});
const props = defineProps<{
latestTasks: {
id: string;
name: string;
project_name: string;
project_id: string;
}[];
latestTeamActivity: {
user_id: string;
name: string;
description: string;
time_entry_id: string;
task_id: string;
status: boolean;
}[];
lastSevenDays: {
date: string;
duration: number; // Total duration in seconds
history: number[]; // Array representing the duration in seconds of the 3h windows for the day
}[];
dailyTrackedHours: [string, number][];
weeklyProjectOverview: {
value: number;
name: string;
color: string;
}[];
totalWeeklyTime: number;
totalWeeklyBillableTime: number;
totalWeeklyBillableAmount: {
value: number;
currency: string;
};
weeklyHistory: {
date: string;
duration: number;
}[];
}>();
</script>
<template>
<AppLayout title="Dashboard">
<template #header>
<h2
class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
Dashboard
</h2>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div
class="bg-white dark:bg-gray-800 overflow-hidden shadow-xl sm:rounded-lg">
<Welcome />
</div>
</div>
<AppLayout title="Dashboard" data-testid="dashboard_view">
<div
class="py-8 sm:px-6 lg:px-8 mx-auto border-b border-default-background-seperator">
<TimeTracker></TimeTracker>
</div>
<div
class="grid gap-x-6 grid-cols-4 sm:px-6 lg:px-8 pt-6 pb-7 border-b border-default-background-seperator">
<RecentlyTrackedTasksCard
:latestTasks="props.latestTasks"></RecentlyTrackedTasksCard>
<LastSevenDaysCard
:last7-days="props.lastSevenDays"></LastSevenDaysCard>
<ActivityGraphCard
:daily-hours-tracked="
props.dailyTrackedHours
"></ActivityGraphCard>
<TeamActivityCard
:latestTeamActivity="
props.latestTeamActivity
"></TeamActivityCard>
</div>
<div class="sm:px-6 lg:px-8 py-6">
<ThisWeekOverview
:weeklyProjectOverview="props.weeklyProjectOverview"
:total-weekly-billable-amount="props.totalWeeklyBillableAmount"
:total-weekly-billable-time="props.totalWeeklyBillableTime"
:total-weekly-time="props.totalWeeklyTime"
:weekly-history="props.weeklyHistory"></ThisWeekOverview>
</div>
</AppLayout>
</template>

View File

@@ -44,7 +44,7 @@ const closeModal = () => {
<template #description> Permanently delete your account. </template>
<template #content>
<div class="max-w-xl text-sm text-gray-600 dark:text-gray-400">
<div class="max-w-xl text-sm text-muted">
Once your account is deleted, all of its resources and data will
be permanently deleted. Before deleting your account, please
download any data or information that you wish to retain.

View File

@@ -53,7 +53,7 @@ const closeModal = () => {
</template>
<template #content>
<div class="max-w-xl text-sm text-gray-600 dark:text-gray-400">
<div class="max-w-xl text-sm text-muted">
If necessary, you may log out of all of your other browser
sessions across all of your devices. Some of your recent
sessions are listed below; however, this list may not be
@@ -70,7 +70,7 @@ const closeModal = () => {
<div>
<svg
v-if="session.agent.is_desktop"
class="w-8 h-8 text-gray-500"
class="w-8 h-8 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -98,7 +98,7 @@ const closeModal = () => {
</div>
<div class="ms-3">
<div class="text-sm text-gray-600 dark:text-gray-400">
<div class="text-sm text-white font-medium">
{{
session.agent.platform
? session.agent.platform
@@ -113,7 +113,7 @@ const closeModal = () => {
</div>
<div>
<div class="text-xs text-gray-500">
<div class="text-xs text-muted">
{{ session.ip_address }},
<span

View File

@@ -137,13 +137,11 @@ const disableTwoFactorAuthentication = () => {
Finish enabling two factor authentication.
</h3>
<h3
v-else
class="text-lg font-medium text-gray-900 dark:text-gray-100">
<h3 v-else class="text-lg font-medium text-white">
You have not enabled two factor authentication.
</h3>
<div class="mt-3 max-w-xl text-sm text-gray-600 dark:text-gray-400">
<div class="mt-3 max-w-xl text-sm text-muted">
<p>
When two factor authentication is enabled, you will be
prompted for a secure, random token during authentication.
@@ -154,8 +152,7 @@ const disableTwoFactorAuthentication = () => {
<div v-if="twoFactorEnabled">
<div v-if="qrCode">
<div
class="mt-4 max-w-xl text-sm text-gray-600 dark:text-gray-400">
<div class="mt-4 max-w-xl text-sm text-muted">
<p v-if="confirming" class="font-semibold">
To finish enabling two factor authentication, scan
the following QR code using your phone's
@@ -176,7 +173,7 @@ const disableTwoFactorAuthentication = () => {
<div
v-if="setupKey"
class="mt-4 max-w-xl text-sm text-gray-600 dark:text-gray-400">
class="mt-4 max-w-xl text-sm text-muted">
<p class="font-semibold">
Setup Key: <span v-html="setupKey"></span>
</p>
@@ -203,8 +200,7 @@ const disableTwoFactorAuthentication = () => {
</div>
<div v-if="recoveryCodes.length > 0 && !confirming">
<div
class="mt-4 max-w-xl text-sm text-gray-600 dark:text-gray-400">
<div class="mt-4 max-w-xl text-sm text-muted">
<p class="font-semibold">
Store these recovery codes in a secure password
manager. They can be used to recover access to your

View File

@@ -184,7 +184,7 @@ const page = usePage<{
:href="route('verification.send')"
method="post"
as="button"
class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800"
class="underline text-sm text-muted hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800"
@click.prevent="sendEmailVerification">
Click here to re-send the verification email.
</Link>

View File

@@ -31,7 +31,7 @@ const deleteTeam = () => {
<template #description> Permanently delete this team. </template>
<template #content>
<div class="max-w-xl text-sm text-gray-600 dark:text-gray-400">
<div class="max-w-xl text-sm text-muted">
Once a team is deleted, all of its resources and data will be
permanently deleted. Before deleting this team, please download
any data or information regarding this team that you wish to

View File

@@ -138,8 +138,7 @@ const displayableRole = (role: string) => {
<template #form>
<div class="col-span-6">
<div
class="max-w-xl text-sm text-gray-600 dark:text-gray-400">
<div class="max-w-xl text-sm text-muted">
Please provide the email address of the person you
would like to add to this team.
</div>
@@ -191,7 +190,7 @@ const displayableRole = (role: string) => {
<!-- Role Name -->
<div class="flex items-center">
<div
class="text-sm text-gray-600 dark:text-gray-400"
class="text-sm text-muted"
:class="{
'font-semibold':
addTeamMemberForm.role ==
@@ -220,7 +219,7 @@ const displayableRole = (role: string) => {
<!-- Role Description -->
<div
class="mt-2 text-xs text-gray-600 dark:text-gray-400 text-start">
class="mt-2 text-xs text-muted text-start">
{{ role.description }}
</div>
</div>
@@ -269,7 +268,7 @@ const displayableRole = (role: string) => {
v-for="invitation in team.team_invitations"
:key="invitation.id"
class="flex items-center justify-between">
<div class="text-gray-600 dark:text-gray-400">
<div class="text-muted">
{{ invitation.email }}
</div>
@@ -390,7 +389,7 @@ const displayableRole = (role: string) => {
<!-- Role Name -->
<div class="flex items-center">
<div
class="text-sm text-gray-600 dark:text-gray-400"
class="text-sm text-muted"
:class="{
'font-semibold':
updateRoleForm.role ===
@@ -415,8 +414,7 @@ const displayableRole = (role: string) => {
</div>
<!-- Role Description -->
<div
class="mt-2 text-xs text-gray-600 dark:text-gray-400">
<div class="mt-2 text-xs text-muted">
{{ role.description }}
</div>
</div>

View File

@@ -6,8 +6,10 @@ import { createInertiaApp } from '@inertiajs/vue3';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
// @ts-expect-error - ziggy in composer is not typed
import { ZiggyVue } from '../../vendor/tightenco/ziggy/dist/vue.m';
import { createPinia } from 'pinia';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
const pinia = createPinia();
createInertiaApp({
title: (title) => `${title} - ${appName}`,
@@ -19,6 +21,7 @@ createInertiaApp({
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.use(pinia)
.use(ZiggyVue)
.mount(el);
},

View File

@@ -61,17 +61,6 @@ export interface Project {
tasks: Task[];
}
export interface Tag {
// columns
id: string;
name: string;
organization_id: string;
created_at: string | null;
updated_at: string | null;
// relations
organization: Organization;
}
export interface Task {
// columns
id: string;

View File

@@ -0,0 +1,4 @@
import type { ApiOf } from '@zodios/core';
import { api } from '../../../openapi.json.client';
export type SolidTimeApi = ApiOf<typeof api>;

View File

@@ -0,0 +1,25 @@
const colors = [
'#ef5350',
'#ec407a',
'#ab47bc',
'#7e57c2',
'#5c6bc0',
'#42a5f5',
'#29b6f6',
'#26c6da',
'#26a69a',
'#66bb6a',
'#9ccc65',
'#d4e157',
'#ffee58',
'#ffca28',
'#ffa726',
'#ff7043',
'#8d6e63',
'#bdbdbd',
'#78909c',
];
export function getRandomColor() {
return colors[Math.floor(Math.random() * colors.length)];
}

View File

@@ -0,0 +1,6 @@
export function formatMoney(amount: number, currency: string) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount);
}

View File

@@ -0,0 +1,8 @@
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
dayjs.extend(duration);
export function formatHumanReadableDuration(duration: number): string {
return dayjs.duration(duration, 's').format('HH[h] mm[min]');
}

View File

@@ -0,0 +1,151 @@
import { defineStore } from 'pinia';
import { computed, reactive, ref } from 'vue';
import { api } from '../../../openapi.json.client';
import type { ZodiosResponseByAlias } from '@zodios/core';
import type { SolidTimeApi } from '@/utils/api';
import dayjs from 'dayjs';
import { getCurrentOrganizationId, getCurrentUserId } from '@/utils/useUser';
type TimeEntryResponse = ZodiosResponseByAlias<SolidTimeApi, 'getTimeEntries'>;
export type TimeEntry = TimeEntryResponse['data'][0];
const emptyTimeEntry = {
id: '',
description: null,
user_id: '',
start: '',
end: null,
duration: null,
task_id: null,
project_id: null,
tags: [],
} as TimeEntry;
export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
const currentTimeEntry = ref<TimeEntry>(reactive(emptyTimeEntry));
function $reset() {
currentTimeEntry.value = { ...emptyTimeEntry };
}
async function fetchCurrentTimeEntry() {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const timeEntriesResponse = await api.getTimeEntries({
queries: {
active: 'true',
},
params: {
organization: organizationId,
},
});
if (timeEntriesResponse.data.length === 1) {
currentTimeEntry.value = timeEntriesResponse.data[0];
} else {
currentTimeEntry.value = { ...emptyTimeEntry };
}
} else {
throw new Error(
'Failed to fetch current time entry because organization ID is missing.'
);
}
}
async function startTimer() {
const user = getCurrentUserId();
const organization = getCurrentOrganizationId();
if (organization) {
const startTime =
currentTimeEntry.value.start !== ''
? currentTimeEntry.value.start
: dayjs().utc().format();
const response = await api.createTimeEntry(
{
user_id: user,
start: startTime,
description: currentTimeEntry.value?.description,
},
{ params: { organization: organization } }
);
currentTimeEntry.value = response.data;
} else {
throw new Error(
'Failed to fetch current time entry because organization ID is missing.'
);
}
}
async function stopTimer() {
const user = getCurrentUserId();
const organization = getCurrentOrganizationId();
if (organization) {
const currentDateTime = dayjs().utc().format();
await api.updateTimeEntry(
{
user_id: user,
start: currentTimeEntry.value.start,
end: currentDateTime,
},
{
params: {
organization: organization,
timeEntry: currentTimeEntry.value.id,
},
}
);
$reset();
} else {
throw new Error(
'Failed to stop current timer because organization ID is missing.'
);
}
}
async function updateTimer() {
const user = getCurrentUserId();
const organization = getCurrentOrganizationId();
if (organization) {
await api.updateTimeEntry(
{
description: currentTimeEntry.value.description,
user_id: user,
project_id: currentTimeEntry.value.project_id,
start: currentTimeEntry.value.start,
end: null,
tags: currentTimeEntry.value.tags,
},
{
params: {
organization: organization,
timeEntry: currentTimeEntry.value.id,
},
}
);
// currentTimeEntry.value = response.data;
} else {
throw new Error(
'Failed to fetch current time entry because organization ID is missing.'
);
}
}
const isActive = computed(() => {
if (currentTimeEntry.value) {
return (
currentTimeEntry.value.start !== '' &&
currentTimeEntry.value.start !== null &&
currentTimeEntry.value.end === null
);
}
return false;
});
return {
currentTimeEntry,
fetchCurrentTimeEntry,
startTimer,
stopTimer,
updateTimer,
isActive,
};
});

View File

@@ -0,0 +1,24 @@
import { defineStore } from 'pinia';
import { api } from '../../../openapi.json.client';
import { computed, ref } from 'vue';
import type { ZodiosResponseByAlias } from '@zodios/core';
import type { SolidTimeApi } from '@/utils/api';
type ProjectResponse = ZodiosResponseByAlias<SolidTimeApi, 'getProjects'>;
export type Project = ProjectResponse['data'][0];
export const useProjectsStore = defineStore('projects', () => {
const projectResponse = ref<ProjectResponse | null>(null);
async function fetchProjects(organizationId: string) {
projectResponse.value = await api.getProjects({
params: {
organization: organizationId,
},
});
}
const projects = computed(() => projectResponse.value?.data || []);
return { projects, fetchProjects };
});

View File

@@ -0,0 +1,53 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import type { ZodiosResponseByAlias } from '@zodios/core';
import type { SolidTimeApi } from '@/utils/api';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '../../../openapi.json.client';
type TagIndexResponse = ZodiosResponseByAlias<SolidTimeApi, 'getTags'>;
export type Tag = TagIndexResponse['data'][0];
export const useTagsStore = defineStore('tags', () => {
const tags = ref<Tag[]>([]);
async function fetchTags() {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const response = await api.getTags({
params: {
organization: organizationId,
},
});
tags.value = response.data;
} else {
throw new Error(
'Failed to fetch current tags because organization ID is missing.'
);
}
}
async function createTag(name: string) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const response = await api.createTag(
{
name: name,
},
{
params: {
organization: organizationId,
},
}
);
tags.value.unshift(response.data);
return response.data;
} else {
throw new Error(
'Failed to create tag because organization ID is missing.'
);
}
}
return { tags, fetchTags, createTag };
});

View File

@@ -0,0 +1,17 @@
import { usePage } from '@inertiajs/vue3';
import type { User } from '@/types/models';
const page = usePage<{
auth: {
user: User;
};
}>();
function getCurrentUserId() {
return page.props.auth.user.id;
}
function getCurrentOrganizationId() {
return page.props.auth.user.current_team_id;
}
export { getCurrentOrganizationId, getCurrentUserId };

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Inertia\Inertia;
/*
@@ -32,6 +33,308 @@ Route::middleware([
'verified',
])->group(function () {
Route::get('/dashboard', function () {
return Inertia::render('Dashboard');
return Inertia::render('Dashboard', [
'weeklyProjectOverview' => [
[
'value' => 120,
'name' => 'Project 11',
'color' => '#26a69a',
],
[
'value' => 200,
'name' => 'Project 2',
'color' => '#d4e157',
],
[
'value' => 150,
'name' => 'Project 3',
'color' => '#ff7043',
],
],
'latestTasks' => [
// the 4 tasks with the most recent time entries
[
'id' => Str::uuid(),
'name' => 'Task 1',
'project_name' => 'Research',
'project_id' => Str::uuid(),
],
[
'id' => Str::uuid(),
'name' => 'Task 2',
'project_name' => 'Research',
'project_id' => Str::uuid(),
],
[
'id' => Str::uuid(),
'name' => 'Task 3',
'project_name' => 'Research',
'project_id' => Str::uuid(),
],
[
'id' => Str::uuid(),
'name' => 'Task 4',
'project_name' => 'Research',
'project_id' => Str::uuid(),
],
],
'lastSevenDays' => [
// the last 7 days with statistics for the time entries
[
'date' => '2024-02-26',
'duration' => 3600, // in seconds
// if that is too difficult we can just skip that for now
'history' => [
// duration in s of the 3h windows for the day starting at 00:00
300,
0,
500,
0,
100,
200,
100,
300,
],
],
[
'date' => '2024-02-25',
'duration' => 7200, // in seconds
'history' => [
// duration in s of the 3h windows for the day starting at 00:00
300,
0,
500,
0,
100,
200,
100,
300,
],
],
[
'date' => '2024-02-24',
'duration' => 10800, // in seconds
'history' => [
// duration in s of the 3h windows for the day starting at 00:00
300,
0,
500,
0,
100,
200,
100,
300,
],
],
[
'date' => '2024-02-23',
'duration' => 14400, // in seconds
'history' => [
// duration in s of the 3h windows for the day starting at 00:00
300,
0,
500,
0,
100,
200,
100,
300,
],
],
[
'date' => '2024-02-22',
'duration' => 18000, // in seconds
'history' => [
// duration in s of the 3h windows for the day starting at 00:00
300,
0,
500,
0,
100,
200,
100,
300,
],
],
[
'date' => '2024-02-21',
'duration' => 21600, // in seconds
'history' => [
// duration in s of the 3h windows for the day starting at 00:00
300,
0,
500,
0,
100,
200,
100,
300,
],
],
[
'date' => '2024-02-20',
'duration' => 25200, // in seconds
'history' => [
// duration in s of the 3h windows for the day starting at 00:00
300,
0,
500,
0,
100,
200,
100,
300,
],
],
],
'latestTeamActivity' => [
// the 4 most recently active members of your team with user_id, name, description of the latest time entry, time_entry_id, task_id and a boolean status if the team member is currently working
[
'user_id' => Str::uuid(),
'name' => 'John Doe',
'description' => 'Working on the new feature',
'time_entry_id' => Str::uuid(),
'task_id' => Str::uuid(),
'status' => true,
],
[
'user_id' => Str::uuid(),
'name' => 'Jane Doe',
'description' => 'Working on the new feature',
'time_entry_id' => Str::uuid(),
'task_id' => Str::uuid(),
'status' => false,
],
[
'user_id' => Str::uuid(),
'name' => 'John Smith',
'description' => 'Working on the new feature',
'time_entry_id' => Str::uuid(),
'task_id' => Str::uuid(),
'status' => true,
],
[
'user_id' => Str::uuid(),
'name' => 'Jane Smith',
'description' => 'Working on the new feature',
'time_entry_id' => Str::uuid(),
'task_id' => Str::uuid(),
'status' => false,
],
],
'dailyTrackedHours' => [
// not really sure how many days we need here but probably around 60
// the second value is the duration in seconds
['2024-01-21', 10],
['2024-01-22', 10],
['2024-01-23', 20],
['2024-01-24', 10],
['2024-01-25', 10],
['2024-01-26', 10],
['2024-01-27', 20],
['2024-01-28', 10],
['2024-01-29', 20],
['2024-01-30', 10],
['2024-01-31', 10],
['2024-02-01', 20],
['2024-02-02', 20],
['2024-02-03', 10],
['2024-02-04', 30],
['2024-02-05', 10],
['2024-02-06', 20],
['2024-02-07', 10],
['2024-02-08', 30],
['2024-02-09', 10],
['2024-02-10', 10],
['2024-02-11', 10],
['2024-02-12', 10],
['2024-02-13', 10],
['2024-02-14', 20],
['2024-02-15', 10],
['2024-02-16', 10],
['2024-02-17', 10],
['2024-02-18', 10],
['2024-02-19', 10],
['2024-02-20', 30],
['2024-02-21', 20],
['2024-02-22', 20],
['2024-02-23', 30],
['2024-02-24', 20],
['2024-02-25', 10],
['2024-02-26', 10],
['2024-02-27', 10],
['2024-02-28', 20],
['2024-02-29', 10],
['2024-03-01', 10],
['2024-03-02', 20],
['2024-03-03', 10],
['2024-03-04', 30],
['2024-03-05', 10],
['2024-03-06', 20],
['2024-03-07', 30],
['2024-03-08', 10],
['2024-03-09', 20],
['2024-03-10', 10],
['2024-03-11', 10],
['2024-03-12', 10],
['2024-03-13', 10],
['2024-03-14', 10],
['2024-03-15', 10],
['2024-03-16', 10],
['2024-03-17', 10],
['2024-03-18', 10],
['2024-03-19', 10],
['2024-03-20', 10],
['2024-03-21', 10],
['2024-03-22', 10],
['2024-03-23', 10],
['2024-03-24', 10],
['2024-03-25', 10],
['2024-03-26', 10],
['2024-03-27', 10],
['2024-03-28', 10],
['2024-03-29', 10],
['2024-03-30', 10],
['2024-03-31', 10],
],
'totalWeeklyTime' => 400,
'totalWeeklyBillableTime' => 300,
'totalWeeklyBillableAmount' => [
'value' => 300.5,
'currency' => 'USD',
],
'weeklyHistory' => [
// statistics for the current week starting at Monday / Sunday
[
'date' => '2024-02-26',
'duration' => 3600,
],
[
'date' => '2024-02-27',
'duration' => 2000,
],
[
'date' => '2024-02-28',
'duration' => 4000,
],
[
'date' => '2024-02-29',
'duration' => 3000,
],
[
'date' => '2024-03-01',
'duration' => 5000,
],
[
'date' => '2024-03-02',
'duration' => 3000,
],
[
'date' => '2024-03-03',
'duration' => 2000,
],
],
]);
})->name('dashboard');
});

View File

@@ -15,7 +15,41 @@ export default {
theme: {
extend: {
fontFamily: {
sans: ['Figtree', ...defaultTheme.fontFamily.sans],
sans: ['Outfit', ...defaultTheme.fontFamily.sans],
},
colors: {
'white': '#D9DCFB',
'default-background': '#040618',
'default-background-seperator': '#13152B',
'card-background': 'var(--theme-color-card-background)',
'card-background-active': '#1C1E34',
'card-background-seperator': '#262A51',
'card-border': '#242940',
'card-border-active': '#2A3461',
'muted': '#8F93B7',
'icon-default': 'var(--theme-color-icon-default)',
'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',
'accent': {
'50': '#eff7ff',
'100': '#daecff',
'200': '#b0d7ff',
'300': '#91caff',
'400': '#5eadfc',
'500': '#388bf9',
'600': '#226cee',
'700': '#1a57db',
'800': '#1c46b1',
'900': '#1c3f8c',
'950': '#162755',
},
},
},
},