mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-17 12:42:15 +01:00
add billable rate time entries update support for existing time entries (member & organization)
This commit is contained in:
@@ -1 +1,78 @@
|
|||||||
// TODO: Edit Billable Rate
|
// TODO: Edit Billable Rate
|
||||||
|
|
||||||
|
import { test } from '../playwright/fixtures';
|
||||||
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
|
|
||||||
|
async function goToMembersSection(page) {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test that member billable rate can be updated', async ({ page }) => {
|
||||||
|
await goToMembersSection(page);
|
||||||
|
const newBillableRate = Math.round(Math.random() * 10000);
|
||||||
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
|
await page.getByRole('button').getByText('Edit').first().click();
|
||||||
|
await page.getByText('Organization Default Rate').click();
|
||||||
|
await page.getByText('Custom Rate').click();
|
||||||
|
await page
|
||||||
|
.getByPlaceholder('Billable Rate')
|
||||||
|
.fill(newBillableRate.toString());
|
||||||
|
await page.getByRole('button', { name: 'Update Member' }).click();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
page
|
||||||
|
.getByRole('button', { name: 'No, only for new time entries' })
|
||||||
|
.click(),
|
||||||
|
page.waitForRequest(
|
||||||
|
async (request) =>
|
||||||
|
request.url().includes('/members/') &&
|
||||||
|
request.method() === 'PUT' &&
|
||||||
|
request.postDataJSON().billable_rate ===
|
||||||
|
newBillableRate * 100 &&
|
||||||
|
request.postDataJSON().billable_rate_update_time_entries ===
|
||||||
|
false
|
||||||
|
),
|
||||||
|
/* page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes("/organizations/") &&
|
||||||
|
response.request().method() === "PUT" &&
|
||||||
|
response.status() === 200 &&
|
||||||
|
(await response.json()).data.billable_rate === (newBillableRate * 100)
|
||||||
|
)*/
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that organization billable rate can be updated with all existing time entries', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await goToMembersSection(page);
|
||||||
|
const newBillableRate = Math.round(Math.random() * 10000);
|
||||||
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
|
await page.getByRole('button').getByText('Edit').first().click();
|
||||||
|
await page.getByText('Organization Default Rate').click();
|
||||||
|
await page.getByText('Custom Rate').click();
|
||||||
|
await page
|
||||||
|
.getByPlaceholder('Billable Rate')
|
||||||
|
.fill(newBillableRate.toString());
|
||||||
|
await page.getByRole('button', { name: 'Update Member' }).click();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Yes, update existing time' }).click(),
|
||||||
|
page.waitForRequest(
|
||||||
|
async (request) =>
|
||||||
|
request.url().includes('/members/') &&
|
||||||
|
request.method() === 'PUT' &&
|
||||||
|
request.postDataJSON().billable_rate ===
|
||||||
|
newBillableRate * 100 &&
|
||||||
|
request.postDataJSON().billable_rate_update_time_entries ===
|
||||||
|
true
|
||||||
|
),
|
||||||
|
/* page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes("/organizations/") &&
|
||||||
|
response.request().method() === "PUT" &&
|
||||||
|
response.status() === 200 &&
|
||||||
|
(await response.json()).data.billable_rate === (newBillableRate * 100)
|
||||||
|
)*/
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect } from '../playwright/fixtures';
|
import { expect, test } from '../playwright/fixtures';
|
||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
|
|
||||||
async function goToOrganizationSettings(page) {
|
async function goToOrganizationSettings(page) {
|
||||||
@@ -68,4 +68,78 @@ test('test that error shows if no role is selected', async ({ page }) => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that organization billable rate can be updated', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await goToOrganizationSettings(page);
|
||||||
|
const newBillableRate = Math.round(Math.random() * 10000);
|
||||||
|
await page.getByLabel('Organization Billable Rate').click();
|
||||||
|
await page
|
||||||
|
.getByLabel('Organization Billable Rate')
|
||||||
|
.fill(newBillableRate.toString());
|
||||||
|
await page
|
||||||
|
.locator('button')
|
||||||
|
.filter({ hasText: /^Save$/ })
|
||||||
|
.click();
|
||||||
|
await Promise.all([
|
||||||
|
page
|
||||||
|
.getByRole('button', { name: 'No, only for new time entries' })
|
||||||
|
.click(),
|
||||||
|
page.waitForRequest(
|
||||||
|
async (request) =>
|
||||||
|
request.url().includes('/organizations/') &&
|
||||||
|
request.method() === 'PUT' &&
|
||||||
|
request.postDataJSON().billable_rate ===
|
||||||
|
newBillableRate * 100 &&
|
||||||
|
request.postDataJSON().billable_rate_update_time_entries ===
|
||||||
|
false
|
||||||
|
),
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/organizations/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200 &&
|
||||||
|
(await response.json()).data.billable_rate ===
|
||||||
|
newBillableRate * 100
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that organization billable rate can be updated with all existing time entries', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await goToOrganizationSettings(page);
|
||||||
|
const newBillableRate = Math.round(Math.random() * 10000);
|
||||||
|
await page.getByLabel('Organization Billable Rate').click();
|
||||||
|
await page
|
||||||
|
.getByLabel('Organization Billable Rate')
|
||||||
|
.fill(newBillableRate.toString());
|
||||||
|
await page
|
||||||
|
.locator('button')
|
||||||
|
.filter({ hasText: /^Save$/ })
|
||||||
|
.click();
|
||||||
|
await Promise.all([
|
||||||
|
page
|
||||||
|
.getByRole('button', { name: 'Yes, update existing time entries' })
|
||||||
|
.click(),
|
||||||
|
page.waitForRequest(
|
||||||
|
async (request) =>
|
||||||
|
request.url().includes('/organizations/') &&
|
||||||
|
request.method() === 'PUT' &&
|
||||||
|
request.postDataJSON().billable_rate ===
|
||||||
|
newBillableRate * 100 &&
|
||||||
|
request.postDataJSON().billable_rate_update_time_entries ===
|
||||||
|
true
|
||||||
|
),
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/organizations/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200 &&
|
||||||
|
(await response.json()).data.billable_rate ===
|
||||||
|
newBillableRate * 100
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
// TODO: Add Test for import
|
// TODO: Add Test for import
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ const ClientResource = z
|
|||||||
.object({
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
is_archived: z.boolean(),
|
||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
updated_at: z.string(),
|
updated_at: z.string(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const ClientCollection = z.array(ClientResource);
|
const ClientCollection = z.array(ClientResource);
|
||||||
|
const updateClient_Body = z
|
||||||
|
.object({ name: z.string(), is_archived: z.boolean().optional() })
|
||||||
|
.passthrough();
|
||||||
const importData_Body = z
|
const importData_Body = z
|
||||||
.object({ type: z.string(), data: z.string() })
|
.object({ type: z.string(), data: z.string() })
|
||||||
.passthrough();
|
.passthrough();
|
||||||
@@ -33,9 +37,11 @@ const MemberPivotResource = z
|
|||||||
.passthrough();
|
.passthrough();
|
||||||
const updateMember_Body = z
|
const updateMember_Body = z
|
||||||
.object({
|
.object({
|
||||||
billable_rate: z.union([z.number(), z.null()]).optional(),
|
|
||||||
role: Role,
|
role: Role,
|
||||||
|
billable_rate: z.union([z.number(), z.null()]),
|
||||||
|
billable_rate_update_time_entries: z.boolean(),
|
||||||
})
|
})
|
||||||
|
.partial()
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const MemberResource = z
|
const MemberResource = z
|
||||||
.object({
|
.object({
|
||||||
@@ -60,6 +66,7 @@ const updateOrganization_Body = z
|
|||||||
.object({
|
.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
billable_rate: z.union([z.number(), z.null()]).optional(),
|
billable_rate: z.union([z.number(), z.null()]).optional(),
|
||||||
|
billable_rate_update_time_entries: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const ProjectResource = z
|
const ProjectResource = z
|
||||||
@@ -68,6 +75,7 @@ const ProjectResource = z
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
color: z.string(),
|
color: z.string(),
|
||||||
client_id: z.union([z.string(), z.null()]),
|
client_id: z.union([z.string(), z.null()]),
|
||||||
|
is_archived: z.boolean(),
|
||||||
billable_rate: z.union([z.number(), z.null()]),
|
billable_rate: z.union([z.number(), z.null()]),
|
||||||
is_billable: z.boolean(),
|
is_billable: z.boolean(),
|
||||||
})
|
})
|
||||||
@@ -81,6 +89,17 @@ const createProject_Body = z
|
|||||||
client_id: z.union([z.string(), z.null()]).optional(),
|
client_id: z.union([z.string(), z.null()]).optional(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
const updateProject_Body = z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
color: z.string(),
|
||||||
|
is_billable: z.boolean(),
|
||||||
|
is_archived: z.boolean().optional(),
|
||||||
|
client_id: z.union([z.string(), z.null()]).optional(),
|
||||||
|
billable_rate: z.union([z.number(), z.null()]).optional(),
|
||||||
|
billable_rate_update_time_entries: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
const ProjectMemberResource = z
|
const ProjectMemberResource = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -96,7 +115,10 @@ const createProjectMember_Body = z
|
|||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const updateProjectMember_Body = z
|
const updateProjectMember_Body = z
|
||||||
.object({ billable_rate: z.union([z.number(), z.null()]) })
|
.object({
|
||||||
|
billable_rate: z.union([z.number(), z.null()]),
|
||||||
|
billable_rate_update_time_entries: z.boolean(),
|
||||||
|
})
|
||||||
.partial()
|
.partial()
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const TagResource = z
|
const TagResource = z
|
||||||
@@ -112,6 +134,7 @@ const TaskResource = z
|
|||||||
.object({
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
is_done: z.boolean(),
|
||||||
project_id: z.string(),
|
project_id: z.string(),
|
||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
updated_at: z.string(),
|
updated_at: z.string(),
|
||||||
@@ -120,6 +143,9 @@ const TaskResource = z
|
|||||||
const createTask_Body = z
|
const createTask_Body = z
|
||||||
.object({ name: z.string(), project_id: z.string() })
|
.object({ name: z.string(), project_id: z.string() })
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
const updateTask_Body = z
|
||||||
|
.object({ name: z.string(), is_done: z.boolean().optional() })
|
||||||
|
.passthrough();
|
||||||
const start = z.union([z.string(), z.null()]).optional();
|
const start = z.union([z.string(), z.null()]).optional();
|
||||||
const TimeEntryResource = z
|
const TimeEntryResource = z
|
||||||
.object({
|
.object({
|
||||||
@@ -149,7 +175,7 @@ const createTimeEntry_Body = z
|
|||||||
tags: z.union([z.array(z.string()), z.null()]).optional(),
|
tags: z.union([z.array(z.string()), z.null()]).optional(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const v1_time_entries_update_multiple_Body = z
|
const updateMultipleTimeEntries_Body = z
|
||||||
.object({
|
.object({
|
||||||
ids: z.array(z.string()),
|
ids: z.array(z.string()),
|
||||||
changes: z
|
changes: z
|
||||||
@@ -182,6 +208,7 @@ const updateTimeEntry_Body = z
|
|||||||
export const schemas = {
|
export const schemas = {
|
||||||
ClientResource,
|
ClientResource,
|
||||||
ClientCollection,
|
ClientCollection,
|
||||||
|
updateClient_Body,
|
||||||
importData_Body,
|
importData_Body,
|
||||||
InvitationResource,
|
InvitationResource,
|
||||||
Role,
|
Role,
|
||||||
@@ -193,6 +220,7 @@ export const schemas = {
|
|||||||
updateOrganization_Body,
|
updateOrganization_Body,
|
||||||
ProjectResource,
|
ProjectResource,
|
||||||
createProject_Body,
|
createProject_Body,
|
||||||
|
updateProject_Body,
|
||||||
ProjectMemberResource,
|
ProjectMemberResource,
|
||||||
createProjectMember_Body,
|
createProjectMember_Body,
|
||||||
updateProjectMember_Body,
|
updateProjectMember_Body,
|
||||||
@@ -200,11 +228,12 @@ export const schemas = {
|
|||||||
TagCollection,
|
TagCollection,
|
||||||
TaskResource,
|
TaskResource,
|
||||||
createTask_Body,
|
createTask_Body,
|
||||||
|
updateTask_Body,
|
||||||
start,
|
start,
|
||||||
TimeEntryResource,
|
TimeEntryResource,
|
||||||
TimeEntryCollection,
|
TimeEntryCollection,
|
||||||
createTimeEntry_Body,
|
createTimeEntry_Body,
|
||||||
v1_time_entries_update_multiple_Body,
|
updateMultipleTimeEntries_Body,
|
||||||
updateTimeEntry_Body,
|
updateTimeEntry_Body,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -287,6 +316,16 @@ const endpoints = makeApi([
|
|||||||
type: 'Path',
|
type: 'Path',
|
||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'page',
|
||||||
|
type: 'Query',
|
||||||
|
schema: z.number().int().gte(1).optional(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'archived',
|
||||||
|
type: 'Query',
|
||||||
|
schema: z.enum(['true', 'false', 'all']).optional(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
response: z.object({ data: ClientCollection }).passthrough(),
|
response: z.object({ data: ClientCollection }).passthrough(),
|
||||||
errors: [
|
errors: [
|
||||||
@@ -300,6 +339,16 @@ const endpoints = makeApi([
|
|||||||
description: `Not found`,
|
description: `Not found`,
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
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(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -352,7 +401,7 @@ const endpoints = makeApi([
|
|||||||
{
|
{
|
||||||
name: 'body',
|
name: 'body',
|
||||||
type: 'Body',
|
type: 'Body',
|
||||||
schema: z.object({ name: z.string() }).passthrough(),
|
schema: updateClient_Body,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'organization',
|
name: 'organization',
|
||||||
@@ -1034,6 +1083,11 @@ const endpoints = makeApi([
|
|||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: z.number().int().gte(1).optional(),
|
schema: z.number().int().gte(1).optional(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'archived',
|
||||||
|
type: 'Query',
|
||||||
|
schema: z.enum(['true', 'false', 'all']).optional(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
response: z
|
response: z
|
||||||
.object({
|
.object({
|
||||||
@@ -1172,7 +1226,7 @@ const endpoints = makeApi([
|
|||||||
{
|
{
|
||||||
name: 'body',
|
name: 'body',
|
||||||
type: 'Body',
|
type: 'Body',
|
||||||
schema: createProject_Body,
|
schema: updateProject_Body,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'organization',
|
name: 'organization',
|
||||||
@@ -1552,6 +1606,11 @@ const endpoints = makeApi([
|
|||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: z.string().uuid().optional(),
|
schema: z.string().uuid().optional(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'done',
|
||||||
|
type: 'Query',
|
||||||
|
schema: z.enum(['true', 'false', 'all']).optional(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
response: z
|
response: z
|
||||||
.object({
|
.object({
|
||||||
@@ -1659,7 +1718,7 @@ const endpoints = makeApi([
|
|||||||
{
|
{
|
||||||
name: 'body',
|
name: 'body',
|
||||||
type: 'Body',
|
type: 'Body',
|
||||||
schema: z.object({ name: z.string() }).passthrough(),
|
schema: updateTask_Body,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'organization',
|
name: 'organization',
|
||||||
@@ -1891,13 +1950,13 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
{
|
{
|
||||||
method: 'patch',
|
method: 'patch',
|
||||||
path: '/v1/organizations/:organization/time-entries',
|
path: '/v1/organizations/:organization/time-entries',
|
||||||
alias: 'v1.time-entries.update-multiple',
|
alias: 'updateMultipleTimeEntries',
|
||||||
requestFormat: 'json',
|
requestFormat: 'json',
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
name: 'body',
|
name: 'body',
|
||||||
type: 'Body',
|
type: 'Body',
|
||||||
schema: v1_time_entries_update_multiple_Body,
|
schema: updateMultipleTimeEntries_Body,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'organization',
|
name: 'organization',
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||||
|
import DialogModal from '@/Components/DialogModal.vue';
|
||||||
|
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||||
|
import { formatCents } from '../../../utils/money';
|
||||||
|
|
||||||
|
const show = defineModel('show', { default: false });
|
||||||
|
const saving = defineModel('saving', { default: false });
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
newBillableRate?: number | null;
|
||||||
|
memberName: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [billable_rate_update_time_entries: boolean];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogModal closeable :show="show" @close="show = false">
|
||||||
|
<template #title>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<span> Update Member Billable Rate </span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="col-span-6 sm:col-span-4 flex-1">
|
||||||
|
<p class="py-0.5 text-center">
|
||||||
|
The billable rate of {{ memberName }} will be updated to
|
||||||
|
<strong>{{
|
||||||
|
newBillableRate
|
||||||
|
? formatCents(newBillableRate)
|
||||||
|
: ' the default rate of the organization'
|
||||||
|
}}</strong
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
<p class="py-0.5 text-center font-semibold">
|
||||||
|
Do you want to update all existing time entries as well?
|
||||||
|
</p>
|
||||||
|
<div class="space-x-3 pt-5 pb-2 flex justify-center">
|
||||||
|
<PrimaryButton
|
||||||
|
:class="{ 'opacity-25': saving }"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="emit('submit', true)">
|
||||||
|
Yes, update existing time entries
|
||||||
|
</PrimaryButton>
|
||||||
|
<PrimaryButton
|
||||||
|
:class="{ 'opacity-25': saving }"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="emit('submit', false)">
|
||||||
|
No, only for new time entries
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
|
||||||
|
</template>
|
||||||
|
</DialogModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SelectDropdown from '@/Components/Common/SelectDropdown.vue';
|
||||||
|
import type { BillableKey } from '@/utils/useProjects';
|
||||||
|
import Badge from '@/Components/Common/Badge.vue';
|
||||||
|
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
|
||||||
|
|
||||||
|
const model = defineModel<BillableKey>({
|
||||||
|
default: 'default-rate',
|
||||||
|
});
|
||||||
|
|
||||||
|
type Option = { key: BillableKey; name: string };
|
||||||
|
|
||||||
|
const options: Option[] = [
|
||||||
|
{
|
||||||
|
key: 'default-rate',
|
||||||
|
name: 'Organization Default Rate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'custom-rate',
|
||||||
|
name: 'Custom Rate',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function getKeyFromItem(item: Option) {
|
||||||
|
return item.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNameFromItem(item: Option) {
|
||||||
|
return item.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNameForKey(key: BillableKey | undefined) {
|
||||||
|
const item = options.find((item) => getKeyFromItem(item) === key);
|
||||||
|
if (item) {
|
||||||
|
return getNameFromItem(item);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectDropdown
|
||||||
|
v-model="model"
|
||||||
|
:get-key-from-item="getKeyFromItem"
|
||||||
|
:get-name-for-item="getNameFromItem"
|
||||||
|
:items="options">
|
||||||
|
<template #trigger>
|
||||||
|
<Badge size="xlarge" class="bg-input-background cursor-pointer">
|
||||||
|
<span>
|
||||||
|
{{ getNameForKey(model) }}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon class="text-muted w-5"></ChevronDownIcon>
|
||||||
|
</Badge>
|
||||||
|
</template>
|
||||||
|
</SelectDropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -4,8 +4,12 @@ import DialogModal from '@/Components/DialogModal.vue';
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import type { Member, UpdateMemberBody } from '@/utils/api';
|
import type { Member, UpdateMemberBody } from '@/utils/api';
|
||||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||||
import { useMembersStore } from '@/utils/useMembers';
|
import { type MemberBillableKey, useMembersStore } from '@/utils/useMembers';
|
||||||
import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
|
import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
|
||||||
|
import InputLabel from '@/Components/InputLabel.vue';
|
||||||
|
import MemberBillableRateModal from '@/Components/Common/Member/MemberBillableRateModal.vue';
|
||||||
|
import MemberBillableSelect from '@/Components/Common/Member/MemberBillableSelect.vue';
|
||||||
|
import { onMounted, watch } from 'vue';
|
||||||
|
|
||||||
const { updateMember } = useMembersStore();
|
const { updateMember } = useMembersStore();
|
||||||
const show = defineModel('show', { default: false });
|
const show = defineModel('show', { default: false });
|
||||||
@@ -21,13 +25,49 @@ const memberBody = ref<UpdateMemberBody>({
|
|||||||
billable_rate: props.member.billable_rate,
|
billable_rate: props.member.billable_rate,
|
||||||
});
|
});
|
||||||
|
|
||||||
async function submit() {
|
async function submit(billableRateUpdateTimeEntries: boolean) {
|
||||||
|
memberBody.value.billable_rate_update_time_entries =
|
||||||
|
billableRateUpdateTimeEntries;
|
||||||
await updateMember(props.member.id, memberBody.value);
|
await updateMember(props.member.id, memberBody.value);
|
||||||
show.value = false;
|
show.value = false;
|
||||||
|
showBillableRateModal.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showBillableRateModal = ref(false);
|
||||||
|
function openBillableRateModalIfNeeded() {
|
||||||
|
if (memberBody.value.billable_rate !== props.member.billable_rate) {
|
||||||
|
showBillableRateModal.value = true;
|
||||||
|
show.value = false;
|
||||||
|
} else {
|
||||||
|
submit(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const billableRateSelect = ref<MemberBillableKey>('default-rate');
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.member.billable_rate !== null) {
|
||||||
|
billableRateSelect.value = 'custom-rate';
|
||||||
|
} else {
|
||||||
|
billableRateSelect.value = 'default-rate';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
watch(billableRateSelect, () => {
|
||||||
|
if (billableRateSelect.value === 'default-rate') {
|
||||||
|
memberBody.value.billable_rate = null;
|
||||||
|
} else if (billableRateSelect.value === 'custom-rate') {
|
||||||
|
memberBody.value.billable_rate = props.member.billable_rate ?? 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<MemberBillableRateModal
|
||||||
|
v-model:saving="saving"
|
||||||
|
v-model:show="showBillableRateModal"
|
||||||
|
:member-name="member.name"
|
||||||
|
:newBillableRate="memberBody.billable_rate"
|
||||||
|
@submit="submit"></MemberBillableRateModal>
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
<DialogModal closeable :show="show" @close="show = false">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
@@ -37,11 +77,29 @@ async function submit() {
|
|||||||
|
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<div class="col-span-6 sm:col-span-4 flex-1">
|
<div class="col-span-6 sm:col-span-4 flex-1 flex space-x-5">
|
||||||
<BillableRateInput
|
<div>
|
||||||
focus
|
<InputLabel for="billableType" value="Billable" />
|
||||||
name="billable_rate"
|
<MemberBillableSelect
|
||||||
v-model="memberBody.billable_rate"></BillableRateInput>
|
class="mt-2"
|
||||||
|
name="billableType"
|
||||||
|
v-model="billableRateSelect"></MemberBillableSelect>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex-1"
|
||||||
|
v-if="billableRateSelect === 'custom-rate'">
|
||||||
|
<InputLabel
|
||||||
|
for="memberBillableRate"
|
||||||
|
value="Billable Rate" />
|
||||||
|
<BillableRateInput
|
||||||
|
focus
|
||||||
|
class="w-full"
|
||||||
|
@keydown.enter="openBillableRateModalIfNeeded()"
|
||||||
|
name="memberBillableRate"
|
||||||
|
v-model="
|
||||||
|
memberBody.billable_rate
|
||||||
|
"></BillableRateInput>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -52,8 +110,8 @@ async function submit() {
|
|||||||
class="ms-3"
|
class="ms-3"
|
||||||
:class="{ 'opacity-25': saving }"
|
:class="{ 'opacity-25': saving }"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
@click="submit">
|
@click="openBillableRateModalIfNeeded()">
|
||||||
Update Client
|
Update Member
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</template>
|
</template>
|
||||||
</DialogModal>
|
</DialogModal>
|
||||||
|
|||||||
@@ -16,20 +16,22 @@ const props = defineProps<{
|
|||||||
<template>
|
<template>
|
||||||
<Dropdown align="bottom-end">
|
<Dropdown align="bottom-end">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<svg
|
<button
|
||||||
data-testid="client_actions"
|
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-1 focus-visible:ring-input-border-active focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity"
|
||||||
:aria-label="'Actions for Member ' + props.member.name"
|
:aria-label="'Actions for Member ' + props.member.name">
|
||||||
class="h-10 w-10 p-2 rounded-full hover:bg-card-background opacity-20 group-hover:opacity-100 transition"
|
<svg
|
||||||
viewBox="0 0 24 24"
|
class="h-10 w-10 p-2 rounded-full"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
viewBox="0 0 24 24"
|
||||||
<path
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
fill="none"
|
<path
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
stroke-linecap="round"
|
stroke="currentColor"
|
||||||
stroke-linejoin="round"
|
stroke-linecap="round"
|
||||||
stroke-width="1.5"
|
stroke-linejoin="round"
|
||||||
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" />
|
stroke-width="1.5"
|
||||||
</svg>
|
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>
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="min-w-[150px]">
|
<div class="min-w-[150px]">
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||||
|
import DialogModal from '@/Components/DialogModal.vue';
|
||||||
|
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||||
|
import { formatCents } from '../../../utils/money';
|
||||||
|
|
||||||
|
const show = defineModel('show', { default: false });
|
||||||
|
const saving = defineModel('saving', { default: false });
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
newBillableRate?: number | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [billable_rate_update_time_entries: boolean];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogModal closeable :show="show" @close="show = false">
|
||||||
|
<template #title>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<span> Update Organization Billable Rate </span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="col-span-6 sm:col-span-4 flex-1">
|
||||||
|
<p class="py-0.5 text-center">
|
||||||
|
The organization billable rate will be updated to
|
||||||
|
<strong>{{
|
||||||
|
newBillableRate
|
||||||
|
? formatCents(newBillableRate)
|
||||||
|
: ' none.'
|
||||||
|
}}</strong
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
<p class="py-0.5 text-center font-semibold">
|
||||||
|
Do you want to update all existing time entries as well?
|
||||||
|
</p>
|
||||||
|
<div class="space-x-3 pt-5 pb-2 flex justify-center">
|
||||||
|
<PrimaryButton
|
||||||
|
:class="{ 'opacity-25': saving }"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="emit('submit', true)">
|
||||||
|
Yes, update existing time entries
|
||||||
|
</PrimaryButton>
|
||||||
|
<PrimaryButton
|
||||||
|
:class="{ 'opacity-25': saving }"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="emit('submit', false)">
|
||||||
|
No, only for new time entries
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
|
||||||
|
</template>
|
||||||
|
</DialogModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -89,6 +89,7 @@ const filteredProjects = computed(() => {
|
|||||||
value: '',
|
value: '',
|
||||||
client_id: null,
|
client_id: null,
|
||||||
billable_rate: null,
|
billable_rate: null,
|
||||||
|
is_archived: false,
|
||||||
is_billable: false,
|
is_billable: false,
|
||||||
},
|
},
|
||||||
tasks: [],
|
tasks: [],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ defineProps<{
|
|||||||
<Component
|
<Component
|
||||||
:is="href ? Link : 'div'"
|
:is="href ? Link : 'div'"
|
||||||
:href="href"
|
:href="href"
|
||||||
|
role="row"
|
||||||
:class="
|
:class="
|
||||||
twMerge(
|
twMerge(
|
||||||
'contents group [&>*]:transition [&>*]:border-row-separator [&>*]:border-b',
|
'contents group [&>*]:transition [&>*]:border-row-separator [&>*]:border-b',
|
||||||
|
|||||||
@@ -7,14 +7,15 @@ import type { UpdateOrganizationBody } from '@/utils/api';
|
|||||||
import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
|
import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
|
||||||
import { useOrganizationStore } from '@/utils/useOrganization';
|
import { useOrganizationStore } from '@/utils/useOrganization';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
|
import OrganizationBillableRateModal from '@/Components/Common/Organization/OrganizationBillableRateModal.vue';
|
||||||
|
|
||||||
const store = useOrganizationStore();
|
const store = useOrganizationStore();
|
||||||
const { fetchOrganization, updateOrganization } = store;
|
const { fetchOrganization, updateOrganization } = store;
|
||||||
const { organization } = storeToRefs(store);
|
const { organization } = storeToRefs(store);
|
||||||
|
const saving = ref(false);
|
||||||
const organizationBody = ref<UpdateOrganizationBody>({
|
const organizationBody = ref<UpdateOrganizationBody>({
|
||||||
name: '',
|
name: '',
|
||||||
billable_rate: null,
|
billable_rate: null as number | null,
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -24,9 +25,15 @@ onMounted(async () => {
|
|||||||
billable_rate: organization.value?.billable_rate,
|
billable_rate: organization.value?.billable_rate,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
const showConfirmationModal = ref(false);
|
||||||
|
|
||||||
function submit() {
|
async function submit(billableRateUpdateTimeEntries: boolean) {
|
||||||
updateOrganization(organizationBody.value);
|
saving.value = true;
|
||||||
|
organizationBody.value.billable_rate_update_time_entries =
|
||||||
|
billableRateUpdateTimeEntries;
|
||||||
|
await updateOrganization(organizationBody.value);
|
||||||
|
saving.value = false;
|
||||||
|
showConfirmationModal.value = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -39,6 +46,12 @@ function submit() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #form>
|
<template #form>
|
||||||
|
<OrganizationBillableRateModal
|
||||||
|
v-model:show="showConfirmationModal"
|
||||||
|
@submit="submit"
|
||||||
|
:new-billable-rate="
|
||||||
|
organizationBody.billable_rate
|
||||||
|
"></OrganizationBillableRateModal>
|
||||||
<!-- Organization Owner Information -->
|
<!-- Organization Owner Information -->
|
||||||
<div class="col-span-6">
|
<div class="col-span-6">
|
||||||
<div class="col-span-6 sm:col-span-4">
|
<div class="col-span-6 sm:col-span-4">
|
||||||
@@ -53,7 +66,9 @@ function submit() {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<PrimaryButton @click="submit">Save</PrimaryButton>
|
<PrimaryButton @click="showConfirmationModal = true"
|
||||||
|
>Save</PrimaryButton
|
||||||
|
>
|
||||||
</template>
|
</template>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import { useNotificationsStore } from '@/utils/notification';
|
import { useNotificationsStore } from '@/utils/notification';
|
||||||
|
|
||||||
|
export type MemberBillableKey = 'default-rate' | 'custom-rate';
|
||||||
|
|
||||||
export const useMembersStore = defineStore('members', () => {
|
export const useMembersStore = defineStore('members', () => {
|
||||||
const membersResponse = ref<MemberIndexResponse | null>(null);
|
const membersResponse = ref<MemberIndexResponse | null>(null);
|
||||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||||
|
|||||||
Reference in New Issue
Block a user