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:
parent
0aa88d0af7
commit
77178cc67e
@ -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>
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user