
Some checks failed
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Failing after 1m17s
This commit includes several key updates and new features: - Enhanced WebSocket functionality across various components, improving real-time communication and user experience. - Introduced new components for managing settlements, including `SettlementCard.vue`, `SettlementForm.vue`, and `SuggestedSettlementsCard.vue`, to streamline financial interactions. - Updated existing components and services to support the new settlement features, ensuring consistency and improved performance. - Added advanced performance optimizations to enhance loading times and responsiveness throughout the application. These changes aim to provide a more robust and user-friendly experience in managing financial settlements and real-time interactions.
1618 lines
47 KiB
Vue
1618 lines
47 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, computed, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { format, startOfDay, isEqual, isToday as isTodayDate, formatDistanceToNow, parseISO } from 'date-fns'
|
|
import { choreService } from '../services/choreService'
|
|
import { useNotificationStore } from '../stores/notifications'
|
|
import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency, ChoreAssignment, ChoreHistory, ChoreWithCompletion } from '../types/chore'
|
|
import { groupService } from '../services/groupService'
|
|
import { useStorage } from '@vueuse/core'
|
|
import ChoreItem from '@/components/ChoreItem.vue';
|
|
import { useChoreStore } from '@/stores/choreStore';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
import type { UserPublic } from '@/types/user';
|
|
import BaseIcon from '@/components/BaseIcon.vue';
|
|
import ChoreDetailSheet from '@/components/ChoreDetailSheet.vue';
|
|
import Dialog from '@/components/ui/Dialog.vue';
|
|
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/vue';
|
|
import { TransitionExpand } from '@/components/ui';
|
|
import { Button } from '@/components/ui';
|
|
import QuickChoreAdd from '@/components/QuickChoreAdd.vue';
|
|
import { storeToRefs } from 'pinia'
|
|
|
|
const { t } = useI18n()
|
|
|
|
const props = defineProps<{ groupId?: number | string }>();
|
|
|
|
// Form state
|
|
const showAdvancedOptions = ref(false)
|
|
|
|
// Types
|
|
// ChoreWithCompletion is now imported from ../types/chore
|
|
|
|
interface ChoreFormData {
|
|
name: string;
|
|
description: string;
|
|
frequency: ChoreFrequency;
|
|
custom_interval_days: number | undefined;
|
|
next_due_date: string;
|
|
type: 'personal' | 'group';
|
|
group_id: number | undefined;
|
|
parent_chore_id?: number | null;
|
|
assigned_to_user_id?: number | null;
|
|
}
|
|
|
|
const notificationStore = useNotificationStore()
|
|
|
|
// State
|
|
const chores = ref<ChoreWithCompletion[]>([])
|
|
const groups = ref<{ id: number, name: string }[]>([])
|
|
const showChoreModal = ref(false)
|
|
const showDeleteDialog = ref(false)
|
|
const showChoreDetailModal = ref(false)
|
|
const showHistoryModal = ref(false)
|
|
const isEditing = ref(false)
|
|
const selectedChore = ref<ChoreWithCompletion | null>(null)
|
|
const selectedChoreHistory = ref<ChoreHistory[]>([])
|
|
const selectedChoreAssignments = ref<ChoreAssignment[]>([])
|
|
const loadingHistory = ref(false)
|
|
const loadingAssignments = ref(false)
|
|
const groupMembers = ref<UserPublic[]>([])
|
|
const loadingMembers = ref(false)
|
|
const choreFormGroupMembers = ref<UserPublic[]>([])
|
|
const loadingChoreFormMembers = ref(false)
|
|
|
|
const cachedChores = useStorage<ChoreWithCompletion[]>('cached-chores-v2', [])
|
|
const cachedTimestamp = useStorage<number>('cached-chores-timestamp-v2', 0)
|
|
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
|
|
|
const initialChoreFormState: ChoreFormData = {
|
|
name: '',
|
|
description: '',
|
|
frequency: 'daily',
|
|
custom_interval_days: undefined,
|
|
next_due_date: format(new Date(), 'yyyy-MM-dd'),
|
|
type: 'personal',
|
|
group_id: undefined,
|
|
parent_chore_id: null,
|
|
assigned_to_user_id: null,
|
|
}
|
|
|
|
const choreForm = ref({ ...initialChoreFormState })
|
|
const isLoading = ref(true)
|
|
|
|
const authStore = useAuthStore();
|
|
const { isGuest, user } = storeToRefs(authStore);
|
|
|
|
const choreStore = useChoreStore();
|
|
|
|
const activeTimer = computed(() => {
|
|
return choreStore.activeTimerEntry;
|
|
});
|
|
|
|
const loadChores = async () => {
|
|
const now = Date.now();
|
|
if (cachedChores.value && cachedChores.value.length > 0 && (now - cachedTimestamp.value) < CACHE_DURATION) {
|
|
chores.value = cachedChores.value;
|
|
isLoading.value = false;
|
|
} else {
|
|
isLoading.value = true;
|
|
}
|
|
|
|
try {
|
|
const fetchedChores = await choreService.getAllChores()
|
|
const currentUserId = user.value?.id ? Number(user.value.id) : null;
|
|
|
|
const mappedChores = fetchedChores.map(c => {
|
|
// Prefer the assignment that belongs to the current user, otherwise fallback to the first assignment
|
|
const userAssignment = c.assignments?.find(a => a.assigned_to_user_id === currentUserId) ?? null;
|
|
const displayAssignment = userAssignment ?? (c.assignments?.[0] ?? null);
|
|
|
|
return {
|
|
...c,
|
|
current_assignment_id: userAssignment?.id ?? null,
|
|
is_completed: userAssignment?.is_complete ?? false,
|
|
completed_at: userAssignment?.completed_at ?? null,
|
|
assigned_user_name:
|
|
displayAssignment?.assigned_user?.name ||
|
|
displayAssignment?.assigned_user?.email ||
|
|
'Unknown',
|
|
completed_by_name:
|
|
displayAssignment?.assigned_user?.name ||
|
|
displayAssignment?.assigned_user?.email ||
|
|
'Unknown',
|
|
updating: false,
|
|
} as ChoreWithCompletion;
|
|
});
|
|
chores.value = mappedChores;
|
|
cachedChores.value = mappedChores;
|
|
cachedTimestamp.value = Date.now()
|
|
} catch (error) {
|
|
console.error(t('choresPage.consoleErrors.loadFailed'), error)
|
|
notificationStore.addNotification({ message: t('choresPage.notifications.loadFailed', 'Failed to load chores.'), type: 'error' })
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
const loadGroups = async () => {
|
|
try {
|
|
groups.value = await groupService.getUserGroups();
|
|
} catch (error) {
|
|
console.error(t('choresPage.consoleErrors.loadGroupsFailed'), error);
|
|
notificationStore.addNotification({ message: t('choresPage.notifications.loadGroupsFailed', 'Failed to load groups.'), type: 'error' });
|
|
}
|
|
}
|
|
|
|
const loadTimeEntries = async () => {
|
|
chores.value.forEach(chore => {
|
|
if (chore.current_assignment_id) {
|
|
choreStore.fetchTimeEntries(chore.current_assignment_id);
|
|
}
|
|
});
|
|
};
|
|
|
|
// Watch for type changes to clear group_id when switching to personal
|
|
watch(() => choreForm.value.type, (newType) => {
|
|
if (newType === 'personal') {
|
|
choreForm.value.group_id = undefined
|
|
}
|
|
})
|
|
|
|
// Fetch group members when a group is selected in the form
|
|
watch(() => choreForm.value.group_id, async (newGroupId) => {
|
|
if (newGroupId && choreForm.value.type === 'group') {
|
|
loadingChoreFormMembers.value = true;
|
|
try {
|
|
choreFormGroupMembers.value = await groupService.getGroupMembers(newGroupId);
|
|
} catch (error) {
|
|
console.error('Failed to load group members for form:', error);
|
|
choreFormGroupMembers.value = [];
|
|
} finally {
|
|
loadingChoreFormMembers.value = false;
|
|
}
|
|
} else {
|
|
choreFormGroupMembers.value = [];
|
|
}
|
|
});
|
|
|
|
// Reload chores once the user information becomes available (e.g., after login refresh)
|
|
watch(user, (newUser, oldUser) => {
|
|
if (newUser && !oldUser) {
|
|
loadChores().then(loadTimeEntries);
|
|
}
|
|
});
|
|
|
|
onMounted(() => {
|
|
loadChores().then(loadTimeEntries);
|
|
loadGroups()
|
|
})
|
|
|
|
const frequencyOptions = computed(() => [
|
|
{ label: t('choresPage.frequencyOptions.oneTime'), value: 'one_time' as ChoreFrequency },
|
|
{ label: t('choresPage.frequencyOptions.daily'), value: 'daily' as ChoreFrequency },
|
|
{ label: t('choresPage.frequencyOptions.weekly'), value: 'weekly' as ChoreFrequency },
|
|
{ label: t('choresPage.frequencyOptions.monthly'), value: 'monthly' as ChoreFrequency },
|
|
{ label: t('choresPage.frequencyOptions.custom'), value: 'custom' as ChoreFrequency }
|
|
]);
|
|
|
|
const getChoreSubtext = (chore: ChoreWithCompletion): string => {
|
|
if (chore.is_completed && chore.completed_at) {
|
|
const completedDate = new Date(chore.completed_at);
|
|
if (isTodayDate(completedDate)) {
|
|
return t('choresPage.completedToday') + (chore.completed_by_name ? ` by ${chore.completed_by_name}` : '');
|
|
}
|
|
const timeAgo = formatDistanceToNow(completedDate, { addSuffix: true });
|
|
return `Completed ${timeAgo}` + (chore.completed_by_name ? ` by ${chore.completed_by_name}` : '');
|
|
}
|
|
|
|
const parts: string[] = [];
|
|
|
|
// Show who it's assigned to if there's an assignment
|
|
if (chore.current_assignment_id && chore.assigned_user_name) {
|
|
parts.push(`Assigned to ${chore.assigned_user_name}`);
|
|
}
|
|
|
|
// Show creator info for group chores
|
|
if (chore.type === 'group' && chore.creator) {
|
|
parts.push(`Created by ${chore.creator.name || chore.creator.email}`);
|
|
}
|
|
|
|
if (chore.frequency && chore.frequency !== 'one_time') {
|
|
const freqOption = frequencyOptions.value.find(f => f.value === chore.frequency);
|
|
if (freqOption) {
|
|
if (chore.frequency === 'custom' && chore.custom_interval_days) {
|
|
parts.push(t('choresPage.frequency.customInterval', { n: chore.custom_interval_days }));
|
|
} else {
|
|
parts.push(freqOption.label);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (chore.type === 'group' && chore.group_id) {
|
|
const group = groups.value.find(g => g.id === chore.group_id);
|
|
if (group) {
|
|
parts.push(group.name);
|
|
}
|
|
}
|
|
|
|
return parts.join(' · ');
|
|
};
|
|
|
|
const filteredChores = computed(() => {
|
|
if (props.groupId) {
|
|
return chores.value.filter(
|
|
c => c.type === 'group' && String(c.group_id) === String(props.groupId)
|
|
);
|
|
}
|
|
return chores.value;
|
|
});
|
|
|
|
const availableParentChores = computed(() => {
|
|
return chores.value.filter(c => {
|
|
// A chore cannot be its own parent
|
|
if (isEditing.value && selectedChore.value && c.id === selectedChore.value.id) {
|
|
return false;
|
|
}
|
|
// A chore that is already a subtask cannot be a parent
|
|
if (c.parent_chore_id) {
|
|
return false;
|
|
}
|
|
// If a group is selected, only show chores from that group or personal chores
|
|
if (choreForm.value.group_id) {
|
|
return c.group_id === choreForm.value.group_id || c.type === 'personal';
|
|
}
|
|
// If no group is selected, only show personal chores that are not in a group
|
|
return c.type === 'personal' && !c.group_id;
|
|
});
|
|
});
|
|
|
|
const groupedChores = computed(() => {
|
|
if (!filteredChores.value) return [];
|
|
|
|
const choreMap = new Map<number, ChoreWithCompletion>();
|
|
filteredChores.value.forEach(chore => {
|
|
choreMap.set(chore.id, { ...chore, child_chores: [] });
|
|
});
|
|
|
|
const rootChores: ChoreWithCompletion[] = [];
|
|
choreMap.forEach(chore => {
|
|
if (chore.parent_chore_id && choreMap.has(chore.parent_chore_id)) {
|
|
choreMap.get(chore.parent_chore_id)?.child_chores?.push(chore);
|
|
} else {
|
|
rootChores.push(chore);
|
|
}
|
|
});
|
|
|
|
const choresByDate = rootChores.reduce((acc, chore) => {
|
|
const dueDate = format(startOfDay(new Date(chore.next_due_date)), 'yyyy-MM-dd');
|
|
if (!acc[dueDate]) {
|
|
acc[dueDate] = [];
|
|
}
|
|
acc[dueDate].push(chore);
|
|
return acc;
|
|
}, {} as Record<string, ChoreWithCompletion[]>);
|
|
|
|
return Object.keys(choresByDate)
|
|
.sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
|
|
.map(dateStr => {
|
|
const dateParts = dateStr.split('-').map(Number);
|
|
const date = new Date(dateParts[0], dateParts[1] - 1, dateParts[2]);
|
|
return {
|
|
date,
|
|
chores: choresByDate[dateStr]
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
.map(chore => ({
|
|
...chore,
|
|
subtext: getChoreSubtext(chore)
|
|
}))
|
|
};
|
|
});
|
|
});
|
|
|
|
const formatDateHeader = (date: Date) => {
|
|
const today = startOfDay(new Date())
|
|
const itemDate = startOfDay(date)
|
|
|
|
if (isEqual(itemDate, today)) {
|
|
return `${t('choresPage.today', 'Today')}, ${format(itemDate, 'eee, d MMM')}`
|
|
}
|
|
return format(itemDate, 'eee, d MMM')
|
|
}
|
|
|
|
const resetChoreForm = () => {
|
|
choreForm.value = { ...initialChoreFormState, next_due_date: format(new Date(), 'yyyy-MM-dd') };
|
|
isEditing.value = false
|
|
selectedChore.value = null
|
|
showAdvancedOptions.value = false // Reset advanced options
|
|
}
|
|
|
|
const openCreateChoreModal = () => {
|
|
resetChoreForm()
|
|
if (props.groupId) {
|
|
choreForm.value.type = 'group';
|
|
choreForm.value.group_id = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId;
|
|
}
|
|
showChoreModal.value = true
|
|
}
|
|
|
|
const openEditChoreModal = (chore: ChoreWithCompletion) => {
|
|
isEditing.value = true
|
|
selectedChore.value = chore
|
|
choreForm.value = {
|
|
name: chore.name,
|
|
description: chore.description || '',
|
|
frequency: chore.frequency,
|
|
custom_interval_days: chore.custom_interval_days ?? undefined,
|
|
next_due_date: chore.next_due_date,
|
|
type: chore.type,
|
|
group_id: chore.group_id ?? undefined,
|
|
parent_chore_id: chore.parent_chore_id,
|
|
assigned_to_user_id: chore.assigned_to_user_id,
|
|
}
|
|
showChoreModal.value = true
|
|
}
|
|
|
|
const handleFormSubmit = async () => {
|
|
try {
|
|
let createdChore;
|
|
if (isEditing.value && selectedChore.value) {
|
|
const updateData: ChoreUpdate = { ...choreForm.value };
|
|
// Ensure group_id is properly set based on type
|
|
if (updateData.type === 'personal') {
|
|
updateData.group_id = undefined;
|
|
}
|
|
createdChore = await choreService.updateChore(selectedChore.value.id, updateData, selectedChore.value);
|
|
notificationStore.addNotification({ message: t('choresPage.notifications.updateSuccess', 'Chore updated successfully!'), type: 'success' });
|
|
} else {
|
|
const createData = { ...choreForm.value };
|
|
// Ensure group_id is properly set based on type
|
|
if (createData.type === 'personal') {
|
|
createData.group_id = undefined;
|
|
}
|
|
createdChore = await choreService.createChore(createData as ChoreCreate);
|
|
|
|
// Create an assignment for the new chore
|
|
if (createdChore) {
|
|
try {
|
|
await choreService.createAssignment({
|
|
chore_id: createdChore.id,
|
|
assigned_to_user_id: createdChore.created_by_id,
|
|
due_date: createdChore.next_due_date
|
|
});
|
|
} catch (assignmentError) {
|
|
console.error(t('choresPage.consoleErrors.createAssignmentForNewChoreFailed'), assignmentError);
|
|
// Continue anyway since the chore was created
|
|
}
|
|
}
|
|
|
|
notificationStore.addNotification({ message: t('choresPage.notifications.createSuccess', 'Chore created successfully!'), type: 'success' });
|
|
}
|
|
showChoreModal.value = false;
|
|
await loadChores();
|
|
} catch (error) {
|
|
console.error(t('choresPage.consoleErrors.saveFailed'), error);
|
|
notificationStore.addNotification({ message: t('choresPage.notifications.saveFailed', 'Failed to save the chore.'), type: 'error' });
|
|
}
|
|
}
|
|
|
|
const confirmDelete = (chore: ChoreWithCompletion) => {
|
|
selectedChore.value = chore
|
|
showDeleteDialog.value = true
|
|
}
|
|
|
|
const deleteChore = async () => {
|
|
if (!selectedChore.value) return
|
|
try {
|
|
await choreService.deleteChore(selectedChore.value.id, selectedChore.value.type, selectedChore.value.group_id ?? undefined)
|
|
notificationStore.addNotification({ message: t('choresPage.notifications.deleteSuccess', 'Chore deleted successfully.'), type: 'success' })
|
|
showDeleteDialog.value = false
|
|
await loadChores()
|
|
} catch (error) {
|
|
console.error(t('choresPage.consoleErrors.deleteFailed'), error)
|
|
notificationStore.addNotification({ message: t('choresPage.notifications.deleteFailed', 'Failed to delete chore.'), type: 'error' })
|
|
}
|
|
}
|
|
|
|
const toggleCompletion = async (chore: ChoreWithCompletion) => {
|
|
const assignment = chore.current_assignment_id
|
|
? chore.assignments?.find(a => a.id === chore.current_assignment_id) || null
|
|
: null
|
|
try {
|
|
await choreStore.toggleCompletion(chore, assignment as any)
|
|
await loadChores()
|
|
notificationStore.addNotification({
|
|
message: t('choresPage.notifications.updateSuccess', 'Chore status updated.'),
|
|
type: 'success',
|
|
})
|
|
} catch (e) {
|
|
notificationStore.addNotification({
|
|
message: t('choresPage.notifications.updateFailed', 'Failed to update chore status.'),
|
|
type: 'error',
|
|
})
|
|
}
|
|
}
|
|
|
|
const openChoreDetailModal = async (chore: ChoreWithCompletion) => {
|
|
selectedChore.value = chore;
|
|
showChoreDetailModal.value = true;
|
|
groupMembers.value = []; // Reset
|
|
|
|
// Load assignments for this chore
|
|
loadingAssignments.value = true;
|
|
try {
|
|
selectedChoreAssignments.value = await choreService.getChoreAssignments(chore.id);
|
|
} catch (error) {
|
|
console.error('Failed to load chore assignments:', error);
|
|
notificationStore.addNotification({
|
|
message: 'Failed to load chore assignments.',
|
|
type: 'error'
|
|
});
|
|
} finally {
|
|
loadingAssignments.value = false;
|
|
}
|
|
|
|
// If it's a group chore, load members
|
|
if (chore.type === 'group' && chore.group_id) {
|
|
loadingMembers.value = true;
|
|
try {
|
|
groupMembers.value = await groupService.getGroupMembers(chore.group_id);
|
|
} catch (error) {
|
|
console.error('Failed to load group members:', error);
|
|
notificationStore.addNotification({
|
|
message: 'Failed to load group members.',
|
|
type: 'error'
|
|
});
|
|
} finally {
|
|
loadingMembers.value = false;
|
|
}
|
|
}
|
|
};
|
|
|
|
const openHistoryModal = async (chore: ChoreWithCompletion) => {
|
|
selectedChore.value = chore;
|
|
showHistoryModal.value = true;
|
|
|
|
// Load history for this chore
|
|
loadingHistory.value = true;
|
|
try {
|
|
selectedChoreHistory.value = await choreService.getChoreHistory(chore.id);
|
|
} catch (error) {
|
|
console.error('Failed to load chore history:', error);
|
|
notificationStore.addNotification({
|
|
message: 'Failed to load chore history.',
|
|
type: 'error'
|
|
});
|
|
} finally {
|
|
loadingHistory.value = false;
|
|
}
|
|
};
|
|
|
|
const formatHistoryEntry = (entry: ChoreHistory) => {
|
|
const timestamp = format(parseISO(entry.timestamp), 'MMM d, h:mm a');
|
|
const user = entry.changed_by_user?.name || entry.changed_by_user?.email || 'System';
|
|
|
|
switch (entry.event_type) {
|
|
case 'created':
|
|
return `${timestamp} - ${user} created this chore`;
|
|
case 'updated':
|
|
return `${timestamp} - ${user} updated this chore`;
|
|
case 'deleted':
|
|
return `${timestamp} - ${user} deleted this chore`;
|
|
case 'assigned':
|
|
return `${timestamp} - ${user} assigned this chore`;
|
|
case 'completed':
|
|
return `${timestamp} - ${user} completed this chore`;
|
|
case 'reopened':
|
|
return `${timestamp} - ${user} reopened this chore`;
|
|
default:
|
|
return `${timestamp} - ${user} performed action: ${entry.event_type}`;
|
|
}
|
|
};
|
|
|
|
const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
|
if (chore.is_completed) return 'completed';
|
|
|
|
const today = startOfDay(new Date());
|
|
const dueDate = startOfDay(new Date(chore.next_due_date));
|
|
|
|
if (dueDate < today) return 'overdue';
|
|
if (isEqual(dueDate, today)) return 'due-today';
|
|
return 'upcoming';
|
|
};
|
|
|
|
const startTimer = async (chore: ChoreWithCompletion) => {
|
|
if (chore.is_completed || !chore.current_assignment_id) return;
|
|
await choreStore.startTimer(chore.current_assignment_id);
|
|
};
|
|
|
|
const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
|
if (!chore.current_assignment_id) return;
|
|
await choreStore.stopTimer(chore.current_assignment_id, timeEntryId);
|
|
};
|
|
|
|
const handleAssignChore = async (userId: number) => {
|
|
if (!selectedChore.value) return;
|
|
try {
|
|
await choreService.createAssignment({
|
|
chore_id: selectedChore.value.id,
|
|
assigned_to_user_id: userId,
|
|
due_date: selectedChore.value.next_due_date
|
|
});
|
|
notificationStore.addNotification({ message: 'Chore assigned successfully!', type: 'success' });
|
|
// Refresh assignments
|
|
selectedChoreAssignments.value = await choreService.getChoreAssignments(selectedChore.value.id);
|
|
await loadChores(); // Also reload all chores to update main list
|
|
} catch (error) {
|
|
console.error('Failed to assign chore:', error);
|
|
notificationStore.addNotification({ message: 'Failed to assign chore.', type: 'error' });
|
|
}
|
|
};
|
|
|
|
const handleUnassignChore = async (assignmentId: number) => {
|
|
if (!selectedChore.value) return;
|
|
try {
|
|
await choreService.deleteAssignment(assignmentId);
|
|
notificationStore.addNotification({ message: 'Chore unassigned successfully!', type: 'success' });
|
|
selectedChoreAssignments.value = await choreService.getChoreAssignments(selectedChore.value.id);
|
|
await loadChores();
|
|
} catch (error) {
|
|
console.error('Failed to unassign chore:', error);
|
|
notificationStore.addNotification({ message: 'Failed to unassign chore.', type: 'error' });
|
|
}
|
|
}
|
|
|
|
const isUserAssigned = (userId: number) => {
|
|
return selectedChoreAssignments.value.some(a => a.assigned_to_user_id === userId);
|
|
};
|
|
|
|
const getAssignmentIdForUser = (userId: number): number | null => {
|
|
const assignment = selectedChoreAssignments.value.find(a => a.assigned_to_user_id === userId);
|
|
return assignment ? assignment.id : null;
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div class="chores-page">
|
|
<!-- Guest Banner -->
|
|
<Transition name="slide-down">
|
|
<div v-if="isGuest" class="guest-banner">
|
|
<div class="guest-banner-content">
|
|
<span class="material-icons">info</span>
|
|
<p>
|
|
You're using a guest account.
|
|
<router-link to="/auth/signup" class="guest-link">Sign up</router-link>
|
|
to save your data permanently.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- Page Header - Only show if not embedded in group -->
|
|
<header v-if="!props.groupId" class="page-header">
|
|
<div class="header-content">
|
|
<h1 class="page-title">
|
|
{{ t('choresPage.title', 'Chores') }}
|
|
</h1>
|
|
<Button @click="openCreateChoreModal" class="add-chore-btn"
|
|
:class="{ 'btn-pulse': groupedChores.length === 0 }">
|
|
<BaseIcon name="heroicons:plus-20-solid" class="w-5 h-5" />
|
|
<span class="btn-text">{{ t('choresPage.addChore', 'Add Chore') }}</span>
|
|
</Button>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Quick Add Section -->
|
|
<section v-if="!props.groupId" class="quick-add-section">
|
|
<QuickChoreAdd />
|
|
</section>
|
|
|
|
<!-- Loading State -->
|
|
<div v-if="isLoading" class="loading-container">
|
|
<div class="skeleton-chores">
|
|
<div class="skeleton-header"></div>
|
|
<div class="skeleton-list">
|
|
<div v-for="i in 3" :key="i" class="skeleton-chore-item">
|
|
<div class="skeleton-avatar"></div>
|
|
<div class="skeleton-content">
|
|
<div class="skeleton-line"></div>
|
|
<div class="skeleton-line short"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div v-else-if="groupedChores.length === 0" class="empty-state">
|
|
<div class="empty-illustration">
|
|
<span class="material-icons">task_alt</span>
|
|
</div>
|
|
<h3 class="empty-title">{{ t('choresPage.empty.title', 'No Chores Yet') }}</h3>
|
|
<p class="empty-subtitle">{{ t('choresPage.empty.message', 'Get started by adding your first chore!') }}</p>
|
|
<Button @click="openCreateChoreModal" class="empty-cta">
|
|
<BaseIcon name="heroicons:plus-20-solid" class="w-5 h-5 mr-2" />
|
|
{{ t('choresPage.addFirstChore', 'Add First Chore') }}
|
|
</Button>
|
|
</div>
|
|
|
|
<!-- Chores List -->
|
|
<section v-else class="chores-section">
|
|
<div v-for="group in groupedChores" :key="group.date.toISOString()" class="date-group">
|
|
<div class="date-header">
|
|
<h2 class="date-title">{{ formatDateHeader(group.date) }}</h2>
|
|
<span class="chore-count">{{ group.chores.length }}</span>
|
|
</div>
|
|
|
|
<div class="chores-grid">
|
|
<ChoreItem v-for="chore in group.chores" :key="chore.id" :chore="chore"
|
|
:time-entries="chore.current_assignment_id ? choreStore.timeEntriesByAssignment[chore.current_assignment_id] || [] : []"
|
|
:active-timer="activeTimer" @toggle-completion="toggleCompletion" @edit="openEditChoreModal"
|
|
@delete="confirmDelete" @open-details="openChoreDetailModal" @open-history="openHistoryModal"
|
|
@start-timer="startTimer" @stop-timer="stopTimer" class="chore-card" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Simplified Chore Form Modal -->
|
|
<Dialog v-model="showChoreModal" class="chore-modal">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title">
|
|
{{ isEditing ? t('choresPage.editChore', 'Edit Chore') : t('choresPage.createChore', 'Create Chore') }}
|
|
</h3>
|
|
<button type="button" @click="showChoreModal = false" class="modal-close" aria-label="Close">
|
|
<BaseIcon name="heroicons:x-mark-20-solid" class="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<form @submit.prevent="handleFormSubmit" class="chore-form">
|
|
<!-- Essential Fields First -->
|
|
<div class="form-section">
|
|
<div class="form-field">
|
|
<label for="chore-name" class="form-label required">{{ t('choresPage.form.name', 'Name') }}</label>
|
|
<input id="chore-name" type="text" v-model="choreForm.name" required class="form-input"
|
|
:placeholder="t('choresPage.form.namePlaceholder', 'e.g., Take out trash')" autocomplete="off" />
|
|
</div>
|
|
|
|
<div class="form-field">
|
|
<label for="chore-date" class="form-label required">{{ t('choresPage.form.dueDate', 'Due Date') }}</label>
|
|
<input id="chore-date" type="date" v-model="choreForm.next_due_date" required class="form-input" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Progressive Disclosure - Advanced Options -->
|
|
<div class="form-section">
|
|
<button type="button" class="form-toggle" @click="showAdvancedOptions = !showAdvancedOptions"
|
|
:aria-expanded="showAdvancedOptions">
|
|
<span>{{ t('choresPage.form.advancedOptions', 'Advanced Options') }}</span>
|
|
<BaseIcon :name="showAdvancedOptions ? 'heroicons:chevron-up-20-solid' : 'heroicons:chevron-down-20-solid'"
|
|
class="w-5 h-5" />
|
|
</button>
|
|
|
|
<Transition name="expand">
|
|
<div v-if="showAdvancedOptions" class="advanced-options">
|
|
<!-- Description -->
|
|
<div class="form-field">
|
|
<label for="chore-desc" class="form-label">{{ t('choresPage.form.description', 'Description') }}</label>
|
|
<textarea id="chore-desc" v-model="choreForm.description" class="form-textarea"
|
|
:placeholder="t('choresPage.form.descriptionPlaceholder', 'Optional details or instructions')"
|
|
rows="3" />
|
|
</div>
|
|
|
|
<!-- Frequency -->
|
|
<div class="form-field">
|
|
<label class="form-label">{{ t('choresPage.form.frequency', 'Frequency') }}</label>
|
|
<Listbox v-model="choreForm.frequency">
|
|
<div class="listbox-container">
|
|
<ListboxButton class="listbox-button">
|
|
<span class="listbox-value">
|
|
{{frequencyOptions.find(f => f.value === choreForm.frequency)?.label}}
|
|
</span>
|
|
<BaseIcon name="heroicons:chevron-up-down-20-solid" class="listbox-icon" />
|
|
</ListboxButton>
|
|
<TransitionExpand>
|
|
<ListboxOptions class="listbox-options">
|
|
<ListboxOption v-for="option in frequencyOptions" :key="option.value" :value="option.value"
|
|
v-slot="{ active, selected }" class="listbox-option">
|
|
<span :class="{ 'font-semibold': selected }">{{ option.label }}</span>
|
|
<span v-if="selected" class="listbox-check">
|
|
<BaseIcon name="heroicons:check-20-solid" class="w-5 h-5" />
|
|
</span>
|
|
</ListboxOption>
|
|
</ListboxOptions>
|
|
</TransitionExpand>
|
|
</div>
|
|
</Listbox>
|
|
</div>
|
|
|
|
<!-- Custom Interval (only if custom frequency) -->
|
|
<Transition name="expand">
|
|
<div v-if="choreForm.frequency === 'custom'" class="form-field">
|
|
<label for="chore-interval" class="form-label">
|
|
{{ t('choresPage.form.interval', 'Interval (days)') }}
|
|
</label>
|
|
<input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days" min="1"
|
|
class="form-input" placeholder="7" />
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- Type Selector (simplified) -->
|
|
<div class="form-field">
|
|
<label class="form-label">{{ t('choresPage.form.type', 'Type') }}</label>
|
|
<div class="radio-group">
|
|
<label class="radio-option">
|
|
<input type="radio" v-model="choreForm.type" value="personal" class="radio-input" />
|
|
<span class="radio-label">Personal</span>
|
|
</label>
|
|
<label class="radio-option">
|
|
<input type="radio" v-model="choreForm.type" value="group" class="radio-input" />
|
|
<span class="radio-label">Group</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Group Selection (only if group type) -->
|
|
<Transition name="expand">
|
|
<div v-if="choreForm.type === 'group'" class="form-field">
|
|
<label class="form-label">{{ t('choresPage.form.assignGroup', 'Group') }}</label>
|
|
<Listbox v-model="choreForm.group_id">
|
|
<div class="listbox-container">
|
|
<ListboxButton class="listbox-button">
|
|
<span class="listbox-value">
|
|
{{groups.find(g => g.id === choreForm.group_id)?.name || 'Select Group'}}
|
|
</span>
|
|
<BaseIcon name="heroicons:chevron-up-down-20-solid" class="listbox-icon" />
|
|
</ListboxButton>
|
|
<TransitionExpand>
|
|
<ListboxOptions class="listbox-options">
|
|
<ListboxOption v-for="group in groups" :key="group.id" :value="group.id"
|
|
v-slot="{ active, selected }" class="listbox-option">
|
|
<span :class="{ 'font-semibold': selected }">{{ group.name }}</span>
|
|
<span v-if="selected" class="listbox-check">
|
|
<BaseIcon name="heroicons:check-20-solid" class="w-5 h-5" />
|
|
</span>
|
|
</ListboxOption>
|
|
</ListboxOptions>
|
|
</TransitionExpand>
|
|
</div>
|
|
</Listbox>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- Assignment (only for group chores) -->
|
|
<Transition name="expand">
|
|
<div v-if="choreForm.type === 'group' && choreForm.group_id" class="form-field">
|
|
<label class="form-label">{{ t('choresPage.form.assignTo', 'Assign To') }}</label>
|
|
<div v-if="loadingChoreFormMembers" class="loading-text">
|
|
Loading members...
|
|
</div>
|
|
<div v-else>
|
|
<Listbox v-model="choreForm.assigned_to_user_id">
|
|
<div class="listbox-container">
|
|
<ListboxButton class="listbox-button">
|
|
<span class="listbox-value">
|
|
{{choreForm.assigned_to_user_id
|
|
? (choreFormGroupMembers.find(m => m.id === choreForm.assigned_to_user_id)?.name ||
|
|
'Unknown')
|
|
: "Anyone can take this"
|
|
}}
|
|
</span>
|
|
<BaseIcon name="heroicons:chevron-up-down-20-solid" class="listbox-icon" />
|
|
</ListboxButton>
|
|
<TransitionExpand>
|
|
<ListboxOptions class="listbox-options">
|
|
<ListboxOption :value="null" v-slot="{ active, selected }" class="listbox-option">
|
|
<span :class="{ 'font-semibold': selected }">Anyone can take this</span>
|
|
<span v-if="selected" class="listbox-check">
|
|
<BaseIcon name="heroicons:check-20-solid" class="w-5 h-5" />
|
|
</span>
|
|
</ListboxOption>
|
|
<ListboxOption v-for="member in choreFormGroupMembers" :key="member.id" :value="member.id"
|
|
v-slot="{ active, selected }" class="listbox-option">
|
|
<span :class="{ 'font-semibold': selected }">{{ member.name || member.email }}</span>
|
|
<span v-if="selected" class="listbox-check">
|
|
<BaseIcon name="heroicons:check-20-solid" class="w-5 h-5" />
|
|
</span>
|
|
</ListboxOption>
|
|
</ListboxOptions>
|
|
</TransitionExpand>
|
|
</div>
|
|
</Listbox>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- Parent Chore (Subtask functionality) -->
|
|
<div v-if="availableParentChores.length > 0" class="form-field">
|
|
<label class="form-label">{{ t('choresPage.form.parentChore', 'Make this a subtask of') }}</label>
|
|
<Listbox v-model="choreForm.parent_chore_id">
|
|
<div class="listbox-container">
|
|
<ListboxButton class="listbox-button">
|
|
<span class="listbox-value">
|
|
{{choreForm.parent_chore_id
|
|
? (availableParentChores.find(p => p.id === choreForm.parent_chore_id)?.name)
|
|
: 'None - This is a main task'
|
|
}}
|
|
</span>
|
|
<BaseIcon name="heroicons:chevron-up-down-20-solid" class="listbox-icon" />
|
|
</ListboxButton>
|
|
<TransitionExpand>
|
|
<ListboxOptions class="listbox-options">
|
|
<ListboxOption :value="null" v-slot="{ active, selected }" class="listbox-option">
|
|
<span :class="{ 'font-semibold': selected }">None - This is a main task</span>
|
|
<span v-if="selected" class="listbox-check">
|
|
<BaseIcon name="heroicons:check-20-solid" class="w-5 h-5" />
|
|
</span>
|
|
</ListboxOption>
|
|
<ListboxOption v-for="parent in availableParentChores" :key="parent.id" :value="parent.id"
|
|
v-slot="{ active, selected }" class="listbox-option">
|
|
<span :class="{ 'font-semibold': selected }">{{ parent.name }}</span>
|
|
<span v-if="selected" class="listbox-check">
|
|
<BaseIcon name="heroicons:check-20-solid" class="w-5 h-5" />
|
|
</span>
|
|
</ListboxOption>
|
|
</ListboxOptions>
|
|
</TransitionExpand>
|
|
</div>
|
|
</Listbox>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
|
|
<!-- Form Actions -->
|
|
<div class="form-actions">
|
|
<Button variant="ghost" color="neutral" @click="showChoreModal = false">
|
|
{{ t('choresPage.form.cancel', 'Cancel') }}
|
|
</Button>
|
|
<Button type="submit" :disabled="!choreForm.name.trim()">
|
|
{{ isEditing ? t('choresPage.form.save', 'Save Changes') : t('choresPage.form.create', 'Create Chore') }}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Dialog>
|
|
|
|
<!-- Simplified Delete Confirmation -->
|
|
<Dialog v-model="showDeleteDialog" class="delete-modal">
|
|
<div class="delete-content">
|
|
<div class="delete-icon">
|
|
<span class="material-icons">warning</span>
|
|
</div>
|
|
<h3 class="delete-title">{{ t('choresPage.deleteConfirm.title', 'Delete Chore?') }}</h3>
|
|
<p class="delete-message">
|
|
{{ t('choresPage.deleteConfirm.message', 'This action cannot be undone.') }}
|
|
</p>
|
|
<div class="delete-actions">
|
|
<Button variant="ghost" color="neutral" @click="showDeleteDialog = false">
|
|
{{ t('choresPage.deleteConfirm.cancel', 'Cancel') }}
|
|
</Button>
|
|
<Button color="error" @click="deleteChore">
|
|
{{ t('choresPage.deleteConfirm.delete', 'Delete') }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
|
|
<!-- Chore Detail Sheet -->
|
|
<ChoreDetailSheet v-model="showChoreDetailModal" :chore="selectedChore" />
|
|
|
|
<!-- History Modal (Simplified) -->
|
|
<Dialog v-model="showHistoryModal" class="history-modal">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title">History: {{ selectedChore?.name }}</h3>
|
|
<button type="button" @click="showHistoryModal = false" class="modal-close" aria-label="Close">
|
|
<BaseIcon name="heroicons:x-mark-20-solid" class="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="history-content">
|
|
<div v-if="loadingHistory" class="loading-text">Loading history...</div>
|
|
<div v-else-if="!selectedChoreHistory.length" class="empty-history">
|
|
No history found for this chore.
|
|
</div>
|
|
<div v-else class="history-list">
|
|
<div v-for="entry in selectedChoreHistory" :key="entry.id" class="history-item">
|
|
<div class="history-main">{{ formatHistoryEntry(entry) }}</div>
|
|
<div class="history-time">{{ format(new Date(entry.timestamp), 'PPpp') }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
@import url('https://fonts.googleapis.com/icon?family=Material+Icons');
|
|
|
|
.chores-page {
|
|
min-height: 100vh;
|
|
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
|
padding: 1rem;
|
|
|
|
@media (min-width: 768px) {
|
|
padding: 1.5rem 2rem;
|
|
}
|
|
}
|
|
|
|
/* Guest Banner */
|
|
.guest-banner {
|
|
background: linear-gradient(135deg, #fef3c7, #fcd34d);
|
|
border: 1px solid #f59e0b;
|
|
border-radius: 12px;
|
|
padding: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.1);
|
|
}
|
|
|
|
.guest-banner-content {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
|
|
.material-icons {
|
|
color: #d97706;
|
|
font-size: 20px;
|
|
}
|
|
|
|
p {
|
|
margin: 0;
|
|
font-size: 0.9rem;
|
|
color: #92400e;
|
|
}
|
|
}
|
|
|
|
.guest-link {
|
|
color: #d97706;
|
|
font-weight: 600;
|
|
text-decoration: underline;
|
|
|
|
&:hover {
|
|
color: #92400e;
|
|
}
|
|
}
|
|
|
|
/* Page Header */
|
|
.page-header {
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.header-content {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.page-title {
|
|
font-size: 2rem;
|
|
font-weight: 700;
|
|
color: #1f2937;
|
|
margin: 0;
|
|
|
|
@media (min-width: 768px) {
|
|
font-size: 2.5rem;
|
|
}
|
|
}
|
|
|
|
.add-chore-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
transition: all 0.2s ease;
|
|
|
|
&.btn-pulse {
|
|
animation: pulse 2s infinite;
|
|
}
|
|
|
|
.btn-text {
|
|
@media (max-width: 640px) {
|
|
display: none;
|
|
}
|
|
}
|
|
}
|
|
|
|
@keyframes pulse {
|
|
|
|
0%,
|
|
100% {
|
|
transform: scale(1);
|
|
}
|
|
|
|
50% {
|
|
transform: scale(1.05);
|
|
}
|
|
}
|
|
|
|
/* Quick Add Section */
|
|
.quick-add-section {
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
/* Loading States */
|
|
.loading-container {
|
|
padding: 2rem 0;
|
|
}
|
|
|
|
.skeleton-chores {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.skeleton-header {
|
|
height: 2rem;
|
|
width: 200px;
|
|
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
|
background-size: 200% 100%;
|
|
animation: skeleton-shimmer 1.5s infinite;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.skeleton-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.skeleton-chore-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
padding: 1rem;
|
|
background: white;
|
|
border-radius: 12px;
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.skeleton-avatar {
|
|
width: 48px;
|
|
height: 48px;
|
|
border-radius: 50%;
|
|
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
|
background-size: 200% 100%;
|
|
animation: skeleton-shimmer 1.5s infinite;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.skeleton-content {
|
|
flex-grow: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.skeleton-line {
|
|
height: 1rem;
|
|
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
|
background-size: 200% 100%;
|
|
animation: skeleton-shimmer 1.5s infinite;
|
|
border-radius: 4px;
|
|
|
|
&.short {
|
|
width: 60%;
|
|
}
|
|
}
|
|
|
|
@keyframes skeleton-shimmer {
|
|
0% {
|
|
background-position: 200% 0;
|
|
}
|
|
|
|
100% {
|
|
background-position: -200% 0;
|
|
}
|
|
}
|
|
|
|
/* Empty State */
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 4rem 2rem;
|
|
max-width: 400px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.empty-illustration {
|
|
margin-bottom: 1.5rem;
|
|
|
|
.material-icons {
|
|
font-size: 4rem;
|
|
color: #d1d5db;
|
|
}
|
|
}
|
|
|
|
.empty-title {
|
|
font-size: 1.5rem;
|
|
font-weight: 600;
|
|
color: #374151;
|
|
margin: 0 0 0.5rem 0;
|
|
}
|
|
|
|
.empty-subtitle {
|
|
color: #6b7280;
|
|
margin: 0 0 2rem 0;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.empty-cta {
|
|
margin: 0 auto;
|
|
}
|
|
|
|
/* Chores Section */
|
|
.chores-section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2rem;
|
|
}
|
|
|
|
.date-group {
|
|
background: white;
|
|
border-radius: 16px;
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.date-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 1rem 1.5rem;
|
|
background: linear-gradient(135deg, #f8f9fa, #e9ecef);
|
|
border-bottom: 1px solid #e5e7eb;
|
|
}
|
|
|
|
.date-title {
|
|
font-size: 1.125rem;
|
|
font-weight: 600;
|
|
color: #374151;
|
|
margin: 0;
|
|
}
|
|
|
|
.chore-count {
|
|
background: #3b82f6;
|
|
color: white;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 12px;
|
|
min-width: 1.5rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.chores-grid {
|
|
padding: 1rem;
|
|
display: grid;
|
|
gap: 0.75rem;
|
|
|
|
@media (min-width: 768px) {
|
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
padding: 1.5rem;
|
|
gap: 1rem;
|
|
}
|
|
}
|
|
|
|
.chore-card {
|
|
transition: all 0.2s ease;
|
|
|
|
&:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
}
|
|
}
|
|
|
|
/* Modal Styles */
|
|
.chore-modal,
|
|
.delete-modal,
|
|
.history-modal {
|
|
.modal-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 1.5rem;
|
|
padding-bottom: 1rem;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
}
|
|
|
|
.modal-title {
|
|
font-size: 1.25rem;
|
|
font-weight: 600;
|
|
color: #111827;
|
|
margin: 0;
|
|
}
|
|
|
|
.modal-close {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 2rem;
|
|
height: 2rem;
|
|
border: none;
|
|
background: none;
|
|
color: #6b7280;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
|
|
&:hover {
|
|
background: #f3f4f6;
|
|
color: #374151;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Form Styles */
|
|
.chore-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.form-section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.form-field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.form-label {
|
|
font-size: 0.9rem;
|
|
font-weight: 500;
|
|
color: #374151;
|
|
|
|
&.required::after {
|
|
content: ' *';
|
|
color: #ef4444;
|
|
}
|
|
}
|
|
|
|
.form-input,
|
|
.form-textarea {
|
|
padding: 0.75rem;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 8px;
|
|
font-size: 0.9rem;
|
|
transition: all 0.2s ease;
|
|
background: white;
|
|
|
|
&:focus {
|
|
outline: none;
|
|
border-color: #3b82f6;
|
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
|
}
|
|
|
|
&::placeholder {
|
|
color: #9ca3af;
|
|
}
|
|
}
|
|
|
|
.form-textarea {
|
|
resize: vertical;
|
|
min-height: 80px;
|
|
}
|
|
|
|
/* Progressive Disclosure Toggle */
|
|
.form-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
width: 100%;
|
|
padding: 0.75rem 1rem;
|
|
background: #f8f9fa;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
font-size: 0.9rem;
|
|
font-weight: 500;
|
|
color: #374151;
|
|
|
|
&:hover {
|
|
background: #f3f4f6;
|
|
border-color: #d1d5db;
|
|
}
|
|
|
|
&[aria-expanded="true"] {
|
|
background: #eff6ff;
|
|
border-color: #3b82f6;
|
|
color: #1d4ed8;
|
|
}
|
|
}
|
|
|
|
.advanced-options {
|
|
padding-top: 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
/* Radio Group */
|
|
.radio-group {
|
|
display: flex;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.radio-option {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.radio-input {
|
|
width: 1rem;
|
|
height: 1rem;
|
|
accent-color: #3b82f6;
|
|
}
|
|
|
|
.radio-label {
|
|
font-size: 0.9rem;
|
|
color: #374151;
|
|
}
|
|
|
|
/* Listbox Styles */
|
|
.listbox-container {
|
|
position: relative;
|
|
}
|
|
|
|
.listbox-button {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
width: 100%;
|
|
padding: 0.75rem;
|
|
background: white;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
|
|
&:hover {
|
|
border-color: #9ca3af;
|
|
}
|
|
|
|
&:focus {
|
|
outline: none;
|
|
border-color: #3b82f6;
|
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
|
}
|
|
}
|
|
|
|
.listbox-value {
|
|
font-size: 0.9rem;
|
|
color: #374151;
|
|
text-align: left;
|
|
}
|
|
|
|
.listbox-icon {
|
|
width: 1.25rem;
|
|
height: 1.25rem;
|
|
color: #6b7280;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.listbox-options {
|
|
position: absolute;
|
|
top: calc(100% + 4px);
|
|
left: 0;
|
|
right: 0;
|
|
background: white;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
|
z-index: 50;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.listbox-option {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.75rem;
|
|
cursor: pointer;
|
|
transition: background-color 0.2s ease;
|
|
font-size: 0.9rem;
|
|
|
|
&:hover {
|
|
background: #f8f9fa;
|
|
}
|
|
|
|
&:first-child {
|
|
border-top-left-radius: 8px;
|
|
border-top-right-radius: 8px;
|
|
}
|
|
|
|
&:last-child {
|
|
border-bottom-left-radius: 8px;
|
|
border-bottom-right-radius: 8px;
|
|
}
|
|
}
|
|
|
|
.listbox-check {
|
|
color: #3b82f6;
|
|
width: 1.25rem;
|
|
height: 1.25rem;
|
|
}
|
|
|
|
.loading-text {
|
|
color: #6b7280;
|
|
font-size: 0.9rem;
|
|
text-align: center;
|
|
padding: 1rem;
|
|
}
|
|
|
|
/* Form Actions */
|
|
.form-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 0.75rem;
|
|
padding-top: 1rem;
|
|
border-top: 1px solid #e5e7eb;
|
|
|
|
@media (max-width: 640px) {
|
|
flex-direction: column-reverse;
|
|
|
|
button {
|
|
width: 100%;
|
|
justify-content: center;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Delete Modal */
|
|
.delete-content {
|
|
text-align: center;
|
|
padding: 1rem 0;
|
|
}
|
|
|
|
.delete-icon {
|
|
margin-bottom: 1rem;
|
|
|
|
.material-icons {
|
|
font-size: 3rem;
|
|
color: #ef4444;
|
|
}
|
|
}
|
|
|
|
.delete-title {
|
|
font-size: 1.25rem;
|
|
font-weight: 600;
|
|
color: #111827;
|
|
margin: 0 0 0.5rem 0;
|
|
}
|
|
|
|
.delete-message {
|
|
color: #6b7280;
|
|
margin: 0 0 2rem 0;
|
|
}
|
|
|
|
.delete-actions {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 0.75rem;
|
|
|
|
@media (max-width: 640px) {
|
|
flex-direction: column-reverse;
|
|
|
|
button {
|
|
width: 100%;
|
|
justify-content: center;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* History Modal */
|
|
.history-content {
|
|
max-height: 60vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.empty-history {
|
|
text-align: center;
|
|
color: #6b7280;
|
|
padding: 2rem;
|
|
}
|
|
|
|
.history-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.history-item {
|
|
padding: 0.75rem;
|
|
background: #f8f9fa;
|
|
border-radius: 8px;
|
|
border-left: 3px solid #3b82f6;
|
|
}
|
|
|
|
.history-main {
|
|
font-size: 0.9rem;
|
|
color: #374151;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.history-time {
|
|
font-size: 0.75rem;
|
|
color: #6b7280;
|
|
}
|
|
|
|
/* Transitions */
|
|
.slide-down-enter-active,
|
|
.slide-down-leave-active {
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.slide-down-enter-from,
|
|
.slide-down-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(-10px);
|
|
}
|
|
|
|
.expand-enter-active,
|
|
.expand-leave-active {
|
|
transition: all 0.3s ease;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.expand-enter-from,
|
|
.expand-leave-to {
|
|
opacity: 0;
|
|
max-height: 0;
|
|
padding-top: 0;
|
|
}
|
|
|
|
.expand-enter-to,
|
|
.expand-leave-from {
|
|
opacity: 1;
|
|
max-height: 500px;
|
|
padding-top: 1rem;
|
|
}
|
|
|
|
/* Dark mode support */
|
|
@media (prefers-color-scheme: dark) {
|
|
.chores-page {
|
|
background: linear-gradient(135deg, #1f2937 0%, #111827 100%);
|
|
}
|
|
|
|
.date-group,
|
|
.form-input,
|
|
.form-textarea,
|
|
.listbox-button,
|
|
.listbox-options {
|
|
background: #374151;
|
|
border-color: #4b5563;
|
|
color: #f9fafb;
|
|
}
|
|
|
|
.page-title,
|
|
.date-title,
|
|
.modal-title,
|
|
.form-label {
|
|
color: #f9fafb;
|
|
}
|
|
|
|
.skeleton-header,
|
|
.skeleton-line,
|
|
.skeleton-avatar {
|
|
background: linear-gradient(90deg, #374151 25%, #4b5563 50%, #374151 75%);
|
|
background-size: 200% 100%;
|
|
}
|
|
}
|
|
|
|
/* Accessibility */
|
|
@media (prefers-reduced-motion: reduce) {
|
|
* {
|
|
animation-duration: 0.01ms !important;
|
|
animation-iteration-count: 1 !important;
|
|
transition-duration: 0.01ms !important;
|
|
}
|
|
}
|
|
</style>
|