
- Introduced a new `RecurrencePattern` model to manage recurrence details for expenses, allowing for daily, weekly, monthly, and yearly patterns. - Updated the `Expense` model to include fields for recurrence management, such as `is_recurring`, `recurrence_pattern_id`, and `next_occurrence`. - Modified the database schema to reflect these changes, including alterations to existing columns and the removal of obsolete fields. - Enhanced the expense creation logic to accommodate recurring expenses and updated related CRUD operations accordingly. - Implemented necessary migrations to ensure database integrity and support for the new features.
728 lines
19 KiB
Vue
728 lines
19 KiB
Vue
<template>
|
|
<main class="container page-padding">
|
|
<div v-if="loading" class="text-center">
|
|
<div class="spinner-dots" role="status"><span /><span /><span /></div>
|
|
<p>Loading group details...</p>
|
|
</div>
|
|
<div v-else-if="error" class="alert alert-error mb-3" role="alert">
|
|
<div class="alert-content">
|
|
<svg class="icon" aria-hidden="true">
|
|
<use xlink:href="#icon-alert-triangle" />
|
|
</svg>
|
|
{{ error }}
|
|
</div>
|
|
<button type="button" class="btn btn-sm btn-danger" @click="fetchGroupDetails">Retry</button>
|
|
</div>
|
|
<div v-else-if="group">
|
|
<h1 class="mb-3">{{ group.name }}</h1>
|
|
|
|
<div class="neo-grid">
|
|
<!-- Group Members Section -->
|
|
<div class="neo-card">
|
|
<div class="neo-card-header">
|
|
<h3>Group Members</h3>
|
|
</div>
|
|
<div class="neo-card-body">
|
|
<div v-if="group.members && group.members.length > 0" class="neo-members-list">
|
|
<div v-for="member in group.members" :key="member.id" class="neo-member-item">
|
|
<div class="neo-member-info">
|
|
<span class="neo-member-name">{{ member.email }}</span>
|
|
<span class="neo-member-role" :class="member.role?.toLowerCase()">{{ member.role || 'Member' }}</span>
|
|
</div>
|
|
<button v-if="canRemoveMember(member)" class="btn btn-danger btn-sm" @click="removeMember(member.id)"
|
|
:disabled="removingMember === member.id">
|
|
<span v-if="removingMember === member.id" class="spinner-dots-sm"
|
|
role="status"><span /><span /><span /></span>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div v-else class="neo-empty-state">
|
|
<svg class="icon icon-lg" aria-hidden="true">
|
|
<use xlink:href="#icon-users" />
|
|
</svg>
|
|
<p>No members found.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Invite Members Section -->
|
|
<div class="neo-card">
|
|
<div class="neo-card-header">
|
|
<h3>Invite Members</h3>
|
|
</div>
|
|
<div class="neo-card-body">
|
|
<button class="btn btn-primary w-full" @click="generateInviteCode" :disabled="generatingInvite">
|
|
<span v-if="generatingInvite" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
|
|
{{ inviteCode ? 'Regenerate Invite Code' : 'Generate Invite Code' }}
|
|
</button>
|
|
<div v-if="inviteCode" class="neo-invite-code mt-3">
|
|
<label for="inviteCodeInput" class="neo-label">Current Active Invite Code:</label>
|
|
<div class="neo-input-group">
|
|
<input id="inviteCodeInput" type="text" :value="inviteCode" class="neo-input" readonly />
|
|
<button class="btn btn-neutral btn-icon-only" @click="copyInviteCodeHandler"
|
|
aria-label="Copy invite code">
|
|
<svg class="icon">
|
|
<use xlink:href="#icon-clipboard"></use>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<p v-if="copySuccess" class="neo-success-text">Invite code copied to clipboard!</p>
|
|
</div>
|
|
<div v-else class="neo-empty-state mt-3">
|
|
<svg class="icon icon-lg" aria-hidden="true">
|
|
<use xlink:href="#icon-link" />
|
|
</svg>
|
|
<p>No active invite code. Click the button above to generate one.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Lists Section -->
|
|
<div class="mt-4">
|
|
<ListsPage :group-id="groupId" />
|
|
</div>
|
|
|
|
<!-- Chores Section -->
|
|
<div class="mt-4">
|
|
<div class="neo-card">
|
|
<div class="neo-card-header">
|
|
<h3>Group Chores</h3>
|
|
<router-link :to="`/groups/${groupId}/chores`" class="btn btn-primary">
|
|
<span class="material-icons">cleaning_services</span>
|
|
Manage Chores
|
|
</router-link>
|
|
</div>
|
|
<div class="neo-card-body">
|
|
<div v-if="upcomingChores.length > 0" class="neo-chores-list">
|
|
<div v-for="chore in upcomingChores" :key="chore.id" class="neo-chore-item">
|
|
<div class="neo-chore-info">
|
|
<span class="neo-chore-name">{{ chore.name }}</span>
|
|
<span class="neo-chore-due">Due: {{ formatDate(chore.next_due_date) }}</span>
|
|
</div>
|
|
<span class="neo-chip" :class="getFrequencyColor(chore.frequency)">
|
|
{{ formatFrequency(chore.frequency) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div v-else class="neo-empty-state">
|
|
<svg class="icon icon-lg" aria-hidden="true">
|
|
<use xlink:href="#icon-cleaning_services" />
|
|
</svg>
|
|
<p>No chores scheduled. Click "Manage Chores" to create some!</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Expenses Section -->
|
|
<div class="mt-4">
|
|
<div class="neo-card">
|
|
<div class="neo-card-header">
|
|
<h3>Group Expenses</h3>
|
|
<router-link :to="`/groups/${groupId}/expenses`" class="btn btn-primary">
|
|
<span class="material-icons">payments</span>
|
|
Manage Expenses
|
|
</router-link>
|
|
</div>
|
|
<div class="neo-card-body">
|
|
<div v-if="recentExpenses.length > 0" class="neo-expenses-list">
|
|
<div v-for="expense in recentExpenses" :key="expense.id" class="neo-expense-item">
|
|
<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>
|
|
<span class="neo-chip" :class="getSplitTypeColor(expense.split_type)">
|
|
{{ formatSplitType(expense.split_type) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="neo-empty-state">
|
|
<svg class="icon icon-lg" aria-hidden="true">
|
|
<use xlink:href="#icon-payments" />
|
|
</svg>
|
|
<p>No expenses recorded. Click "Manage Expenses" to add some!</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div v-else class="alert alert-info" role="status">
|
|
<div class="alert-content">Group not found or an error occurred.</div>
|
|
</div>
|
|
</main>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, computed } from 'vue';
|
|
// import { useRoute } from 'vue-router';
|
|
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
|
|
import { useClipboard } from '@vueuse/core';
|
|
import ListsPage from './ListsPage.vue'; // Import ListsPage
|
|
import { useNotificationStore } from '@/stores/notifications';
|
|
import { choreService } from '../services/choreService'
|
|
import type { Chore, ChoreFrequency } from '../types/chore'
|
|
import { format } from 'date-fns'
|
|
import type { Expense } from '@/types/expense'
|
|
|
|
interface Group {
|
|
id: string | number;
|
|
name: string;
|
|
members?: GroupMember[];
|
|
}
|
|
|
|
interface GroupMember {
|
|
id: number;
|
|
email: string;
|
|
role?: string;
|
|
}
|
|
|
|
const props = defineProps<{
|
|
id: string;
|
|
}>();
|
|
|
|
// const route = useRoute();
|
|
// const $q = useQuasar(); // Not used anymore
|
|
|
|
const notificationStore = useNotificationStore();
|
|
const group = ref<Group | null>(null);
|
|
const loading = ref(true);
|
|
const error = ref<string | null>(null);
|
|
const inviteCode = ref<string | null>(null);
|
|
const inviteExpiresAt = ref<string | null>(null);
|
|
const generatingInvite = ref(false);
|
|
const copySuccess = ref(false);
|
|
const removingMember = ref<number | null>(null);
|
|
|
|
// groupId is directly from props.id now, which comes from the route path param
|
|
const groupId = computed(() => props.id);
|
|
|
|
const { copy, copied, isSupported: clipboardIsSupported } = useClipboard({
|
|
source: computed(() => inviteCode.value || '')
|
|
});
|
|
|
|
// Chores state
|
|
const upcomingChores = ref<Chore[]>([])
|
|
|
|
// Add new state for expenses
|
|
const recentExpenses = ref<Expense[]>([])
|
|
|
|
const fetchActiveInviteCode = async () => {
|
|
if (!groupId.value) return;
|
|
// Consider adding a loading state for this fetch if needed, e.g., initialInviteCodeLoading
|
|
try {
|
|
const response = await apiClient.get(API_ENDPOINTS.GROUPS.GET_ACTIVE_INVITE(String(groupId.value)));
|
|
if (response.data && response.data.code) {
|
|
inviteCode.value = response.data.code;
|
|
inviteExpiresAt.value = response.data.expires_at; // Store expiry
|
|
} else {
|
|
inviteCode.value = null; // No active code found
|
|
inviteExpiresAt.value = null;
|
|
}
|
|
} catch (err: any) {
|
|
if (err.response && err.response.status === 404) {
|
|
inviteCode.value = null; // Explicitly set to null on 404
|
|
inviteExpiresAt.value = null;
|
|
// Optional: notify user or set a flag to show "generate one" message more prominently
|
|
console.info('No active invite code found for this group.');
|
|
} else {
|
|
const message = err instanceof Error ? err.message : 'Failed to fetch active invite code.';
|
|
// error.value = message; // This would display a large error banner, might be too much
|
|
console.error('Error fetching active invite code:', err);
|
|
notificationStore.addNotification({ message, type: 'error' });
|
|
}
|
|
}
|
|
};
|
|
|
|
const fetchGroupDetails = async () => {
|
|
if (!groupId.value) return;
|
|
loading.value = true;
|
|
error.value = null;
|
|
try {
|
|
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(String(groupId.value)));
|
|
group.value = response.data;
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to fetch group details.';
|
|
error.value = message;
|
|
console.error('Error fetching group details:', err);
|
|
notificationStore.addNotification({ message, type: 'error' });
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
// Fetch active invite code after group details are loaded
|
|
await fetchActiveInviteCode();
|
|
};
|
|
|
|
const generateInviteCode = async () => {
|
|
if (!groupId.value) return;
|
|
generatingInvite.value = true;
|
|
copySuccess.value = false;
|
|
try {
|
|
const response = await apiClient.post(API_ENDPOINTS.GROUPS.CREATE_INVITE(String(groupId.value)));
|
|
if (response.data && response.data.code) {
|
|
inviteCode.value = response.data.code;
|
|
inviteExpiresAt.value = response.data.expires_at; // Update with new expiry
|
|
notificationStore.addNotification({ message: 'New invite code generated successfully!', type: 'success' });
|
|
} else {
|
|
// Should not happen if POST is successful and returns the code
|
|
throw new Error('New invite code data is invalid.');
|
|
}
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to generate invite code.';
|
|
console.error('Error generating invite code:', err);
|
|
notificationStore.addNotification({ message, type: 'error' });
|
|
} finally {
|
|
generatingInvite.value = false;
|
|
}
|
|
};
|
|
|
|
const copyInviteCodeHandler = async () => {
|
|
if (!clipboardIsSupported.value || !inviteCode.value) {
|
|
notificationStore.addNotification({ message: 'Clipboard not supported or no code to copy.', type: 'warning' });
|
|
return;
|
|
}
|
|
await copy(inviteCode.value);
|
|
if (copied.value) {
|
|
copySuccess.value = true;
|
|
setTimeout(() => (copySuccess.value = false), 2000);
|
|
// Optionally, notify success via store if preferred over inline message
|
|
// notificationStore.addNotification({ message: 'Invite code copied!', type: 'info' });
|
|
} else {
|
|
notificationStore.addNotification({ message: 'Failed to copy invite code.', type: 'error' });
|
|
}
|
|
};
|
|
|
|
const canRemoveMember = (member: GroupMember): boolean => {
|
|
// Only allow removing members if the current user is the owner
|
|
// and the member is not the owner themselves
|
|
return group.value?.members?.some(m => m.role === 'owner' && m.id === member.id) === false;
|
|
};
|
|
|
|
const removeMember = async (memberId: number) => {
|
|
if (!groupId.value) return;
|
|
|
|
removingMember.value = memberId;
|
|
try {
|
|
await apiClient.delete(API_ENDPOINTS.GROUPS.MEMBER(String(groupId.value), String(memberId)));
|
|
// Refresh group details to update the members list
|
|
await fetchGroupDetails();
|
|
notificationStore.addNotification({
|
|
message: 'Member removed successfully',
|
|
type: 'success'
|
|
});
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to remove member';
|
|
console.error('Error removing member:', err);
|
|
notificationStore.addNotification({ message, type: 'error' });
|
|
} finally {
|
|
removingMember.value = null;
|
|
}
|
|
};
|
|
|
|
// Chores methods
|
|
const loadUpcomingChores = async () => {
|
|
if (!groupId.value) return
|
|
try {
|
|
const chores = await choreService.getChores(Number(groupId.value))
|
|
// Sort by due date and take the next 5
|
|
upcomingChores.value = chores
|
|
.sort((a, b) => new Date(a.next_due_date).getTime() - new Date(b.next_due_date).getTime())
|
|
.slice(0, 5)
|
|
} catch (error) {
|
|
console.error('Error loading upcoming chores:', error)
|
|
}
|
|
}
|
|
|
|
const formatDate = (date: string) => {
|
|
return format(new Date(date), 'MMM d, yyyy')
|
|
}
|
|
|
|
const formatFrequency = (frequency: ChoreFrequency) => {
|
|
const options = {
|
|
one_time: 'One Time',
|
|
daily: 'Daily',
|
|
weekly: 'Weekly',
|
|
monthly: 'Monthly',
|
|
custom: 'Custom'
|
|
}
|
|
return options[frequency] || frequency
|
|
}
|
|
|
|
const getFrequencyColor = (frequency: ChoreFrequency) => {
|
|
const colors: Record<ChoreFrequency, string> = {
|
|
one_time: 'grey',
|
|
daily: 'blue',
|
|
weekly: 'green',
|
|
monthly: 'purple',
|
|
custom: 'orange'
|
|
}
|
|
return colors[frequency]
|
|
}
|
|
|
|
// Add new methods for expenses
|
|
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
|
|
} catch (error) {
|
|
console.error('Error loading recent expenses:', error)
|
|
}
|
|
}
|
|
|
|
const formatAmount = (amount: string) => {
|
|
return parseFloat(amount).toFixed(2)
|
|
}
|
|
|
|
const formatSplitType = (type: string) => {
|
|
return type.split('_').map(word =>
|
|
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
|
).join(' ')
|
|
}
|
|
|
|
const getSplitTypeColor = (type: string) => {
|
|
const colors: Record<string, string> = {
|
|
equal: 'blue',
|
|
exact_amounts: 'green',
|
|
percentage: 'purple',
|
|
shares: 'orange',
|
|
item_based: 'teal'
|
|
}
|
|
return colors[type] || 'grey'
|
|
}
|
|
|
|
onMounted(() => {
|
|
fetchGroupDetails();
|
|
loadUpcomingChores();
|
|
loadRecentExpenses();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.page-padding {
|
|
padding: 1rem;
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.mt-1 {
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.mt-2 {
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.mt-3 {
|
|
margin-top: 1.5rem;
|
|
}
|
|
|
|
.mt-4 {
|
|
margin-top: 2rem;
|
|
}
|
|
|
|
.mb-3 {
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.ml-1 {
|
|
margin-left: 0.25rem;
|
|
}
|
|
|
|
.w-full {
|
|
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);
|
|
border: 3px solid #111;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.neo-card-header {
|
|
padding: 1.5rem;
|
|
border-bottom: 3px solid #111;
|
|
background: #fafafa;
|
|
}
|
|
|
|
.neo-card-header h3 {
|
|
font-weight: 900;
|
|
font-size: 1.25rem;
|
|
margin: 0;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.neo-card-body {
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
/* Members List Styles */
|
|
.neo-members-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.neo-member-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 1rem;
|
|
border-radius: 12px;
|
|
background: #fafafa;
|
|
border: 2px solid #111;
|
|
transition: transform 0.1s ease-in-out;
|
|
}
|
|
|
|
.neo-member-item:hover {
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.neo-member-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.neo-member-name {
|
|
font-weight: 600;
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.neo-member-role {
|
|
font-size: 0.875rem;
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 1rem;
|
|
background: #e0e0e0;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.neo-member-role.owner {
|
|
background: #111;
|
|
color: white;
|
|
}
|
|
|
|
/* Invite Code Styles */
|
|
.neo-invite-code {
|
|
background: #fafafa;
|
|
padding: 1rem;
|
|
border-radius: 12px;
|
|
border: 2px solid #111;
|
|
}
|
|
|
|
.neo-label {
|
|
display: block;
|
|
font-weight: 600;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.neo-input-group {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.neo-input {
|
|
flex: 1;
|
|
padding: 0.75rem;
|
|
border: 2px solid #111;
|
|
border-radius: 8px;
|
|
font-family: monospace;
|
|
font-size: 1rem;
|
|
background: white;
|
|
}
|
|
|
|
.neo-success-text {
|
|
color: var(--success);
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
/* Empty State Styles */
|
|
.neo-empty-state {
|
|
text-align: center;
|
|
padding: 2rem;
|
|
color: #666;
|
|
}
|
|
|
|
.neo-empty-state .icon {
|
|
width: 3rem;
|
|
height: 3rem;
|
|
margin-bottom: 1rem;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
/* Responsive Adjustments */
|
|
@media (max-width: 900px) {
|
|
.neo-grid {
|
|
gap: 1.5rem;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.page-padding {
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
.neo-card-header,
|
|
.neo-card-body {
|
|
padding: 1rem;
|
|
}
|
|
|
|
.neo-member-item {
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.neo-member-info {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
gap: 0.5rem;
|
|
}
|
|
}
|
|
|
|
/* Chores List Styles */
|
|
.neo-chores-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.neo-chore-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 1rem;
|
|
border-radius: 12px;
|
|
background: #fafafa;
|
|
border: 2px solid #111;
|
|
transition: transform 0.1s ease-in-out;
|
|
}
|
|
|
|
.neo-chore-item:hover {
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.neo-chore-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.neo-chore-name {
|
|
font-weight: 600;
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.neo-chore-due {
|
|
font-size: 0.875rem;
|
|
color: #666;
|
|
}
|
|
|
|
/* Expenses List Styles */
|
|
.neo-expenses-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.neo-expense-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 1rem;
|
|
border-radius: 12px;
|
|
background: #fafafa;
|
|
border: 2px solid #111;
|
|
transition: transform 0.1s ease-in-out;
|
|
}
|
|
|
|
.neo-expense-item:hover {
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.neo-expense-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.neo-expense-name {
|
|
font-weight: 600;
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.neo-expense-date {
|
|
font-size: 0.875rem;
|
|
color: #666;
|
|
}
|
|
|
|
.neo-expense-details {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.neo-expense-amount {
|
|
font-weight: 600;
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.neo-chip {
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 1rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
background: #e0e0e0;
|
|
}
|
|
|
|
.neo-chip.blue {
|
|
background: #e3f2fd;
|
|
color: #1976d2;
|
|
}
|
|
|
|
.neo-chip.green {
|
|
background: #e8f5e9;
|
|
color: #2e7d32;
|
|
}
|
|
|
|
.neo-chip.purple {
|
|
background: #f3e5f5;
|
|
color: #7b1fa2;
|
|
}
|
|
|
|
.neo-chip.orange {
|
|
background: #fff3e0;
|
|
color: #f57c00;
|
|
}
|
|
|
|
.neo-chip.teal {
|
|
background: #e0f2f1;
|
|
color: #00796b;
|
|
}
|
|
|
|
.neo-chip.grey {
|
|
background: #f5f5f5;
|
|
color: #616161;
|
|
}
|
|
</style>
|