mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
add dashboard frontend
This commit is contained in:
@@ -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',
|
||||
}
|
||||
|
||||
15
README.md
15
README.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
507
e2e/timetracker.spec.ts
Normal 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
1
openapi.json
Normal file
File diff suppressed because one or more lines are too long
833
openapi.json.client.ts
Normal file
833
openapi.json.client.ts
Normal 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
1086
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
|
||||
BIN
public/fonts/Outfit-VariableFont_wght.ttf
Normal file
BIN
public/fonts/Outfit-VariableFont_wght.ttf
Normal file
Binary file not shown.
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
13
resources/js/Components/CurrentSidebarTimer.vue
Normal file
13
resources/js/Components/CurrentSidebarTimer.vue
Normal 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>
|
||||
87
resources/js/Components/Dashboard/ActivityGraphCard.vue
Normal file
87
resources/js/Components/Dashboard/ActivityGraphCard.vue
Normal 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>
|
||||
25
resources/js/Components/Dashboard/DashboardCard.vue
Normal file
25
resources/js/Components/Dashboard/DashboardCard.vue
Normal 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>
|
||||
103
resources/js/Components/Dashboard/DayOverviewCardEntry.vue
Normal file
103
resources/js/Components/Dashboard/DayOverviewCardEntry.vue
Normal 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>
|
||||
22
resources/js/Components/Dashboard/LastSevenDaysCard.vue
Normal file
22
resources/js/Components/Dashboard/LastSevenDaysCard.vue
Normal 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>
|
||||
105
resources/js/Components/Dashboard/ProjectsChartCard.vue
Normal file
105
resources/js/Components/Dashboard/ProjectsChartCard.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
27
resources/js/Components/Dashboard/TeamActivityCard.vue
Normal file
27
resources/js/Components/Dashboard/TeamActivityCard.vue
Normal 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>
|
||||
31
resources/js/Components/Dashboard/TeamActivityCardEntry.vue
Normal file
31
resources/js/Components/Dashboard/TeamActivityCardEntry.vue
Normal 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>
|
||||
171
resources/js/Components/Dashboard/ThisWeekOverview.vue
Normal file
171
resources/js/Components/Dashboard/ThisWeekOverview.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
34
resources/js/Components/NavigationSidebarItem.vue
Normal file
34
resources/js/Components/NavigationSidebarItem.vue
Normal 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>
|
||||
134
resources/js/Components/OrganizationSwitcher.vue
Normal file
134
resources/js/Components/OrganizationSwitcher.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
178
resources/js/Components/TimeTracker.vue
Normal file
178
resources/js/Components/TimeTracker.vue
Normal 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>
|
||||
89
resources/js/Components/UserSettingsIcon.vue
Normal file
89
resources/js/Components/UserSettingsIcon.vue
Normal 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>
|
||||
@@ -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>
|
||||
42
resources/js/Components/common/BillableToggleButton.vue
Normal file
42
resources/js/Components/common/BillableToggleButton.vue
Normal 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>
|
||||
22
resources/js/Components/common/CardTitle.vue
Normal file
22
resources/js/Components/common/CardTitle.vue
Normal 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>
|
||||
51
resources/js/Components/common/ProjectBadge.vue
Normal file
51
resources/js/Components/common/ProjectBadge.vue
Normal 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>
|
||||
162
resources/js/Components/common/ProjectDropdown.vue
Normal file
162
resources/js/Components/common/ProjectDropdown.vue
Normal 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>
|
||||
19
resources/js/Components/common/ProjectDropdownItem.vue
Normal file
19
resources/js/Components/common/ProjectDropdownItem.vue
Normal 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>
|
||||
18
resources/js/Components/common/StatCard.vue
Normal file
18
resources/js/Components/common/StatCard.vue
Normal 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>
|
||||
220
resources/js/Components/common/TagDropdown.vue
Normal file
220
resources/js/Components/common/TagDropdown.vue
Normal 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>
|
||||
28
resources/js/Components/common/TagDropdownItem.vue
Normal file
28
resources/js/Components/common/TagDropdownItem.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import { computed } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
selected: boolean;
|
||||
}>();
|
||||
|
||||
const iconClasses = computed(() => {
|
||||
if (props.selected) {
|
||||
return 'text-accent-200';
|
||||
} else {
|
||||
return 'text-card-border';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center space-x-3 w-full px-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>
|
||||
89
resources/js/Components/common/TimeTrackerStartStop.vue
Normal file
89
resources/js/Components/common/TimeTrackerStartStop.vue
Normal 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>
|
||||
@@ -1,476 +1,110 @@
|
||||
<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 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>
|
||||
<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')"
|
||||
: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>
|
||||
|
||||
<div class="text-muted font-semibold text-sm pt-6 pb-4">
|
||||
Manage
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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-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>
|
||||
|
||||
<!-- Responsive Navigation Menu -->
|
||||
<div
|
||||
:class="{
|
||||
block: showingNavigationDropdown,
|
||||
hidden: !showingNavigationDropdown,
|
||||
}"
|
||||
class="sm:hidden">
|
||||
<div class="pt-2 pb-3 space-y-1">
|
||||
<ResponsiveNavLink
|
||||
:href="route('dashboard')"
|
||||
:active="route().current('dashboard')">
|
||||
Dashboard
|
||||
</ResponsiveNavLink>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</nav>
|
||||
|
||||
class="min-h-screen bg-default-background border-l border-default-background-seperator">
|
||||
<!-- 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">
|
||||
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>
|
||||
@@ -481,4 +115,5 @@ const logout = () => {
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
<AppLayout title="Dashboard" data-testid="dashboard_view">
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 overflow-hidden shadow-xl sm:rounded-lg">
|
||||
<Welcome />
|
||||
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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
4
resources/js/utils/api.ts
Normal file
4
resources/js/utils/api.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { ApiOf } from '@zodios/core';
|
||||
import { api } from '../../../openapi.json.client';
|
||||
|
||||
export type SolidTimeApi = ApiOf<typeof api>;
|
||||
25
resources/js/utils/color.ts
Normal file
25
resources/js/utils/color.ts
Normal 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)];
|
||||
}
|
||||
6
resources/js/utils/money.ts
Normal file
6
resources/js/utils/money.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function formatMoney(amount: number, currency: string) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
}).format(amount);
|
||||
}
|
||||
8
resources/js/utils/time.ts
Normal file
8
resources/js/utils/time.ts
Normal 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]');
|
||||
}
|
||||
151
resources/js/utils/useCurrentTimeEntry.ts
Normal file
151
resources/js/utils/useCurrentTimeEntry.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
24
resources/js/utils/useProjects.ts
Normal file
24
resources/js/utils/useProjects.ts
Normal 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 };
|
||||
});
|
||||
53
resources/js/utils/useTags.ts
Normal file
53
resources/js/utils/useTags.ts
Normal 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 };
|
||||
});
|
||||
17
resources/js/utils/useUser.ts
Normal file
17
resources/js/utils/useUser.ts
Normal 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 };
|
||||
305
routes/web.php
305
routes/web.php
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user