mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-13 12:52:41 +01:00
add time overview page
This commit is contained in:
438
e2e/time.spec.ts
Normal file
438
e2e/time.spec.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import { expect, Page } from '@playwright/test';
|
||||
import {
|
||||
assertThatTimerHasStarted,
|
||||
assertThatTimerIsStopped,
|
||||
newTimeEntryResponse,
|
||||
startOrStopTimerWithButton,
|
||||
stoppedTimeEntryResponse,
|
||||
} from './utils/currentTimeEntry';
|
||||
|
||||
async function goToTimeOverview(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
}
|
||||
|
||||
async function createEmptyTimeEntry(page: Page) {
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
assertThatTimerHasStarted(page),
|
||||
]);
|
||||
await page.waitForTimeout(1500);
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
}
|
||||
|
||||
test('test that starting and stopping an empty time entry shows a new time entry in the overview', async ({
|
||||
page,
|
||||
}) => {
|
||||
await Promise.all([
|
||||
goToTimeOverview(page),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.status() === 200
|
||||
),
|
||||
]);
|
||||
|
||||
// check that there are not testid time_entry_row elements on the page
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
const initialTimeEntryCount = await timeEntryRows.count();
|
||||
await createEmptyTimeEntry(page);
|
||||
|
||||
await expect(timeEntryRows).toHaveCount(initialTimeEntryCount + 1);
|
||||
});
|
||||
|
||||
// Test that description update works
|
||||
|
||||
test('test that updating a description of a time entry in the overview works on blur', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToTimeOverview(page);
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
await createEmptyTimeEntry(page);
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.status() === 200
|
||||
);
|
||||
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
await newTimeEntry.locator('[data-testid="timer_button"].bg-accent-300/70');
|
||||
const newDescription = Math.floor(Math.random() * 1000000).toString();
|
||||
const descriptionElement = newTimeEntry.getByTestId(
|
||||
'time_entry_description'
|
||||
);
|
||||
await descriptionElement.fill(newDescription);
|
||||
await Promise.all([
|
||||
descriptionElement.press('Tab'),
|
||||
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 == newDescription &&
|
||||
(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([])
|
||||
);
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that updating a description of a time entry in the overview works on enter', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToTimeOverview(page);
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
await createEmptyTimeEntry(page);
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.status() === 200
|
||||
);
|
||||
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
await newTimeEntry.locator('[data-testid="timer_button"].bg-accent-300/70');
|
||||
const newDescription = Math.floor(Math.random() * 1000000).toString();
|
||||
const descriptionElement = newTimeEntry.getByTestId(
|
||||
'time_entry_description'
|
||||
);
|
||||
await descriptionElement.fill(newDescription);
|
||||
await Promise.all([
|
||||
descriptionElement.press('Enter'),
|
||||
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 == newDescription &&
|
||||
(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([])
|
||||
);
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that adding a new tag to an existing time entry works', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToTimeOverview(page);
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
await createEmptyTimeEntry(page);
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.status() === 200
|
||||
);
|
||||
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
await newTimeEntry.locator('[data-testid="timer_button"].bg-accent-300/70');
|
||||
const newTagName = Math.floor(Math.random() * 1000000).toString();
|
||||
|
||||
await newTimeEntry.getByTestId('time_entry_tag_dropdown').click();
|
||||
await newTimeEntry.getByTestId('tag_dropdown_search').fill(newTagName);
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 201 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.name === newTagName
|
||||
);
|
||||
}),
|
||||
newTimeEntry.getByTestId('tag_dropdown_search').press('Enter'),
|
||||
]);
|
||||
|
||||
await expect(newTimeEntry.getByTestId('tag_dropdown_search')).toHaveValue(
|
||||
''
|
||||
);
|
||||
await expect(
|
||||
newTimeEntry.getByRole('option', { name: newTagName })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// Test that Start / End Time Update Works
|
||||
test('test that updating a the start of an existing time entry in the overview works on blur', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToTimeOverview(page);
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
await createEmptyTimeEntry(page);
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.status() === 200
|
||||
);
|
||||
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
await newTimeEntry.locator('[data-testid="timer_button"].bg-accent-300/70');
|
||||
await page.waitForTimeout(1500);
|
||||
const timeEntryRangeElement = newTimeEntry.getByTestId(
|
||||
'time_entry_range_selector'
|
||||
);
|
||||
await timeEntryRangeElement.click();
|
||||
await newTimeEntry
|
||||
.getByTestId('time_entry_range_start')
|
||||
.getByTestId('time_picker_hour')
|
||||
.fill('1');
|
||||
await newTimeEntry
|
||||
.getByTestId('time_entry_range_start')
|
||||
.getByTestId('time_picker_minute')
|
||||
.fill('1');
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
// TODO! Actually check the value
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null
|
||||
);
|
||||
}),
|
||||
newTimeEntry
|
||||
.getByTestId('time_entry_range_end')
|
||||
.getByTestId('time_picker_minute')
|
||||
.press('Tab'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that updating a the duration in the overview works on blur', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToTimeOverview(page);
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
await createEmptyTimeEntry(page);
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.status() === 200
|
||||
);
|
||||
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
await newTimeEntry.locator('[data-testid="timer_button"].bg-accent-300/70');
|
||||
await page.waitForTimeout(1500);
|
||||
const timeEntryDurationInput = newTimeEntry.getByTestId(
|
||||
'time_entry_duration_input'
|
||||
);
|
||||
await timeEntryDurationInput.fill('20min');
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
// TODO! Actually check the value
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null
|
||||
);
|
||||
}),
|
||||
timeEntryDurationInput.press('Tab'),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
newTimeEntry.getByTestId('time_entry_duration_input')
|
||||
).toHaveValue('00h 20min');
|
||||
});
|
||||
|
||||
// Test that start stop button stops running timer
|
||||
test('test that stopping a time entry from the overview works', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToTimeOverview(page);
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
assertThatTimerHasStarted(page),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.status() === 200
|
||||
),
|
||||
]);
|
||||
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
const stopButton = newTimeEntry.getByTestId('timer_button');
|
||||
await newTimeEntry.locator('[data-testid="timer_button"].bg-red-400/80');
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null
|
||||
);
|
||||
}),
|
||||
stopButton.click(),
|
||||
]);
|
||||
|
||||
await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass(
|
||||
/bg-accent-300\/50/
|
||||
);
|
||||
});
|
||||
|
||||
// Test that start stop button stops running timer
|
||||
test('test that starting a time entry from the overview works', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToTimeOverview(page);
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
await createEmptyTimeEntry(page);
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.status() === 200
|
||||
);
|
||||
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
const startButton = newTimeEntry.getByTestId('timer_button');
|
||||
await expect(startButton).toHaveClass(/bg-accent-300\/50/);
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null
|
||||
);
|
||||
}),
|
||||
startButton.click(),
|
||||
]);
|
||||
|
||||
await expect(startButton).toHaveClass(/bg-red-500\/80/);
|
||||
await page.waitForTimeout(1500);
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null
|
||||
);
|
||||
}),
|
||||
startOrStopTimerWithButton(page),
|
||||
expect(startButton).toHaveClass(/bg-accent-300\/50/),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that updating a the duration in the overview for a running timer works on blur', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToTimeOverview(page);
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
assertThatTimerHasStarted(page),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.status() === 200
|
||||
),
|
||||
]);
|
||||
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
const startButton = newTimeEntry.getByTestId('timer_button');
|
||||
await page.waitForTimeout(1500);
|
||||
const timeEntryDurationInput = newTimeEntry.getByTestId(
|
||||
'time_entry_duration_input'
|
||||
);
|
||||
await timeEntryDurationInput.fill('20min');
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
// TODO! Actually check the value
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null
|
||||
);
|
||||
}),
|
||||
timeEntryDurationInput.press('Tab'),
|
||||
]);
|
||||
|
||||
await expect(page.getByTestId('time_entry_time')).toHaveValue('00:20:00');
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null
|
||||
);
|
||||
}),
|
||||
startOrStopTimerWithButton(page),
|
||||
expect(startButton).toHaveClass(/bg-accent-300\/50/),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that deleting a time entry from the overview works', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToTimeOverview(page);
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
await createEmptyTimeEntry(page);
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.status() === 200
|
||||
);
|
||||
const timeEntryCount = await timeEntryRows.count();
|
||||
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
const actionsDropdown = newTimeEntry.getByTestId('time_entry_actions');
|
||||
await actionsDropdown.click();
|
||||
const deleteButton = newTimeEntry.getByTestId('time_entry_delete');
|
||||
await deleteButton.click();
|
||||
await expect(timeEntryRows).toHaveCount(timeEntryCount - 1);
|
||||
});
|
||||
|
||||
// TODO: Test that updating the time entry start / end times works while it is running
|
||||
|
||||
// TODO: Test for project update
|
||||
|
||||
// TODO: Test for resume button click works with project / task
|
||||
|
||||
// TODO: Test that time entries are loaded at the end of the page
|
||||
@@ -1,75 +1,29 @@
|
||||
import { expect, test } from '../playwright/fixtures';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import {
|
||||
assertThatTimerHasStarted,
|
||||
assertThatTimerIsStopped,
|
||||
newTimeEntryResponse,
|
||||
startOrStopTimerWithButton,
|
||||
stoppedTimeEntryResponse,
|
||||
} from './utils/currentTimeEntry';
|
||||
|
||||
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'
|
||||
);
|
||||
}
|
||||
|
||||
function newTimeEntryResponse(page) {
|
||||
return 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 assertThatTimerIsStopped(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);
|
||||
const newTimeEntryPromise = newTimeEntryResponse(page);
|
||||
await startOrStopTimerWithButton(page);
|
||||
await newTimeEntryPromise;
|
||||
await assertThatTimerHasStarted(page);
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
assertThatTimerHasStarted(page),
|
||||
]);
|
||||
await page.waitForTimeout(1500);
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
(await response.headerValue('Content-Type')) ===
|
||||
'application/json' &&
|
||||
(await response.json()).data.id !== null &&
|
||||
(await response.json()).data.start !== null &&
|
||||
(await response.json()).data.end !== null &&
|
||||
(await response.json()).data.project_id === null &&
|
||||
(await response.json()).data.description === '' &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration !== null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
JSON.stringify((await response.json()).data.tags) ===
|
||||
JSON.stringify([])
|
||||
);
|
||||
}),
|
||||
stoppedTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
@@ -471,6 +425,12 @@ test('test that adding a new tag when the timer is running', async ({
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
// test that adding a new tag when the timer is running
|
||||
|
||||
// test that search is working
|
||||
|
||||
// test that adding a tag and project and starting the timer afterwards works and sets the project and tag correctly
|
||||
|
||||
// test that changing the project works
|
||||
|
||||
// test that sidebar timetracker starts and stops timer
|
||||
|
||||
// test that sidebar timetracker changes state when tmer on dashboard is started
|
||||
|
||||
59
e2e/utils/currentTimeEntry.ts
Normal file
59
e2e/utils/currentTimeEntry.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export async function startOrStopTimerWithButton(page: Page) {
|
||||
await page
|
||||
.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
|
||||
.click();
|
||||
}
|
||||
|
||||
export async function assertThatTimerHasStarted(page: Page) {
|
||||
await page.locator(
|
||||
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-red-400/80'
|
||||
);
|
||||
}
|
||||
|
||||
export function newTimeEntryResponse(page: Page) {
|
||||
return 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([])
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function assertThatTimerIsStopped(page: Page) {
|
||||
await page.locator(
|
||||
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
|
||||
);
|
||||
}
|
||||
|
||||
export async function stoppedTimeEntryResponse(page: Page) {
|
||||
return 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([])
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,16 @@ const ClientCollection = z.array(ClientResource);
|
||||
const v1_import_import_Body = z
|
||||
.object({ type: z.string(), data: z.string() })
|
||||
.passthrough();
|
||||
const MemberResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
role: z.string(),
|
||||
is_placeholder: z.boolean(),
|
||||
})
|
||||
.passthrough();
|
||||
const MemberCollection = z.array(MemberResource);
|
||||
const OrganizationResource = z
|
||||
.object({ id: z.string(), name: z.string(), is_personal: z.string() })
|
||||
.passthrough();
|
||||
@@ -53,43 +63,40 @@ const TimeEntryResource = z
|
||||
project_id: z.union([z.string(), z.null()]),
|
||||
user_id: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
billable: z.boolean(),
|
||||
})
|
||||
.passthrough();
|
||||
const TimeEntryCollection = z.array(TimeEntryResource);
|
||||
const createTimeEntry_Body = z
|
||||
.object({
|
||||
user_id: z.string().uuid(),
|
||||
project_id: z.union([z.string(), z.null()]).optional(),
|
||||
task_id: z.union([z.string(), z.null()]).optional(),
|
||||
start: z.string(),
|
||||
end: z.union([z.string(), z.null()]).optional(),
|
||||
billable: z.boolean(),
|
||||
description: z.union([z.string(), z.null()]).optional(),
|
||||
tags: z.union([z.array(z.string()), z.null()]).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const updateTimeEntry_Body = z
|
||||
.object({
|
||||
project_id: z.union([z.string(), z.null()]).optional(),
|
||||
task_id: z.union([z.string(), z.null()]).optional(),
|
||||
start: z.string(),
|
||||
end: z.union([z.string(), z.null()]).optional(),
|
||||
billable: z.boolean().optional(),
|
||||
description: z.union([z.string(), z.null()]).optional(),
|
||||
tags: z.union([z.array(z.string()), z.null()]).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const UserResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
role: z.string(),
|
||||
is_placeholder: z.boolean(),
|
||||
})
|
||||
.passthrough();
|
||||
const UserCollection = z.array(UserResource);
|
||||
|
||||
export const schemas = {
|
||||
ClientResource,
|
||||
ClientCollection,
|
||||
v1_import_import_Body,
|
||||
MemberResource,
|
||||
MemberCollection,
|
||||
OrganizationResource,
|
||||
ProjectResource,
|
||||
ProjectCollection,
|
||||
@@ -101,8 +108,6 @@ export const schemas = {
|
||||
TimeEntryCollection,
|
||||
createTimeEntry_Body,
|
||||
updateTimeEntry_Body,
|
||||
UserResource,
|
||||
UserCollection,
|
||||
};
|
||||
|
||||
const endpoints = makeApi([
|
||||
@@ -392,6 +397,89 @@ const endpoints = makeApi([
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/organizations/:organization/members',
|
||||
alias: 'v1.users.index',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: MemberCollection }).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/members/:user/invite-placeholder',
|
||||
alias: 'v1.users.invite-placeholder',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({}).partial().passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
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',
|
||||
@@ -806,6 +894,17 @@ const endpoints = makeApi([
|
||||
],
|
||||
response: z.object({ data: TimeEntryResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
@@ -910,78 +1009,6 @@ const endpoints = makeApi([
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/organizations/:organization/users',
|
||||
alias: 'v1.users.index',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: UserCollection }).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/users/:user/invite-placeholder',
|
||||
alias: 'v1.users.invite-placeholder',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({}).partial().passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.string(),
|
||||
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('/api', endpoints);
|
||||
|
||||
904
package-lock.json
generated
904
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -23,24 +23,27 @@
|
||||
"laravel-vite-plugin": "^1.0.0",
|
||||
"openapi-zod-client": "^1.16.2",
|
||||
"postcss": "^8.4.14",
|
||||
"postcss-nesting": "^12.1.0",
|
||||
"tailwindcss": "^3.1.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-checker": "^0.6.2",
|
||||
"vue": "^3.2.31",
|
||||
"vue": "^3.4.0",
|
||||
"vue-tsc": "^1.8.27",
|
||||
"ziggy-js": "^1.8.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/vue": "^2.1.1",
|
||||
"@rushstack/eslint-patch": "^1.7.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"@vueuse/core": "^10.9.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^5.5.0",
|
||||
"parse-duration": "^1.1.0",
|
||||
"pinia": "^2.1.7",
|
||||
"radix-vue": "^1.4.9",
|
||||
"radix-vue": "^1.5.2",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"vue-echarts": "^6.6.9"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export default defineConfig({
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : 1,
|
||||
workers: 1,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: process.env.CI ? 'line' : 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'postcss-import': {},
|
||||
'tailwindcss/nesting': {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
--theme-color-card-background: #13152B;
|
||||
}
|
||||
|
||||
*{
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
|
||||
[x-cloak] {
|
||||
display: none;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -8,23 +9,27 @@ const props = withDefaults(
|
||||
tag: string;
|
||||
class?: string;
|
||||
color: string;
|
||||
border: boolean;
|
||||
}>(),
|
||||
{
|
||||
size: 'base',
|
||||
tag: 'div',
|
||||
color: 'var(--theme-color-icon-default)',
|
||||
border: true,
|
||||
}
|
||||
);
|
||||
|
||||
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-xs',
|
||||
large: 'py-1.5 px-3 space-x-2 text-sm text-muted',
|
||||
};
|
||||
|
||||
const borderClasses = computed(() => {
|
||||
if (props.border) {
|
||||
return 'border-input-border border';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,17 +39,11 @@ const badgeClasses = {
|
||||
twMerge(
|
||||
props.class,
|
||||
badgeClasses[size],
|
||||
'border-input-border border rounded inline-flex items-center font-semibold text-white'
|
||||
borderClasses,
|
||||
'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>
|
||||
<slot></slot>
|
||||
</component>
|
||||
</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>
|
||||
23
resources/js/Components/Common/CardTitle.vue
Normal file
23
resources/js/Components/Common/CardTitle.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<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-base 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>
|
||||
29
resources/js/Components/Common/DaySectionHeader.vue
Normal file
29
resources/js/Components/Common/DaySectionHeader.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDate, formatHumanReadableDate } from '@/utils/time';
|
||||
|
||||
defineProps<{
|
||||
date: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center space-x-2">
|
||||
<svg class="w-5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none">
|
||||
<path
|
||||
d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7zm-5-9a1 1 0 0 1 1 1v1h2a2 2 0 0 1 2 2v3H3V7a2 2 0 0 1 2-2h2V4a1 1 0 0 1 2 0v1h6V4a1 1 0 0 1 1-1" />
|
||||
</g>
|
||||
</svg>
|
||||
<span class="font-semibold text-white">
|
||||
{{ formatHumanReadableDate(date) }}
|
||||
</span>
|
||||
<span class="font-semibold">
|
||||
{{ formatDate(date) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
41
resources/js/Components/Common/Project/ProjectBadge.vue
Normal file
41
resources/js/Components/Common/Project/ProjectBadge.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import Badge from '@/Components/Common/Badge.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name: string;
|
||||
size: 'base' | 'large';
|
||||
tag: string;
|
||||
class?: string;
|
||||
color: string;
|
||||
border: boolean;
|
||||
}>(),
|
||||
{
|
||||
size: 'base',
|
||||
tag: 'div',
|
||||
color: 'var(--theme-color-icon-default)',
|
||||
border: true,
|
||||
}
|
||||
);
|
||||
|
||||
const indicatorClasses = {
|
||||
base: 'w-2.5 h-2.5',
|
||||
large: 'w-3 h-3',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Badge :name :size :tag :class="props.class" :color :border>
|
||||
<div
|
||||
:style="{ backgroundColor: props.color }"
|
||||
:class="
|
||||
twMerge(indicatorClasses[size], 'inline-block rounded-full')
|
||||
"></div>
|
||||
<span>
|
||||
{{ name }}
|
||||
</span>
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import ProjectBadge from '@/Components/common/ProjectBadge.vue';
|
||||
import ProjectBadge from '@/Components/Common/Project/ProjectBadge.vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { type Project, useProjectsStore } from '@/utils/useProjects';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import Dropdown from '@/Components/Dropdown.vue';
|
||||
import {
|
||||
ComboboxAnchor,
|
||||
@@ -12,19 +12,21 @@ import {
|
||||
ComboboxViewport,
|
||||
} from 'radix-vue';
|
||||
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import ProjectDropdownItem from '@/Components/common/ProjectDropdownItem.vue';
|
||||
import ProjectDropdownItem from '@/Components/Common/Project/ProjectDropdownItem.vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { api } from '../../../../openapi.json.client';
|
||||
import { api } from '../../../../../openapi.json.client';
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { getRandomColor } from '@/utils/color';
|
||||
import type { Project } from '@/utils/api';
|
||||
|
||||
const searchValue = ref('');
|
||||
const searchInput = ref<HTMLElement | null>(null);
|
||||
const model = defineModel<Project>({
|
||||
default: undefined,
|
||||
const model = defineModel<string | null>({
|
||||
default: null,
|
||||
});
|
||||
const open = ref(false);
|
||||
const projectsStore = useProjectsStore();
|
||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||
|
||||
const { projects } = storeToRefs(projectsStore);
|
||||
const projectDropdownTrigger = ref<HTMLElement | null>(null);
|
||||
@@ -36,6 +38,15 @@ const shownProjects = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
border: boolean;
|
||||
}>(),
|
||||
{
|
||||
border: true,
|
||||
}
|
||||
);
|
||||
|
||||
const page = usePage<{
|
||||
auth: {
|
||||
user: {
|
||||
@@ -54,7 +65,7 @@ async function addProjectIfNoneExists() {
|
||||
{ params: { organization: page.props.auth.user.current_team_id } }
|
||||
);
|
||||
projects.value.unshift(response.data);
|
||||
model.value = response.data;
|
||||
model.value = response.data.id;
|
||||
searchValue.value = '';
|
||||
open.value = false;
|
||||
}
|
||||
@@ -67,23 +78,32 @@ watch(open, (isOpen) => {
|
||||
searchInput.value?.$el?.focus();
|
||||
});
|
||||
|
||||
projects.value.sort((a) => {
|
||||
return model.value === a ? -1 : 1;
|
||||
projects.value.sort((iteratingProject) => {
|
||||
return model.value === iteratingProject.id ? -1 : 1;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const currentProject = computed(() => {
|
||||
return projects.value.find((project) => project.id === model.value);
|
||||
});
|
||||
|
||||
function isProjectSelected(project: Project) {
|
||||
return model.value?.id === project.id;
|
||||
return model.value === project.id;
|
||||
}
|
||||
|
||||
const selectedProjectName = computed(() => {
|
||||
return model.value?.name || 'No Project';
|
||||
return currentProject.value?.name || 'No Project';
|
||||
});
|
||||
|
||||
const selectedProjectColor = computed(() => {
|
||||
return model.value?.color || 'var(--theme-color-icon-default)';
|
||||
return currentProject.value?.color || 'var(--theme-color-icon-default)';
|
||||
});
|
||||
|
||||
function updateValue(project: Project) {
|
||||
model.value = project.id;
|
||||
emit('changed');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -93,6 +113,7 @@ const selectedProjectColor = computed(() => {
|
||||
ref="projectDropdownTrigger"
|
||||
:color="selectedProjectColor"
|
||||
size="large"
|
||||
:border
|
||||
tag="button"
|
||||
:name="selectedProjectName"
|
||||
class="focus:border-input-border-active focus:outline-0 focus:bg-card-background-seperator hover:bg-card-background-seperator"></ProjectBadge>
|
||||
@@ -101,8 +122,10 @@ const selectedProjectColor = computed(() => {
|
||||
<template #content>
|
||||
<ComboboxRoot
|
||||
:open="open"
|
||||
v-model="model"
|
||||
v-model:searchTerm="searchValue"
|
||||
:modelValue="currentProject"
|
||||
@update:modelValue="updateValue"
|
||||
@update:searchTerm="(e) => console.log(e)"
|
||||
:searchTerm="searchValue"
|
||||
class="relative">
|
||||
<ComboboxAnchor>
|
||||
<ComboboxInput
|
||||
@@ -119,7 +142,8 @@ const selectedProjectColor = computed(() => {
|
||||
:data-project-id="null"
|
||||
:value="{
|
||||
id: null,
|
||||
name: '',
|
||||
name: 'No Project',
|
||||
color: 'var(--theme-color-icon-default)',
|
||||
}">
|
||||
<ProjectDropdownItem
|
||||
name="No Project"
|
||||
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-3.5 py-2.5">
|
||||
<dt class="font-bold text-sm text-muted">{{ title }}</dt>
|
||||
<dd class="text-2xl text-white pt-1 font-bold">
|
||||
{{ value }}
|
||||
</dd>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
40
resources/js/Components/Common/Tag/TagBadge.vue
Normal file
40
resources/js/Components/Common/Tag/TagBadge.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import Badge from '@/Components/Common/Badge.vue';
|
||||
import { TagIcon } from '@heroicons/vue/20/solid';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name: string;
|
||||
size: 'base' | 'large';
|
||||
tag: string;
|
||||
class?: string;
|
||||
color: string;
|
||||
border: boolean;
|
||||
}>(),
|
||||
{
|
||||
size: 'base',
|
||||
tag: 'div',
|
||||
color: 'var(--theme-color-icon-default)',
|
||||
border: true,
|
||||
}
|
||||
);
|
||||
|
||||
const indicatorClasses = {
|
||||
base: 'w-3 h-3',
|
||||
large: 'w-5 h-5',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Badge :name :size :tag :class="props.class" :color :border>
|
||||
<TagIcon
|
||||
:style="{ color: color }"
|
||||
:class="twMerge(indicatorClasses[size])"></TagIcon>
|
||||
<span>
|
||||
{{ name }}
|
||||
</span>
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
193
resources/js/Components/Common/Tag/TagDropdown.vue
Normal file
193
resources/js/Components/Common/Tag/TagDropdown.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import Dropdown from '@/Components/Dropdown.vue';
|
||||
import { type Component, computed, nextTick, ref, watch } from 'vue';
|
||||
import TagDropdownItem from '@/Components/Common/Tag/TagDropdownItem.vue';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
const tagsStore = useTagsStore();
|
||||
const { tags } = storeToRefs(tagsStore);
|
||||
|
||||
const model = defineModel<string[]>({
|
||||
default: [],
|
||||
});
|
||||
|
||||
const searchInput = ref<HTMLInputElement | 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');
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus();
|
||||
});
|
||||
|
||||
// sort tags alphabetically
|
||||
tags.value.sort((a, b) => {
|
||||
const aIsSelected = model.value.includes(a.id);
|
||||
const bIsSelected = model.value.includes(b.id);
|
||||
if (aIsSelected === bIsSelected) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
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() || '');
|
||||
});
|
||||
});
|
||||
|
||||
async function addTagIfNoneExists() {
|
||||
if (searchValue.value.length > 0 && filteredTags.value.length === 0) {
|
||||
const newTag = await tagsStore.createTag(searchValue.value);
|
||||
addOrRemoveTagFromSelection(newTag.id);
|
||||
searchValue.value = '';
|
||||
} else {
|
||||
if (highlightedItemId.value) {
|
||||
addOrRemoveTagFromSelection(highlightedItemId.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(filteredTags, () => {
|
||||
if (filteredTags.value.length > 0) {
|
||||
highlightedItemId.value = filteredTags.value[0].id;
|
||||
}
|
||||
});
|
||||
|
||||
function updateSearchValue(event: Event) {
|
||||
const newInput = (event.target as HTMLInputElement).value;
|
||||
if (newInput === ' ') {
|
||||
searchValue.value = '';
|
||||
const highlightedTagId = highlightedItemId.value;
|
||||
if (highlightedTagId) {
|
||||
const highlightedTag = tags.value.find(
|
||||
(tag) => tag.id === highlightedTagId
|
||||
);
|
||||
if (highlightedTag) {
|
||||
addOrRemoveTagFromSelection(highlightedTag.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
searchValue.value = newInput;
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||
|
||||
function toggleTag(newValue: string) {
|
||||
if (model.value.includes(newValue)) {
|
||||
model.value = model.value.filter((id) => id !== newValue);
|
||||
} else {
|
||||
model.value.push(newValue);
|
||||
}
|
||||
emit('changed');
|
||||
}
|
||||
|
||||
function moveHighlightUp() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredTags.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
if (currentHightlightedIndex === 0) {
|
||||
highlightedItemId.value =
|
||||
filteredTags.value[filteredTags.value.length - 1].id;
|
||||
} else {
|
||||
highlightedItemId.value =
|
||||
filteredTags.value[currentHightlightedIndex - 1].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function moveHighlightDown() {
|
||||
if (highlightedItem.value) {
|
||||
const currentHightlightedIndex = filteredTags.value.indexOf(
|
||||
highlightedItem.value
|
||||
);
|
||||
if (currentHightlightedIndex === filteredTags.value.length - 1) {
|
||||
highlightedItemId.value = filteredTags.value[0].id;
|
||||
} else {
|
||||
highlightedItemId.value =
|
||||
filteredTags.value[currentHightlightedIndex + 1].id;
|
||||
}
|
||||
}
|
||||
console.log('move down');
|
||||
}
|
||||
|
||||
const highlightedItemId = ref<string | null>(null);
|
||||
const highlightedItem = computed(() => {
|
||||
return tags.value.find((tag) => tag.id === highlightedItemId.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown width="120" v-model="open" :closeOnContentClick="false">
|
||||
<template #trigger>
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
<template #content>
|
||||
<input
|
||||
:value="searchValue"
|
||||
@input="updateSearchValue"
|
||||
@keydown.enter="addTagIfNoneExists"
|
||||
data-testid="tag_dropdown_search"
|
||||
@keydown.up.prevent="moveHighlightUp"
|
||||
@keydown.down.prevent="moveHighlightDown"
|
||||
ref="searchInput"
|
||||
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-seperator focus:border-card-background-seperator w-full"
|
||||
placeholder="Search for a tag..." />
|
||||
<div ref="dropdownViewport" class="w-60">
|
||||
<div
|
||||
v-if="searchValue.length > 0 && filteredTags.length === 0"
|
||||
class="bg-card-background-active">
|
||||
<div
|
||||
class="flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t rounded-b-lg border-card-background-seperator">
|
||||
<PlusCircleIcon
|
||||
class="w-5 flex-shrink-0"></PlusCircleIcon>
|
||||
<span>Add "{{ searchValue }}" as a new Tag</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else></div>
|
||||
<div
|
||||
v-for="tag in filteredTags"
|
||||
:key="tag.id"
|
||||
role="option"
|
||||
:value="tag.id"
|
||||
:class="{
|
||||
'bg-card-background-active':
|
||||
tag.id === highlightedItemId,
|
||||
}"
|
||||
data-testid="tag_dropdown_entries"
|
||||
:data-tag-id="tag.id">
|
||||
<TagDropdownItem
|
||||
:selected="isTagSelected(tag.id)"
|
||||
@click="toggleTag(tag.id)"
|
||||
:name="tag.name"></TagDropdownItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
const value = defineModel();
|
||||
const emit = defineEmits(['changed']);
|
||||
|
||||
function onChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
emit('changed', target.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<label class="input-sizer text-sm font-medium" :data-value="value">
|
||||
<input
|
||||
data-testid="time_entry_description"
|
||||
v-model="value"
|
||||
@blur="onChange"
|
||||
@keydown.enter="onChange"
|
||||
placeholder="Add a description"
|
||||
class="text-white placeholder-muted font-medium bg-transparent hover:bg-card-background rounded-lg border border-transparent hover:border-card-border" />
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
.input-sizer {
|
||||
display: inline-grid;
|
||||
vertical-align: top;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
&.stacked {
|
||||
align-items: stretch;
|
||||
|
||||
&::after,
|
||||
input,
|
||||
textarea {
|
||||
grid-area: 2 / 1;
|
||||
}
|
||||
}
|
||||
|
||||
&::after,
|
||||
input,
|
||||
textarea {
|
||||
width: auto;
|
||||
min-width: 1em;
|
||||
grid-area: 1 / 2;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
resize: none;
|
||||
background: none;
|
||||
appearance: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: attr(data-value) ' ';
|
||||
visibility: hidden;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import Dropdown from '@/Components/Dropdown.vue';
|
||||
import { TrashIcon } from '@heroicons/vue/20/solid';
|
||||
const emit = defineEmits<{
|
||||
delete: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown>
|
||||
<template #trigger>
|
||||
<svg
|
||||
data-testid="time_entry_actions"
|
||||
class="h-10 w-10 p-2 rounded-full hover:bg-card-background opacity-20 group-hover:opacity-100 transition"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
|
||||
</svg>
|
||||
</template>
|
||||
<template #content>
|
||||
<button
|
||||
@click="emit('delete')"
|
||||
data-testid="time_entry_delete"
|
||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
||||
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import Dropdown from '@/Components/Dropdown.vue';
|
||||
import { defineProps, ref, watch } from 'vue';
|
||||
import { formatTime } from '@/utils/time';
|
||||
import TimePicker from '@/Components/Common/TimePicker.vue';
|
||||
import { useFocusWithin } from '@vueuse/core';
|
||||
|
||||
const props = defineProps<{
|
||||
start: string;
|
||||
end: string | null;
|
||||
}>();
|
||||
|
||||
function formatStartEnd(start: string, end: string | null) {
|
||||
if (end) {
|
||||
return `${formatTime(start)} - ${formatTime(end)}`;
|
||||
} else {
|
||||
return `${formatTime(start)} - ...`;
|
||||
}
|
||||
}
|
||||
const emit = defineEmits(['changed']);
|
||||
const tempStart = ref(props.start);
|
||||
const tempEnd = ref(props.end || null);
|
||||
|
||||
watch(props, () => {
|
||||
tempStart.value = props.start;
|
||||
tempEnd.value = props.end;
|
||||
});
|
||||
function updateTimeEntry() {
|
||||
emit('changed', tempStart.value, tempEnd.value);
|
||||
}
|
||||
|
||||
const dropdownContent = ref();
|
||||
const { focused } = useFocusWithin(dropdownContent);
|
||||
|
||||
watch(focused, (newValue, oldValue) => {
|
||||
if (oldValue === true && newValue === false) {
|
||||
console.log(newValue, oldValue);
|
||||
updateTimeEntry();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Dropdown
|
||||
align="right"
|
||||
:close-on-content-click="false"
|
||||
@submit="updateTimeEntry">
|
||||
<template #trigger>
|
||||
<button
|
||||
data-testid="time_entry_range_selector"
|
||||
class="text-muted w-[110px] px-2 py-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium">
|
||||
{{ formatStartEnd(start, end) }}
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
ref="dropdownContent"
|
||||
class="grid grid-cols-2 divide-x divide-card-background-seperator text-center py-1">
|
||||
<div>
|
||||
<div class="font-bold text-white text-sm pb-1">
|
||||
Start
|
||||
</div>
|
||||
<TimePicker
|
||||
data-testid="time_entry_range_start"
|
||||
@updated="updateTimeEntry"
|
||||
v-model="tempStart"></TimePicker>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold text-white text-sm pb-1">End</div>
|
||||
<TimePicker
|
||||
data-testid="time_entry_range_end"
|
||||
@updated="updateTimeEntry"
|
||||
v-model="tempEnd"></TimePicker>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
130
resources/js/Components/Common/TimeEntry/TimeEntryRow.vue
Normal file
130
resources/js/Components/Common/TimeEntry/TimeEntryRow.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import MainContainer from '@/Pages/MainContainer.vue';
|
||||
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
|
||||
import TimeEntryRangeSelector from '@/Components/Common/TimeEntry/TimeEntryRangeSelector.vue';
|
||||
import type { Project, TimeEntry } from '@/utils/api';
|
||||
import { computed } from 'vue';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import ProjectDropdown from '@/Components/Common/Project/ProjectDropdown.vue';
|
||||
import TimeEntryDescriptionInput from '@/Components/Common/TimeEntry/TimeEntryDescriptionInput.vue';
|
||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
||||
import TimeEntryRowTagDropdown from '@/Components/Common/TimeEntry/TimeEntryRowTagDropdown.vue';
|
||||
import TimeEntryRowDurationInput from '@/Components/Common/TimeEntry/TimeEntryRowDurationInput.vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||
import TimeEntryMoreOptionsDropdown from '@/Components/Common/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
|
||||
|
||||
const projectsStore = useProjectsStore();
|
||||
const { projects } = storeToRefs(projectsStore);
|
||||
|
||||
const currentTimeEntryStore = useCurrentTimeEntryStore();
|
||||
const { stopTimer, updateTimer } = currentTimeEntryStore;
|
||||
const { currentTimeEntry } = storeToRefs(currentTimeEntryStore);
|
||||
|
||||
const props = defineProps<{
|
||||
timeEntry: TimeEntry;
|
||||
}>();
|
||||
|
||||
const { updateTimeEntry, createTimeEntry, fetchTimeEntries } =
|
||||
useTimeEntriesStore();
|
||||
|
||||
const timeEntryProject = computed<Project | undefined>(() => {
|
||||
return projects.value.find(
|
||||
(project) => project.id === props.timeEntry.project_id
|
||||
);
|
||||
});
|
||||
|
||||
async function updateStartEndTime(start: string, end: string | null) {
|
||||
if (currentTimeEntry.value.id === props.timeEntry.id) {
|
||||
currentTimeEntry.value.start = start;
|
||||
currentTimeEntry.value.end = end;
|
||||
await updateTimer();
|
||||
} else {
|
||||
await updateTimeEntry({ ...props.timeEntry, start, end });
|
||||
}
|
||||
await fetchTimeEntries();
|
||||
}
|
||||
|
||||
async function onStartStopClick() {
|
||||
if (props.timeEntry.start && !props.timeEntry.end) {
|
||||
await updateTimeEntry({
|
||||
...props.timeEntry,
|
||||
end: dayjs().utc().format(),
|
||||
});
|
||||
} else {
|
||||
if (currentTimeEntry.value.id) {
|
||||
await stopTimer();
|
||||
}
|
||||
await createTimeEntry({
|
||||
...props.timeEntry,
|
||||
start: dayjs().utc().format(),
|
||||
end: null,
|
||||
});
|
||||
}
|
||||
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
|
||||
fetchTimeEntries();
|
||||
}
|
||||
|
||||
function deleteTimeEntry() {
|
||||
useTimeEntriesStore().deleteTimeEntry(props.timeEntry.id);
|
||||
fetchTimeEntries();
|
||||
}
|
||||
|
||||
function updateTimeEntryDescription(description: string) {
|
||||
updateTimeEntry({ ...props.timeEntry, description });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="border-b border-card-border transition"
|
||||
data-testid="time_entry_row">
|
||||
<MainContainer>
|
||||
<div class="flex py-1.5 items-center justify-between group">
|
||||
<div class="flex space-x-1 items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded bg-card-background border-input-border text-accent-500/80 focus:ring-accent-500/80" />
|
||||
<TimeEntryDescriptionInput
|
||||
@changed="updateTimeEntryDescription"
|
||||
:modelValue="
|
||||
timeEntry.description
|
||||
"></TimeEntryDescriptionInput>
|
||||
<ProjectDropdown
|
||||
:border="false"
|
||||
:value="timeEntryProject"></ProjectDropdown>
|
||||
</div>
|
||||
<div class="flex items-center font-medium space-x-2">
|
||||
<TimeEntryRowTagDropdown
|
||||
@changed="updateTimeEntry(timeEntry)"
|
||||
:modelValue="timeEntry.tags"></TimeEntryRowTagDropdown>
|
||||
<div>
|
||||
<TimeEntryRangeSelector
|
||||
:start="timeEntry.start"
|
||||
:end="timeEntry.end"
|
||||
@changed="
|
||||
updateStartEndTime
|
||||
"></TimeEntryRangeSelector>
|
||||
</div>
|
||||
<TimeEntryRowDurationInput
|
||||
:start="timeEntry.start"
|
||||
:end="timeEntry.end"
|
||||
@changed="
|
||||
updateStartEndTime
|
||||
"></TimeEntryRowDurationInput>
|
||||
<TimeTrackerStartStop
|
||||
@changed="onStartStopClick"
|
||||
:active="!!(timeEntry.start && !timeEntry.end)"
|
||||
class="opacity-20 group-hover:opacity-100"></TimeTrackerStartStop>
|
||||
<TimeEntryMoreOptionsDropdown
|
||||
@delete="
|
||||
deleteTimeEntry
|
||||
"></TimeEntryMoreOptionsDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</MainContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { calculateDifference, formatHumanReadableDuration } from '@/utils/time';
|
||||
import { computed, defineProps, ref } from 'vue';
|
||||
import parse from 'parse-duration';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const props = defineProps<{
|
||||
start: string;
|
||||
end: string | null;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
changed: [start: string, end: string | null];
|
||||
}>();
|
||||
|
||||
const temporaryCustomTimerEntry = ref<string>('');
|
||||
|
||||
function updateTimerAndStartLiveTimerUpdate() {
|
||||
const time = parse(temporaryCustomTimerEntry.value, 's');
|
||||
if (time && time > 0) {
|
||||
let newEndDate = props.end;
|
||||
let newStartDate = props.start;
|
||||
if (props.end) {
|
||||
// only update end for time entries that are already finished
|
||||
newEndDate = dayjs(props.start).utc().add(time, 's').format();
|
||||
} else {
|
||||
newStartDate = dayjs().utc().subtract(time, 's').format();
|
||||
}
|
||||
emit('changed', newStartDate, newEndDate);
|
||||
}
|
||||
temporaryCustomTimerEntry.value = '';
|
||||
}
|
||||
|
||||
const currentTime = computed({
|
||||
get() {
|
||||
if (temporaryCustomTimerEntry.value !== '') {
|
||||
return temporaryCustomTimerEntry.value;
|
||||
}
|
||||
return formatHumanReadableDuration(
|
||||
calculateDifference(props.start, props.end)
|
||||
);
|
||||
},
|
||||
// setter
|
||||
set(newValue) {
|
||||
if (newValue) {
|
||||
temporaryCustomTimerEntry.value = newValue;
|
||||
} else {
|
||||
temporaryCustomTimerEntry.value = '';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function selectInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
target.select();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
data-testid="time_entry_duration_input"
|
||||
class="text-white w-[100px] px-3 py-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold"
|
||||
@focus="selectInput"
|
||||
@blur="updateTimerAndStartLiveTimerUpdate"
|
||||
@keydown.enter="updateTimerAndStartLiveTimerUpdate"
|
||||
v-model="currentTime" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import DaySectionHeader from '@/Components/Common/DaySectionHeader.vue';
|
||||
import MainContainer from '@/Pages/MainContainer.vue';
|
||||
defineProps<{
|
||||
date: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-card-background border-t border-b border-card-border py-1.5 text-sm">
|
||||
<MainContainer>
|
||||
<DaySectionHeader :date></DaySectionHeader>
|
||||
</MainContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import TagDropdown from '@/Components/Common/Tag/TagDropdown.vue';
|
||||
import { computed } from 'vue';
|
||||
import TagBadge from '@/Components/Common/Tag/TagBadge.vue';
|
||||
import type { Tag } from '@/utils/api';
|
||||
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: [],
|
||||
});
|
||||
|
||||
const timeEntryTags = computed<Tag[]>(() => {
|
||||
return tags.value.filter((tag) => model.value.includes(tag.id));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagDropdown @changed="emit('changed')" v-model="model">
|
||||
<template #trigger>
|
||||
<button data-testid="time_entry_tag_dropdown">
|
||||
<TagBadge
|
||||
:border="false"
|
||||
size="large"
|
||||
class="border-0"
|
||||
:name="
|
||||
timeEntryTags.map((tag) => tag.name).join(', ')
|
||||
"></TagBadge>
|
||||
</button>
|
||||
</template>
|
||||
</TagDropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
58
resources/js/Components/Common/TimePicker.vue
Normal file
58
resources/js/Components/Common/TimePicker.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const model = defineModel<string | null>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const hours = computed(() => {
|
||||
return model.value ? dayjs(model.value).utc().hour() : null;
|
||||
});
|
||||
|
||||
const minutes = computed(() => {
|
||||
return model.value ? dayjs(model.value).utc().minute() : null;
|
||||
});
|
||||
|
||||
function updateMinutes(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const newValue = target.value;
|
||||
if (parseInt(newValue)) {
|
||||
model.value = dayjs(model.value)
|
||||
.utc()
|
||||
.set('minutes', parseInt(newValue))
|
||||
.format();
|
||||
}
|
||||
}
|
||||
|
||||
function updateHours(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const newValue = target.value;
|
||||
if (parseInt(newValue)) {
|
||||
model.value = dayjs(model.value)
|
||||
.utc()
|
||||
.set('hours', parseInt(newValue))
|
||||
.format();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center">
|
||||
<input
|
||||
:value="hours"
|
||||
@input="updateHours"
|
||||
data-testid="time_picker_hour"
|
||||
type="text"
|
||||
class="bg-card-background border-none text-sm px-1 py-0.5 w-[30px] text-center focus:ring-0 focus:bg-card-background-active" />
|
||||
<span>:</span>
|
||||
<input
|
||||
:value="minutes"
|
||||
@input="updateMinutes"
|
||||
data-testid="time_picker_minute"
|
||||
type="text"
|
||||
class="bg-card-background border-none text-sm px-1 py-0.5 w-[30px] text-center focus:ring-0 focus:bg-card-background-active" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import TagDropdown from '@/Components/Common/Tag/TagDropdown.vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { TagIcon } from '@heroicons/vue/20/solid';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const emit = defineEmits(['changed']);
|
||||
const model = defineModel({
|
||||
default: [],
|
||||
});
|
||||
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';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagDropdown @changed="emit('changed')" v-model="model">
|
||||
<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>
|
||||
<span
|
||||
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 }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</TagDropdown>
|
||||
</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/40 hover:scale-110 hover:bg-accent-300/70 ring-accent-200/10 focus:ring-accent-200/10 hover:ring-4',
|
||||
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/50 hover:bg-accent-400/70 focus:bg-accent-400/70';
|
||||
}
|
||||
});
|
||||
|
||||
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,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import TimeTrackerStartStop from '@/Components/common/TimeTrackerStartStop.vue';
|
||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { formatHumanReadableDuration } from '@/utils/time';
|
||||
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
|
||||
const store = useCurrentTimeEntryStore();
|
||||
const { currentTimeEntry, now, isActive } = storeToRefs(store);
|
||||
const { onToggleButtonPress } = store;
|
||||
|
||||
@@ -1,36 +1,22 @@
|
||||
<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();
|
||||
}
|
||||
import {
|
||||
formatHumanReadableDate,
|
||||
formatHumanReadableDuration,
|
||||
} from '@/utils/time';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 py-1.5 grid grid-cols-3">
|
||||
<div class="px-3.5 py-2 flex @container">
|
||||
<div class="flex items-center">
|
||||
<p class="font-semibold text-sm text-white">
|
||||
{{ dayFormat(date) }}
|
||||
{{ formatHumanReadableDate(date) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="items-center justify-center flex-1 flex">
|
||||
<svg
|
||||
class="w-20 opacity-70 transition hover:opacity-100"
|
||||
viewBox="0 0 42 10"
|
||||
@@ -96,7 +82,8 @@ function dayFormat(date: string) {
|
||||
fill-opacity="0.9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center justify-center text-muted font-semibold">
|
||||
<div
|
||||
class="flex text-sm items-center justify-center text-muted font-semibold">
|
||||
{{ formatHumanReadableDuration(duration) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import ProjectBadge from '@/Components/common/ProjectBadge.vue';
|
||||
import TimeTrackerStartStop from '@/Components/common/TimeTrackerStartStop.vue';
|
||||
import ProjectBadge from '@/Components/Common/Project/ProjectBadge.vue';
|
||||
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
@@ -9,7 +9,7 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 py-2 grid grid-cols-5">
|
||||
<div class="px-3.5 py-2 grid grid-cols-5">
|
||||
<div class="col-span-4">
|
||||
<p class="font-semibold text-white text-sm pb-1">
|
||||
{{ title }}
|
||||
|
||||
@@ -7,25 +7,31 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 py-3 grid grid-cols-3">
|
||||
<div class="px-4 py-2 2xl:py-3">
|
||||
<div class="col-span-2">
|
||||
<p class="font-semibold text-sm text-white">
|
||||
{{ name }}
|
||||
</p>
|
||||
<div class="flex justify-between">
|
||||
<p class="font-semibold text-sm text-white">
|
||||
{{ name }}
|
||||
</p>
|
||||
<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 text-sm block pb-0.5">
|
||||
working
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted text-sm 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 text-sm block pb-0.5">
|
||||
working
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
} from 'echarts/components';
|
||||
import VChart, { THEME_KEY } from 'vue-echarts';
|
||||
import { provide, ref } from 'vue';
|
||||
import StatCard from '@/Components/common/StatCard.vue';
|
||||
import StatCard from '@/Components/Common/StatCard.vue';
|
||||
import { ClockIcon } from '@heroicons/vue/20/solid';
|
||||
import CardTitle from '@/Components/common/CardTitle.vue';
|
||||
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';
|
||||
@@ -130,7 +130,7 @@ const option = ref({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-x-6 xl:gap-x-8 grid-cols-4">
|
||||
<div class="grid gap-x-6 xl:gap-x-6 grid-cols-4">
|
||||
<div class="col-span-3">
|
||||
<CardTitle
|
||||
title="This Week"
|
||||
|
||||
@@ -21,13 +21,17 @@ const props = withDefaults(
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits(['open']);
|
||||
const emit = defineEmits(['open', 'submit']);
|
||||
const open = defineModel({ default: false });
|
||||
|
||||
const closeOnEscape = (e: KeyboardEvent) => {
|
||||
if (open.value && e.key === 'Escape') {
|
||||
open.value = false;
|
||||
}
|
||||
if (open.value && e.key === 'Enter') {
|
||||
emit('submit');
|
||||
if (props.closeOnContentClick) open.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', closeOnEscape));
|
||||
@@ -67,6 +71,11 @@ function toggleOpen() {
|
||||
emit('open');
|
||||
}
|
||||
}
|
||||
|
||||
function onBackgroundClick() {
|
||||
emit('submit');
|
||||
open.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -76,7 +85,10 @@ function toggleOpen() {
|
||||
</div>
|
||||
|
||||
<!-- Full Screen Dropdown Overlay -->
|
||||
<div v-show="open" class="fixed inset-0 z-40" @click="open = false" />
|
||||
<div
|
||||
v-show="open"
|
||||
class="fixed inset-0 z-40"
|
||||
@click="onBackgroundClick" />
|
||||
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue';
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
@@ -11,13 +12,13 @@ defineProps<{
|
||||
|
||||
<template>
|
||||
<li>
|
||||
<a
|
||||
<Link
|
||||
: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',
|
||||
: 'text-muted hover:text-white hover:bg-menu-active ',
|
||||
'group flex gap-x-2 rounded-md px-2 py-1.5 transition leading-6 font-semibold text-sm items-center',
|
||||
]">
|
||||
<component
|
||||
:is="icon"
|
||||
@@ -25,10 +26,10 @@ defineProps<{
|
||||
current
|
||||
? 'text-icon-active'
|
||||
: 'text-icon-default group-hover:text-icon-active',
|
||||
'transition h-6 w-6 shrink-0',
|
||||
'transition h-5 w-5 shrink-0',
|
||||
]"
|
||||
aria-hidden="true" />
|
||||
{{ title }}
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<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 CardTitle from '@/Components/Common/CardTitle.vue';
|
||||
import BillableToggleButton from '@/Components/Common/BillableToggleButton.vue';
|
||||
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
|
||||
import ProjectDropdown from '@/Components/Common/Project/ProjectDropdown.vue';
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { type User } from '@/types/models';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
@@ -14,8 +13,8 @@ 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';
|
||||
import TimeTrackerTagDropdown from '@/Components/Common/TimeTracker/TimeTrackerTagDropdown.vue';
|
||||
|
||||
const page = usePage<{
|
||||
auth: {
|
||||
@@ -70,16 +69,6 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
const currentProject = ref<Project>();
|
||||
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();
|
||||
@@ -122,12 +111,14 @@ function updateTimerAndStartLiveTimerUpdate() {
|
||||
type="text" />
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<ProjectDropdown v-model="currentProject"></ProjectDropdown>
|
||||
<ProjectDropdown
|
||||
@changed="updateTimeEntry"
|
||||
v-model="currentTimeEntry.project_id"></ProjectDropdown>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 px-4">
|
||||
<TagDropdown
|
||||
<TimeTrackerTagDropdown
|
||||
@changed="updateTimeEntry"
|
||||
v-model="currentTimeEntry.tags"></TagDropdown>
|
||||
v-model="currentTimeEntry.tags"></TimeTrackerTagDropdown>
|
||||
<BillableToggleButton></BillableToggleButton>
|
||||
</div>
|
||||
<div class="border-l border-card-border">
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
<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 as any"
|
||||
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-sm 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-xs 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>
|
||||
@@ -16,16 +16,25 @@ import {
|
||||
} from '@heroicons/vue/20/solid';
|
||||
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
|
||||
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
|
||||
import MainContainer from '@/Pages/MainContainer.vue';
|
||||
import { onMounted } from 'vue';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
|
||||
defineProps({
|
||||
title: String,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await useProjectsStore().fetchProjects();
|
||||
await useTagsStore().fetchTags();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap bg-default-background text-muted">
|
||||
<div
|
||||
class="flex-shrink-0 h-screen fixed w-[250px] xl:w-[300px] px-2.5 xl:px-4 py-4 flex flex-col justify-between">
|
||||
class="flex-shrink-0 h-screen fixed w-[230px] 2xl:w-[270px] px-2.5 2xl:px-4 py-4 flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="border-b border-default-background-seperator pb-2">
|
||||
<OrganizationSwitcher></OrganizationSwitcher>
|
||||
@@ -45,7 +54,8 @@ defineProps({
|
||||
<NavigationSidebarItem
|
||||
title="Time"
|
||||
:icon="ClockIcon"
|
||||
:href="route('dashboard')"></NavigationSidebarItem>
|
||||
:current="route().current('time')"
|
||||
:href="route('time')"></NavigationSidebarItem>
|
||||
<NavigationSidebarItem
|
||||
title="Reporting"
|
||||
:icon="ChartBarIcon"
|
||||
@@ -53,9 +63,7 @@ defineProps({
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="text-muted font-semibold text-sm pt-6 pb-4">
|
||||
Manage
|
||||
</div>
|
||||
<div class="text-muted text-sm font-bold pt-6 pb-4">Manage</div>
|
||||
|
||||
<nav>
|
||||
<ul class="space-y-1">
|
||||
@@ -93,7 +101,7 @@ defineProps({
|
||||
<UserSettingsIcon></UserSettingsIcon>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex-1 ml-[250px] xl:ml-[300px]">
|
||||
<div class="flex-1 ml-[230px] 2xl:ml-[270px]">
|
||||
<Head :title="title" />
|
||||
|
||||
<Banner />
|
||||
@@ -104,8 +112,10 @@ defineProps({
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="bg-default-background border-b border-default-background-seperator shadow">
|
||||
<div class="py-6 px-4 sm:px-6 lg:px-8">
|
||||
<slot name="header" />
|
||||
<div class="pt-8 pb-3">
|
||||
<MainContainer>
|
||||
<slot name="header" />
|
||||
</MainContainer>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -5,28 +5,9 @@ import RecentlyTrackedTasksCard from '@/Components/Dashboard/RecentlyTrackedTask
|
||||
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';
|
||||
import MainContainer from '@/Pages/MainContainer.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;
|
||||
@@ -73,7 +54,7 @@ const props = defineProps<{
|
||||
<TimeTracker></TimeTracker>
|
||||
</MainContainer>
|
||||
<MainContainer
|
||||
class="grid gap-x-6 xl:gap-x-8 grid-cols-4 pt-5 pb-6 border-b border-default-background-seperator items-stretch">
|
||||
class="grid gap-x-6 grid-cols-2 xl:grid-cols-4 pt-5 pb-6 border-b border-default-background-seperator items-stretch">
|
||||
<RecentlyTrackedTasksCard
|
||||
:latestTasks="props.latestTasks"></RecentlyTrackedTasksCard>
|
||||
<LastSevenDaysCard
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div class="sm:px-6 lg:px-8 xl:px-10 2xl:px-12 mx-auto">
|
||||
<div class="sm:px-6 lg:px-8 3xl:px-12 mx-auto">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
47
resources/js/Pages/Time.vue
Normal file
47
resources/js/Pages/Time.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import TimeTracker from '@/Components/TimeTracker.vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import MainContainer from '@/Pages/MainContainer.vue';
|
||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import dayjs from 'dayjs';
|
||||
import type { TimeEntry } from '@/utils/api';
|
||||
import TimeEntryRowHeading from '@/Components/Common/TimeEntry/TimeEntryRowHeading.vue';
|
||||
import TimeEntryRow from '@/Components/Common/TimeEntry/TimeEntryRow.vue';
|
||||
|
||||
const timeEntriesStore = useTimeEntriesStore();
|
||||
const { timeEntries } = storeToRefs(timeEntriesStore);
|
||||
|
||||
onMounted(async () => {
|
||||
await timeEntriesStore.fetchTimeEntries();
|
||||
});
|
||||
|
||||
const groupedTimeEntries = computed(() => {
|
||||
const groupedEntries: Record<string, TimeEntry[]> = {};
|
||||
for (const entry of timeEntries.value) {
|
||||
const oldEntries =
|
||||
groupedEntries[dayjs(entry.start).utc().format('YYYY-MM-DD')];
|
||||
const newEntries = [...(oldEntries ?? []), entry];
|
||||
groupedEntries[dayjs(entry.start).utc().format('YYYY-MM-DD')] =
|
||||
newEntries;
|
||||
}
|
||||
return groupedEntries;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Dashboard" data-testid="dashboard_view">
|
||||
<MainContainer
|
||||
class="py-8 border-b border-default-background-seperator">
|
||||
<TimeTracker></TimeTracker>
|
||||
</MainContainer>
|
||||
<div v-for="(value, key) in groupedTimeEntries" :key="key">
|
||||
<TimeEntryRowHeading :date="key"></TimeEntryRowHeading>
|
||||
<TimeEntryRow
|
||||
:key="entry.id"
|
||||
v-for="entry in value"
|
||||
:time-entry="entry"></TimeEntryRow>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -74,27 +74,6 @@ export interface Task {
|
||||
organization: Organization;
|
||||
}
|
||||
|
||||
export interface TimeEntry {
|
||||
// columns
|
||||
id: string;
|
||||
description: string;
|
||||
start: string;
|
||||
end: string | null;
|
||||
billable: boolean;
|
||||
user_id: string;
|
||||
organization_id: string;
|
||||
project_id: string | null;
|
||||
task_id: string | null;
|
||||
tags: string[] | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
// relations
|
||||
user: User;
|
||||
organization: Organization;
|
||||
project: Project;
|
||||
task: Task;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
// columns
|
||||
id: string;
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
import type { ApiOf } from '@zodios/core';
|
||||
import type { ApiOf, ZodiosResponseByAlias } from '@zodios/core';
|
||||
import { api } from '../../../openapi.json.client';
|
||||
|
||||
export type SolidTimeApi = ApiOf<typeof api>;
|
||||
|
||||
export type TimeEntryResponse = ZodiosResponseByAlias<
|
||||
SolidTimeApi,
|
||||
'getTimeEntries'
|
||||
>;
|
||||
export type TimeEntry = TimeEntryResponse['data'][0];
|
||||
|
||||
export type ProjectResponse = ZodiosResponseByAlias<
|
||||
SolidTimeApi,
|
||||
'getProjects'
|
||||
>;
|
||||
export type Project = ProjectResponse['data'][0];
|
||||
|
||||
export type TagIndexResponse = ZodiosResponseByAlias<SolidTimeApi, 'getTags'>;
|
||||
export type Tag = TagIndexResponse['data'][0];
|
||||
|
||||
@@ -1,8 +1,39 @@
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import isToday from 'dayjs/plugin/isToday';
|
||||
import isYesterday from 'dayjs/plugin/isYesterday';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(isToday);
|
||||
dayjs.extend(isYesterday);
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(utc);
|
||||
|
||||
export function formatHumanReadableDuration(duration: number): string {
|
||||
return dayjs.duration(duration, 's').format('HH[h] mm[min]');
|
||||
}
|
||||
|
||||
export function calculateDifference(start: string, end: string | null) {
|
||||
if (end === null) {
|
||||
end = dayjs().utc().format();
|
||||
}
|
||||
return dayjs(end).diff(dayjs(start), 'second');
|
||||
}
|
||||
export function formatTime(date: string) {
|
||||
return dayjs(date).utc().format('HH:mm');
|
||||
}
|
||||
|
||||
export function formatDate(date: string): string {
|
||||
return dayjs(date).utc().format('DD.MM.YYYY');
|
||||
}
|
||||
|
||||
export function formatHumanReadableDate(date: string) {
|
||||
if (dayjs(date).isToday()) {
|
||||
return 'Today';
|
||||
} else if (dayjs(date).isYesterday()) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
return dayjs(date).fromNow();
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
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 type { TimeEntry } from '@/utils/api';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import { getCurrentOrganizationId, getCurrentUserId } from '@/utils/useUser';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
type TimeEntryResponse = ZodiosResponseByAlias<SolidTimeApi, 'getTimeEntries'>;
|
||||
export type TimeEntry = TimeEntryResponse['data'][0];
|
||||
const emptyTimeEntry = {
|
||||
id: '',
|
||||
description: null,
|
||||
@@ -21,11 +20,16 @@ const emptyTimeEntry = {
|
||||
task_id: null,
|
||||
project_id: null,
|
||||
tags: [],
|
||||
billable: false,
|
||||
} as TimeEntry;
|
||||
|
||||
export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
const currentTimeEntry = ref<TimeEntry>(reactive(emptyTimeEntry));
|
||||
|
||||
useLocalStorage('solidtime/current-time-entry', currentTimeEntry, {
|
||||
deep: true,
|
||||
});
|
||||
|
||||
function $reset() {
|
||||
currentTimeEntry.value = { ...emptyTimeEntry };
|
||||
}
|
||||
@@ -84,6 +88,9 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
user_id: user,
|
||||
start: startTime,
|
||||
description: currentTimeEntry.value?.description,
|
||||
project_id: currentTimeEntry.value?.project_id,
|
||||
billable: currentTimeEntry.value.billable,
|
||||
tags: currentTimeEntry.value?.tags,
|
||||
},
|
||||
{ params: { organization: organization } }
|
||||
);
|
||||
@@ -131,6 +138,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
user_id: user,
|
||||
project_id: currentTimeEntry.value.project_id,
|
||||
start: currentTimeEntry.value.start,
|
||||
billable: currentTimeEntry.value.billable,
|
||||
end: null,
|
||||
tags: currentTimeEntry.value.tags,
|
||||
},
|
||||
@@ -168,6 +176,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
stopLiveTimer();
|
||||
await stopTimer();
|
||||
}
|
||||
useTimeEntriesStore().fetchTimeEntries();
|
||||
}
|
||||
|
||||
startLiveTimer();
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
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];
|
||||
import type { Project, ProjectResponse } from '@/utils/api';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
|
||||
export const useProjectsStore = defineStore('projects', () => {
|
||||
const projectResponse = ref<ProjectResponse | null>(null);
|
||||
|
||||
async function fetchProjects(organizationId: string) {
|
||||
projectResponse.value = await api.getProjects({
|
||||
params: {
|
||||
organization: organizationId,
|
||||
},
|
||||
});
|
||||
async function fetchProjects() {
|
||||
const organization = getCurrentOrganizationId();
|
||||
if (organization) {
|
||||
projectResponse.value = await api.getProjects({
|
||||
params: {
|
||||
organization: organization,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const projects = computed(() => projectResponse.value?.data || []);
|
||||
const projects = computed<Project[]>(
|
||||
() => projectResponse.value?.data || []
|
||||
);
|
||||
|
||||
return { projects, fetchProjects };
|
||||
});
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import type { ZodiosResponseByAlias } from '@zodios/core';
|
||||
import type { SolidTimeApi } from '@/utils/api';
|
||||
import type { Tag } 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[]>([]);
|
||||
|
||||
|
||||
69
resources/js/utils/useTimeEntries.ts
Normal file
69
resources/js/utils/useTimeEntries.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { api } from '../../../openapi.json.client';
|
||||
import { reactive, ref } from 'vue';
|
||||
import type { TimeEntry } from '@/utils/api';
|
||||
|
||||
export const useTimeEntriesStore = defineStore('timeEntries', () => {
|
||||
const timeEntries = ref<TimeEntry[]>(reactive([]));
|
||||
|
||||
async function fetchTimeEntries() {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId) {
|
||||
const timeEntriesResponse = await api.getTimeEntries({
|
||||
params: {
|
||||
organization: organizationId,
|
||||
},
|
||||
});
|
||||
timeEntries.value = timeEntriesResponse.data;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTimeEntry(timeEntry: TimeEntry) {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId) {
|
||||
await api.updateTimeEntry(timeEntry, {
|
||||
params: {
|
||||
organization: organizationId,
|
||||
timeEntry: timeEntry.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function createTimeEntry(timeEntry: TimeEntry) {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId) {
|
||||
await api.createTimeEntry(timeEntry, {
|
||||
params: {
|
||||
organization: organizationId,
|
||||
},
|
||||
});
|
||||
await fetchTimeEntries();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTimeEntry(timeEntryId: string) {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId) {
|
||||
await api.deleteTimeEntry(
|
||||
{},
|
||||
{
|
||||
params: {
|
||||
organization: organizationId,
|
||||
timeEntry: timeEntryId,
|
||||
},
|
||||
}
|
||||
);
|
||||
await fetchTimeEntries();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
timeEntries,
|
||||
fetchTimeEntries,
|
||||
updateTimeEntry,
|
||||
createTimeEntry,
|
||||
deleteTimeEntry,
|
||||
};
|
||||
});
|
||||
@@ -33,4 +33,9 @@ Route::middleware([
|
||||
'verified',
|
||||
])->group(function () {
|
||||
Route::get('/dashboard', [DashboardController::class, 'dashboard'])->name('dashboard');
|
||||
|
||||
|
||||
Route::get('/time', function () {
|
||||
return Inertia::render('Time');
|
||||
})->name('time');
|
||||
});
|
||||
|
||||
@@ -54,5 +54,5 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [forms, typography],
|
||||
plugins: [forms, typography, require('@tailwindcss/container-queries')],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user