Refactor VBadge and GroupDetailPage for enhanced badge variants and UI improvements

- Updated VBadge component to include additional badge variants: 'primary', 'success', 'danger', 'warning', 'info', and 'neutral'.
- Modified the GroupDetailPage to utilize the new badge variants for member roles and chore frequencies.
- Improved layout and styling of sections within GroupDetailPage for better user experience.
- Enhanced error handling and notification messages for invite code generation and clipboard actions.
This commit is contained in:
mohamad 2025-06-07 16:08:59 +02:00
parent 0aa88d0af7
commit 77178cc67e
2 changed files with 189 additions and 132 deletions

View File

@ -3,9 +3,9 @@
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from 'vue';
import { defineComponent, computed, type PropType } from 'vue';
type BadgeVariant = 'secondary' | 'accent' | 'settled' | 'pending';
export type BadgeVariant = 'primary' | 'secondary' | 'accent' | 'settled' | 'pending' | 'success' | 'danger' | 'warning' | 'info' | 'neutral';
export default defineComponent({
name: 'VBadge',
@ -17,7 +17,7 @@ export default defineComponent({
variant: {
type: String as PropType<BadgeVariant>,
default: 'secondary',
validator: (value: string) => ['secondary', 'accent', 'settled', 'pending'].includes(value),
validator: (value: string) => ['primary', 'secondary', 'accent', 'settled', 'pending', 'success', 'danger', 'warning', 'info', 'neutral'].includes(value),
},
sticky: {
type: Boolean,
@ -81,8 +81,7 @@ export default defineComponent({
color: #FFA500; // Warning-700 (assuming)
// Design doc has #FFC107 (Warning-500) for text and #FFF3CD (Warning-100) for background. Let's use those.
background-color: #FFF3CD;
color: #FFC107; // Note: Design shows a darker text #FFA000 (Warning-600 like) but specifies #FFC107 for the color name.
// Using #FFC107 for now, can be adjusted.
color: #856404; // Using a darker color for better contrast
}
// Sticky style for Accent variant
@ -104,4 +103,34 @@ export default defineComponent({
// Example: add a small border to distinguish it slightly when sticky.
border: 1px solid #007AFF; // Primary-500 (same as text color for accent)
}
.badge-primary {
background-color: #CCE5FF;
color: #004085;
}
.badge-success {
background-color: #D1E7DD;
color: #198754;
}
.badge-danger {
background-color: #F8D7DA;
color: #721C24;
}
.badge-warning {
background-color: #FFF3CD;
color: #856404;
}
.badge-info {
background-color: #D1ECF1;
color: #0C5460;
}
.badge-neutral {
background-color: #F8F9FA;
color: #343A40;
}
</style>

View File

@ -9,109 +9,122 @@
</template>
</VAlert>
<div v-else-if="group">
<VHeading level="1" :text="group.name" class="mb-3" />
<div class="flex justify-between items-center mb-3">
<VHeading :level="1" :text="group.name" />
<!-- Potential global actions here -->
</div>
<div class="neo-grid">
<!-- Group Members Section -->
<VCard>
<template #header><VHeading level="3">{{ t('groupDetailPage.members.title') }}</VHeading></template>
<VList v-if="group.members && group.members.length > 0">
<VListItem v-for="member in group.members" :key="member.id" class="flex justify-between items-center">
<div class="neo-member-info">
<span class="neo-member-name">{{ member.email }}</span>
<VBadge :text="member.role || t('groupDetailPage.members.defaultRole')" :variant="member.role?.toLowerCase() === 'owner' ? 'primary' : 'secondary'" />
<div class="neo-section-container">
<div class="neo-grid">
<!-- Group Members Section -->
<div class="neo-section">
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.members.title') }}</VHeading>
<VList v-if="group.members && group.members.length > 0">
<VListItem v-for="member in group.members" :key="member.id" class="flex justify-between items-center">
<div class="neo-member-info">
<span class="neo-member-name">{{ member.email }}</span>
<VBadge :text="member.role || t('groupDetailPage.members.defaultRole')"
:variant="member.role?.toLowerCase() === 'owner' ? 'primary' : 'secondary'" />
</div>
<VButton v-if="canRemoveMember(member)" variant="danger" size="sm" @click="removeMember(member.id)"
:disabled="removingMember === member.id">
<VSpinner v-if="removingMember === member.id" size="sm" /> {{
t('groupDetailPage.members.removeButton') }}
</VButton>
</VListItem>
</VList>
<div v-else class="text-center py-4">
<VIcon name="users" size="lg" class="opacity-50 mb-2" />
<p>{{ t('groupDetailPage.members.emptyState') }}</p>
</div>
</div>
<!-- Invite Members Section -->
<div class="neo-section">
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.invites.title') }}</VHeading>
<VButton variant="primary" class="w-full" @click="generateInviteCode" :disabled="generatingInvite">
<VSpinner v-if="generatingInvite" size="sm" /> {{ inviteCode ?
t('groupDetailPage.invites.regenerateButton') :
t('groupDetailPage.invites.generateButton') }}
</VButton>
<div v-if="inviteCode" class="neo-invite-code mt-3">
<VFormField :label="t('groupDetailPage.invites.activeCodeLabel')" :label-sr-only="false">
<div class="flex items-center gap-2">
<VInput id="inviteCodeInput" :model-value="inviteCode" readonly class="flex-grow" />
<VButton variant="neutral" :icon-only="true" iconLeft="clipboard" @click="copyInviteCodeHandler"
:aria-label="t('groupDetailPage.invites.copyButtonLabel')" />
</div>
</VFormField>
<p v-if="copySuccess" class="text-sm text-green-600 mt-1">{{ t('groupDetailPage.invites.copySuccess') }}
</p>
</div>
<div v-else class="text-center py-4 mt-3">
<VIcon name="link" size="lg" class="opacity-50 mb-2" />
<p>{{ t('groupDetailPage.invites.emptyState') }}</p>
</div>
</div>
</div>
<!-- Lists Section -->
<div class="mt-4 neo-section">
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.lists.title') }}</VHeading>
<ListsPage :group-id="groupId" />
</div>
<!-- Chores Section -->
<div class="mt-4 neo-section">
<div class="flex justify-between items-center w-full mb-2">
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.chores.title') }}</VHeading>
<VButton :to="`/groups/${groupId}/chores`" variant="primary">
<span class="material-icons" style="margin-right: 0.25em;">cleaning_services</span> {{
t('groupDetailPage.chores.manageButton') }}
</VButton>
</div>
<VList v-if="upcomingChores.length > 0">
<VListItem v-for="chore in upcomingChores" :key="chore.id" class="flex justify-between items-center">
<div class="neo-chore-info">
<span class="neo-chore-name">{{ chore.name }}</span>
<span class="neo-chore-due">{{ t('groupDetailPage.chores.duePrefix') }} {{
formatDate(chore.next_due_date)
}}</span>
</div>
<VButton v-if="canRemoveMember(member)" variant="danger" size="sm" @click="removeMember(member.id)" :disabled="removingMember === member.id">
<VSpinner v-if="removingMember === member.id" size="sm"/> {{ t('groupDetailPage.members.removeButton') }}
</VButton>
<VBadge :text="formatFrequency(chore.frequency)" :variant="getFrequencyBadgeVariant(chore.frequency)" />
</VListItem>
</VList>
<div v-else class="text-center py-4">
<VIcon name="users" size="lg" class="opacity-50 mb-2" />
<p>{{ t('groupDetailPage.members.emptyState') }}</p>
<VIcon name="cleaning_services" size="lg" class="opacity-50 mb-2" />
<p>{{ t('groupDetailPage.chores.emptyState') }}</p>
</div>
</VCard>
<!-- Invite Members Section -->
<VCard>
<template #header><VHeading level="3">{{ t('groupDetailPage.invites.title') }}</VHeading></template>
<VButton variant="primary" class="w-full" @click="generateInviteCode" :disabled="generatingInvite">
<VSpinner v-if="generatingInvite" size="sm"/> {{ inviteCode ? t('groupDetailPage.invites.regenerateButton') : t('groupDetailPage.invites.generateButton') }}
</VButton>
<div v-if="inviteCode" class="neo-invite-code mt-3">
<VFormField :label="t('groupDetailPage.invites.activeCodeLabel')" :label-sr-only="false">
<div class="flex items-center gap-2">
<VInput id="inviteCodeInput" :model-value="inviteCode" readonly class="flex-grow" />
<VButton variant="neutral" :icon-only="true" iconLeft="clipboard" @click="copyInviteCodeHandler" :aria-label="t('groupDetailPage.invites.copyButtonLabel')" />
</div>
</VFormField>
<p v-if="copySuccess" class="text-sm text-green-600 mt-1">{{ t('groupDetailPage.invites.copySuccess') }}</p>
</div>
<div v-else class="text-center py-4 mt-3">
<VIcon name="link" size="lg" class="opacity-50 mb-2" />
<p>{{ t('groupDetailPage.invites.emptyState') }}</p>
</div>
</VCard>
</div>
<!-- Lists Section -->
<div class="mt-4">
<ListsPage :group-id="groupId" />
</div>
<!-- Chores Section -->
<VCard class="mt-4">
<template #header>
<div class="flex justify-between items-center w-full">
<VHeading level="3">{{ t('groupDetailPage.chores.title') }}</VHeading>
<VButton :to="`/groups/${groupId}/chores`" variant="primary">
<span class="material-icons" style="margin-right: 0.25em;">cleaning_services</span> {{ t('groupDetailPage.chores.manageButton') }}
</VButton>
</div>
</template>
<VList v-if="upcomingChores.length > 0">
<VListItem v-for="chore in upcomingChores" :key="chore.id" class="flex justify-between items-center">
<div class="neo-chore-info">
<span class="neo-chore-name">{{ chore.name }}</span>
<span class="neo-chore-due">{{ t('groupDetailPage.chores.duePrefix') }} {{ formatDate(chore.next_due_date) }}</span>
</div>
<VBadge :text="formatFrequency(chore.frequency)" :variant="getFrequencyBadgeVariant(chore.frequency)" />
</VListItem>
</VList>
<div v-else class="text-center py-4">
<VIcon name="cleaning_services" size="lg" class="opacity-50 mb-2" /> {/* Assuming cleaning_services is a valid VIcon name or will be added */}
<p>{{ t('groupDetailPage.chores.emptyState') }}</p>
</div>
</VCard>
<!-- Expenses Section -->
<VCard class="mt-4">
<template #header>
<div class="flex justify-between items-center w-full">
<VHeading level="3">{{ t('groupDetailPage.expenses.title') }}</VHeading>
<!-- Expenses Section -->
<div class="mt-4 neo-section">
<div class="flex justify-between items-center w-full mb-2">
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.expenses.title') }}</VHeading>
<VButton :to="`/groups/${groupId}/expenses`" variant="primary">
<span class="material-icons" style="margin-right: 0.25em;">payments</span> {{ t('groupDetailPage.expenses.manageButton') }}
<span class="material-icons" style="margin-right: 0.25em;">payments</span> {{
t('groupDetailPage.expenses.manageButton') }}
</VButton>
</div>
</template>
<VList v-if="recentExpenses.length > 0">
<VListItem v-for="expense in recentExpenses" :key="expense.id" class="flex justify-between items-center">
<div class="neo-expense-info">
<span class="neo-expense-name">{{ expense.description }}</span>
<span class="neo-expense-date">{{ formatDate(expense.expense_date) }}</span>
</div>
<div class="neo-expense-details">
<span class="neo-expense-amount">{{ expense.currency }} {{ formatAmount(expense.total_amount) }}</span>
<VBadge :text="formatSplitType(expense.split_type)" :variant="getSplitTypeBadgeVariant(expense.split_type)" />
</div>
</VListItem>
</VList>
<div v-else class="text-center py-4">
<VIcon name="payments" size="lg" class="opacity-50 mb-2" /> {/* Assuming payments is a valid VIcon name or will be added */}
<p>{{ t('groupDetailPage.expenses.emptyState') }}</p>
<VList v-if="recentExpenses.length > 0">
<VListItem v-for="expense in recentExpenses" :key="expense.id" class="flex justify-between items-center">
<div class="neo-expense-info">
<span class="neo-expense-name">{{ expense.description }}</span>
<span class="neo-expense-date">{{ formatDate(expense.expense_date) }}</span>
</div>
<div class="neo-expense-details">
<span class="neo-expense-amount">{{ expense.currency }} {{ formatAmount(expense.total_amount) }}</span>
<VBadge :text="formatSplitType(expense.split_type)"
:variant="getSplitTypeBadgeVariant(expense.split_type)" />
</div>
</VListItem>
</VList>
<div v-else class="text-center py-4">
<VIcon name="payments" size="lg" class="opacity-50 mb-2" />
<p>{{ t('groupDetailPage.expenses.emptyState') }}</p>
</div>
</div>
</VCard>
</div>
</div>
<VAlert v-else type="info" :message="t('groupDetailPage.groupNotFound')" />
@ -130,6 +143,7 @@ import { choreService } from '../services/choreService'
import type { Chore, ChoreFrequency } from '../types/chore'
import { format } from 'date-fns'
import type { Expense } from '@/types/expense'
import type { BadgeVariant } from '@/components/valerie/VBadge.vue';
import VHeading from '@/components/valerie/VHeading.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
import VAlert from '@/components/valerie/VAlert.vue';
@ -241,13 +255,13 @@ const generateInviteCode = async () => {
if (response.data && response.data.code) {
inviteCode.value = response.data.code;
inviteExpiresAt.value = response.data.expires_at; // Update with new expiry
notificationStore.addNotification({ message: t('groupDetailPage.notifications.generateInviteSuccess'), type: 'success' });
notificationStore.addNotification({ message: t('groupDetailPage.notifications.generateInviteSuccess'), type: 'success' });
} else {
// Should not happen if POST is successful and returns the code
throw new Error(t('groupDetailPage.invites.errors.newDataInvalid'));
throw new Error(t('groupDetailPage.invites.errors.newDataInvalid'));
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('groupDetailPage.notifications.generateInviteError');
const message = err instanceof Error ? err.message : t('groupDetailPage.notifications.generateInviteError');
console.error('Error generating invite code:', err);
notificationStore.addNotification({ message, type: 'error' });
} finally {
@ -257,7 +271,7 @@ const generateInviteCode = async () => {
const copyInviteCodeHandler = async () => {
if (!clipboardIsSupported.value || !inviteCode.value) {
notificationStore.addNotification({ message: t('groupDetailPage.notifications.clipboardNotSupported'), type: 'warning' });
notificationStore.addNotification({ message: t('groupDetailPage.notifications.clipboardNotSupported'), type: 'warning' });
return;
}
await copy(inviteCode.value);
@ -267,7 +281,7 @@ const copyInviteCodeHandler = async () => {
// Optionally, notify success via store if preferred over inline message
// notificationStore.addNotification({ message: 'Invite code copied!', type: 'info' });
} else {
notificationStore.addNotification({ message: t('groupDetailPage.notifications.copyInviteFailed'), type: 'error' });
notificationStore.addNotification({ message: t('groupDetailPage.notifications.copyInviteFailed'), type: 'error' });
}
};
@ -327,8 +341,8 @@ const formatFrequency = (frequency: ChoreFrequency) => {
return options[frequency] || frequency;
};
const getFrequencyBadgeVariant = (frequency: ChoreFrequency): string => {
const colorMap: Record<ChoreFrequency, string> = {
const getFrequencyBadgeVariant = (frequency: ChoreFrequency): BadgeVariant => {
const colorMap: Record<ChoreFrequency, BadgeVariant> = {
one_time: 'neutral',
daily: 'info',
weekly: 'success',
@ -342,8 +356,10 @@ const getFrequencyBadgeVariant = (frequency: ChoreFrequency): string => {
const loadRecentExpenses = async () => {
if (!groupId.value) return
try {
const response = await apiClient.get(`/api/groups/${groupId.value}/expenses`)
recentExpenses.value = response.data.slice(0, 5) // Get only the 5 most recent expenses
const response = await apiClient.get(
`${API_ENDPOINTS.FINANCIALS.EXPENSES}?group_id=${groupId.value}&limit=5`
)
recentExpenses.value = response.data
} catch (error) {
console.error('Error loading recent expenses:', error)
}
@ -363,8 +379,8 @@ const formatSplitType = (type: string) => {
return t(key);
};
const getSplitTypeBadgeVariant = (type: string): string => {
const colorMap: Record<string, string> = {
const getSplitTypeBadgeVariant = (type: string): BadgeVariant => {
const colorMap: Record<string, BadgeVariant> = {
equal: 'info',
exact_amounts: 'success',
percentage: 'accent', // Using accent for purple
@ -416,38 +432,55 @@ onMounted(() => {
width: 100%;
}
/* Neo Grid Layout */
.neo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
/* Neo Card Styles */
.neo-card {
border-radius: 18px;
box-shadow: 6px 6px 0 #111;
background: var(--light);
.neo-section-container {
border: 3px solid #111;
border-radius: 18px;
background: rgb(255, 248, 240);
box-shadow: 6px 6px 0 #111;
overflow: hidden;
}
.neo-card-header {
.neo-section {
padding: 1.5rem;
border-bottom: 3px solid #111;
background: #fafafa;
border-bottom: 1px solid #eee;
}
.neo-card-header h3 {
.neo-section:last-child {
border-bottom: none;
}
.neo-section-header {
font-weight: 900;
font-size: 1.25rem;
margin: 0;
margin-bottom: 1rem;
letter-spacing: 0.5px;
}
.neo-card-body {
padding: 1.5rem;
.neo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 0;
border-bottom: 1px solid #eee;
}
.neo-grid .neo-section {
border-bottom: none;
}
.neo-grid .neo-section:first-child {
border-right: 1px solid #eee;
}
@media (max-width: 620px) {
.neo-grid {
grid-template-columns: 1fr;
}
.neo-grid .neo-section:first-child {
border-right: none;
border-bottom: 1px solid #eee;
}
}
/* Members List Styles */
@ -549,7 +582,7 @@ onMounted(() => {
/* Responsive Adjustments */
@media (max-width: 900px) {
.neo-grid {
gap: 1.5rem;
/* The gap is removed to allow for border-based separators */
}
}
@ -558,11 +591,6 @@ onMounted(() => {
padding: 0.5rem;
}
.neo-card-header,
.neo-card-body {
padding: 1rem;
}
.neo-member-item {
flex-direction: column;
gap: 0.75rem;