Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add project members to project show page #27

Merged
merged 2 commits into from
Apr 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions openapi.json.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1487,6 +1487,21 @@ const endpoints = makeApi([
},
],
},
{
method: 'get',
path: '/v1/users/me/time-entries/active',
alias: 'getMyActiveTimeEntry',
description: `This endpoint is independent of organization.`,
requestFormat: 'json',
response: z.object({ data: TimeEntryResource }).passthrough(),
errors: [
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
]);

export const api = new Zodios('/api', endpoints);
Expand Down
72 changes: 72 additions & 0 deletions resources/js/Components/Common/BillableRateInput.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<script setup lang="ts">
import TextInput from '@/Components/TextInput.vue';
import {
formatMoney,
getOrganizationCurrencyString,
getOrganizationCurrencySymbol,
} from '../../utils/money';

const model = defineModel({
default: null,
type: Number,
});

function cleanUpDecimalValue(value: string) {
value = value.replace(/,/g, '');
value = value.replace(getOrganizationCurrencySymbol(), '');
return value.replace(/\./g, '');
}

function updateRate(value: string) {
if (value.includes(',')) {
const parts = value.split(',');
const lastPart = (parts[parts.length - 1] = parts[parts.length - 1]);
if (lastPart.length === 2) {
// we detected a decimal number with 2 digits after the comma
value = cleanUpDecimalValue(value);
model.value = parseInt(value);
}
} else if (value.includes('.')) {
const parts = value.split('.');
const lastPart = (parts[parts.length - 1] = parts[parts.length - 1]);
if (lastPart.length === 2) {
value = cleanUpDecimalValue(value);
model.value = parseInt(value);
}
} else {
// if it doesn't contain a comma or a dot, it's probably a whole number so let's convert it to cents
model.value = parseInt(cleanUpDecimalValue(value)) * 100;
}
}
function formatCents(modelValue: number) {
const formattedValue = formatMoney(
modelValue / 100,
getOrganizationCurrencyString()
);
return formattedValue.replace(getOrganizationCurrencySymbol(), '');
}
</script>

<template>
<div class="relative">
<TextInput
id="projectMemberRate"
ref="projectMemberRateInput"
:modelValue="formatCents(model)"
@blur="updateRate($event.target.value)"
type="text"
placeholder="Billable Rate"
class="mt-1 block w-full"
autocomplete="teamMemberRate" />
<span>
<div
class="absolute top-0 right-0 h-full flex items-center px-4 font-medium">
<span>
{{ getOrganizationCurrencyString() }}
</span>
</div>
</span>
</div>
</template>

<style scoped></style>
7 changes: 7 additions & 0 deletions resources/js/Components/Common/Card.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script setup lang="ts"></script>

<template>
<div class="rounded-lg border border-card-border">
<slot></slot>
</div>
</template>
25 changes: 15 additions & 10 deletions resources/js/Components/Common/CardTitle.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,21 @@ defineProps<{
</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>
<div class="flex w-full items-center justify-between pb-4">
<h3
class="text-white font-bold 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>
<div>
<slot name="actions"></slot>
</div>
</div>
</template>

<style scoped></style>
190 changes: 190 additions & 0 deletions resources/js/Components/Common/Member/MemberCombobox.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<script setup lang="ts">
import Dropdown from '@/Components/Dropdown.vue';
import { type Component, computed, nextTick, onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import ClientDropdownItem from '@/Components/Common/Client/ClientDropdownItem.vue';
import { useMembersStore } from '@/utils/useMembers';
import { UserIcon, XMarkIcon } from '@heroicons/vue/24/solid';
import TextInput from '@/Components/TextInput.vue';
import { useFocus } from '@vueuse/core';
import type { ProjectMember } from '@/utils/api';

const membersStore = useMembersStore();
const { members } = storeToRefs(membersStore);

const model = defineModel<string>({
default: '',
});

const props = defineProps<{
hiddenMembers: ProjectMember[];
}>();

const searchInput = ref<HTMLInputElement | null>(null);
const dropdownViewport = ref<Component | null>(null);

const searchValue = ref('');

function isMemberSelected(id: string) {
return model.value === id;
}

const { focused } = useFocus(searchInput, { initialValue: true });

const filteredMembers = computed(() => {
return members.value.filter((member) => {
return (
member.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '') &&
!props.hiddenMembers.some(
(hiddenMember) => hiddenMember.user_id === member.id
) &&
member.is_placeholder === false
);
});
});

watch(filteredMembers, () => {
resetHighlightedItem();
});

onMounted(() => {
resetHighlightedItem();
});

function resetHighlightedItem() {
if (filteredMembers.value.length > 0) {
highlightedItemId.value = filteredMembers.value[0].id;
}
}

function updateSearchValue(event: Event) {
const newInput = (event.target as HTMLInputElement).value;
if (newInput === ' ') {
searchValue.value = '';
const highlightedClientId = highlightedItemId.value;
if (highlightedClientId) {
const highlightedClient = members.value.find(
(member) => member.id === highlightedClientId
);
if (highlightedClient) {
model.value = highlightedClient.id;
}
}
} else {
searchValue.value = newInput;
}
}

const emit = defineEmits(['update:modelValue', 'changed']);

function updateMember(newValue: string | null) {
if (newValue) {
model.value = newValue;
nextTick(() => {
emit('changed');
});
}
}

function moveHighlightUp() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredMembers.value.indexOf(
highlightedItem.value
);
if (currentHightlightedIndex === 0) {
highlightedItemId.value =
filteredMembers.value[filteredMembers.value.length - 1].id;
} else {
highlightedItemId.value =
filteredMembers.value[currentHightlightedIndex - 1].id;
}
}
}

function moveHighlightDown() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredMembers.value.indexOf(
highlightedItem.value
);
if (currentHightlightedIndex === filteredMembers.value.length - 1) {
highlightedItemId.value = filteredMembers.value[0].id;
} else {
highlightedItemId.value =
filteredMembers.value[currentHightlightedIndex + 1].id;
}
}
}

const highlightedItemId = ref<string | null>(null);
const highlightedItem = computed(() => {
return members.value.find(
(member) => member.id === highlightedItemId.value
);
});

const currentValue = computed(() => {
if (model.value) {
return members.value.find((member) => member.id === model.value)?.name;
}
return searchValue.value;
});

const hasMemberSelected = computed(() => {
return model.value !== '';
});
</script>

<template>
<div class="flex relative">
<div
class="absolute h-full items-center px-3 w-full flex justify-between">
<UserIcon class="relative z-10 w-4 text-muted"></UserIcon>
<button
v-if="hasMemberSelected"
@click="model = ''"
class="focus:text-accent-200 focus:bg-card-background text-muted">
<XMarkIcon class="relative z-10 w-4"></XMarkIcon>
</button>
</div>
<TextInput
:value="currentValue"
@input="updateSearchValue"
data-testid="member_dropdown_search"
@keydown.enter.prevent="updateMember(highlightedItemId)"
@keydown.up.prevent="moveHighlightUp"
class="relative w-full pl-10"
@keydown.down.prevent="moveHighlightDown"
placeholder="Search for a member..."
ref="searchInput" />
</div>
<Dropdown
align="left"
width="300"
v-model="focused"
:closeOnContentClick="true">
<template #content>
<div ref="dropdownViewport" class="w-60">
<div
v-for="member in filteredMembers"
:key="member.id"
role="option"
:value="member.id"
:class="{
'bg-card-background-active':
member.id === highlightedItemId,
}"
@click="updateMember(member.id)"
data-testid="client_dropdown_entries"
:data-client-id="member.id">
<ClientDropdownItem
:selected="isMemberSelected(member.id)"
:name="member.name"></ClientDropdownItem>
</div>
</div>
</template>
</Dropdown>
</template>

<style scoped></style>
5 changes: 1 addition & 4 deletions resources/js/Components/Common/Member/MemberTableRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/20/solid';
import { useClientsStore } from '@/utils/useClients';
import MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue';
import TableRow from '@/Components/TableRow.vue';
import { capitalizeFirstLetter } from '../../../utils/format';

const props = defineProps<{
member: Member;
Expand All @@ -12,10 +13,6 @@ const props = defineProps<{
function removeMember() {
useClientsStore().deleteClient(props.member.id);
}

function capitalizeFirstLetter(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
</script>

<template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
Client
</div>
<div class="px-3 py-1.5 text-left text-sm font-semibold text-white">
Team
Billable Rate
</div>
<div class="px-3 py-1.5 text-left text-sm font-semibold text-white">
Status
Expand Down
19 changes: 1 addition & 18 deletions resources/js/Components/Common/Project/ProjectTableRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,24 +54,7 @@ function deleteProject() {
<div v-else>No client</div>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
<div class="isolate flex -space-x-1 opacity-50">
<img
class="relative z-30 inline-block h-6 w-6 rounded-full ring-4 ring-card-background"
src="https://images.unsplash.com/photo-1491528323818-fdd1faba62cc?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
alt="" />
<img
class="relative z-20 inline-block h-6 w-6 rounded-full ring-4 ring-card-background"
src="https://images.unsplash.com/photo-1550525811-e5869dd03032?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
alt="" />
<img
class="relative z-10 inline-block h-6 w-6 rounded-full ring-4 ring-card-background"
src="https://images.unsplash.com/photo-1500648767791-00dcc994a43e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2.25&w=256&h=256&q=80"
alt="" />
<img
class="relative z-0 inline-block h-6 w-6 rounded-full ring-4 ring-card-background"
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
alt="" />
</div>
{{ project.billable_rate ?? '--' }}
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-muted flex space-x-1 items-center font-medium">
Expand Down
Loading
Loading