feat: remove and reorder meal types in weekly planner
- MealPlanGrid: edit-mode toggle (visible when >1 meal type) reveals
per-row ↑/↓ reorder and ✕ remove controls; label column expands to
auto width via CSS class swap
- mealPlan store: removeMealType() and reorderMealTypes() — both send
the full updated array to the existing PATCH /meal-plans/{id} endpoint
- MealPlanView: wires remove-meal-type and reorder-meal-types emits;
shares mealTypeAdding loading flag to disable controls during save
- Guard: remove disabled when only one type remains (always keep ≥1)
This commit is contained in:
parent
6aa63cf2f0
commit
23a2e8fe38
3 changed files with 126 additions and 10 deletions
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
<div v-show="!collapsed" class="grid-body">
|
||||
<!-- Day headers -->
|
||||
<div class="day-headers">
|
||||
<div class="day-headers" :class="{ 'headers-editing': editing }">
|
||||
<div class="meal-type-col-spacer" />
|
||||
<div
|
||||
v-for="(day, i) in DAY_LABELS"
|
||||
|
|
@ -26,11 +26,35 @@
|
|||
|
||||
<!-- One row per meal type -->
|
||||
<div
|
||||
v-for="mealType in activeMealTypes"
|
||||
v-for="(mealType, idx) in activeMealTypes"
|
||||
:key="mealType"
|
||||
class="meal-row"
|
||||
:class="{ 'row-editing': editing }"
|
||||
>
|
||||
<div class="meal-type-label">{{ mealType }}</div>
|
||||
<div class="meal-type-label" :class="{ 'label-editing': editing }">
|
||||
<template v-if="editing">
|
||||
<button
|
||||
class="reorder-btn"
|
||||
:disabled="idx === 0 || mealTypeChanging"
|
||||
aria-label="Move up"
|
||||
@click="onMoveUp(idx)"
|
||||
>↑</button>
|
||||
<button
|
||||
class="reorder-btn"
|
||||
:disabled="idx === activeMealTypes.length - 1 || mealTypeChanging"
|
||||
aria-label="Move down"
|
||||
@click="onMoveDown(idx)"
|
||||
>↓</button>
|
||||
<span class="label-text">{{ mealType }}</span>
|
||||
<button
|
||||
class="remove-btn"
|
||||
:disabled="activeMealTypes.length <= 1 || mealTypeChanging"
|
||||
:aria-label="`Remove ${mealType}`"
|
||||
@click="$emit('remove-meal-type', mealType)"
|
||||
>✕</button>
|
||||
</template>
|
||||
<template v-else>{{ mealType }}</template>
|
||||
</div>
|
||||
<button
|
||||
v-for="dayIndex in 7"
|
||||
:key="dayIndex - 1"
|
||||
|
|
@ -46,11 +70,17 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add meal type row (Paid only) -->
|
||||
<div v-if="canAddMealType" class="add-meal-type-row">
|
||||
<button class="add-meal-type-btn" @click="$emit('add-meal-type')">
|
||||
<!-- Add / edit meal type controls (Paid only) -->
|
||||
<div v-if="canAddMealType || activeMealTypes.length > 1" class="add-meal-type-row">
|
||||
<button v-if="canAddMealType && !editing" class="add-meal-type-btn" @click="$emit('add-meal-type')">
|
||||
+ Add meal type
|
||||
</button>
|
||||
<button
|
||||
v-if="activeMealTypes.length > 1"
|
||||
class="edit-types-btn"
|
||||
:class="{ active: editing }"
|
||||
@click="editing = !editing"
|
||||
>{{ editing ? 'Done' : 'Edit types' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -60,22 +90,44 @@
|
|||
import { ref } from 'vue'
|
||||
import { useMealPlanStore } from '../stores/mealPlan'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
activeMealTypes: string[]
|
||||
canAddMealType: boolean
|
||||
mealTypeChanging?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
(e: 'slot-click', payload: { dayOfWeek: number; mealType: string }): void
|
||||
(e: 'add-meal-type'): void
|
||||
(e: 'remove-meal-type', mealType: string): void
|
||||
(e: 'reorder-meal-types', newOrder: string[]): void
|
||||
}>()
|
||||
|
||||
const store = useMealPlanStore()
|
||||
const { getSlot } = store
|
||||
|
||||
const collapsed = ref(false)
|
||||
const editing = ref(false)
|
||||
|
||||
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
|
||||
function onMoveUp(idx: number) {
|
||||
if (idx === 0) return
|
||||
const arr = [...props.activeMealTypes]
|
||||
const tmp = arr[idx - 1]!
|
||||
arr[idx - 1] = arr[idx]!
|
||||
arr[idx] = tmp
|
||||
emit('reorder-meal-types', arr)
|
||||
}
|
||||
|
||||
function onMoveDown(idx: number) {
|
||||
if (idx === props.activeMealTypes.length - 1) return
|
||||
const arr = [...props.activeMealTypes]
|
||||
const tmp = arr[idx]!
|
||||
arr[idx] = arr[idx + 1]!
|
||||
arr[idx + 1] = tmp
|
||||
emit('reorder-meal-types', arr)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -117,10 +169,38 @@ const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
|||
.slot-title { text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; color: var(--color-text); }
|
||||
.slot-empty { opacity: 0.25; font-size: 1rem; }
|
||||
|
||||
.add-meal-type-row { padding: 0.4rem 0 0.2rem; }
|
||||
.add-meal-type-row { padding: 0.4rem 0 0.2rem; display: flex; gap: 0.75rem; align-items: center; }
|
||||
.add-meal-type-btn { font-size: 0.75rem; background: none; border: none; cursor: pointer; color: var(--color-accent); padding: 0; }
|
||||
.edit-types-btn {
|
||||
font-size: 0.75rem; background: none; border: none; cursor: pointer;
|
||||
color: var(--color-text-secondary); padding: 0;
|
||||
}
|
||||
.edit-types-btn:hover { color: var(--color-text); }
|
||||
.edit-types-btn.active { color: var(--color-accent); font-weight: 600; }
|
||||
|
||||
/* Edit mode — expand label column to fit controls */
|
||||
.row-editing, .headers-editing { grid-template-columns: auto repeat(7, 1fr); }
|
||||
.label-editing {
|
||||
flex-direction: row; align-items: center; gap: 2px;
|
||||
opacity: 1; white-space: nowrap;
|
||||
}
|
||||
.label-text { flex: 1; font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; font-weight: 600; padding: 0 2px; }
|
||||
.reorder-btn {
|
||||
background: none; border: 1px solid var(--color-border); border-radius: 3px;
|
||||
cursor: pointer; font-size: 0.6rem; padding: 1px 3px; line-height: 1;
|
||||
color: var(--color-text-secondary); min-width: 16px;
|
||||
}
|
||||
.reorder-btn:hover:not(:disabled) { border-color: var(--color-accent); color: var(--color-accent); }
|
||||
.reorder-btn:disabled { opacity: 0.25; cursor: default; }
|
||||
.remove-btn {
|
||||
background: none; border: none; cursor: pointer; font-size: 0.65rem;
|
||||
color: var(--color-text-secondary); padding: 1px 3px; border-radius: 3px; line-height: 1;
|
||||
}
|
||||
.remove-btn:hover:not(:disabled) { color: var(--color-error, #e05252); background: var(--color-error-subtle, #fef2f2); }
|
||||
.remove-btn:disabled { opacity: 0.25; cursor: default; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.day-headers, .meal-row { grid-template-columns: 2.5rem repeat(7, 1fr); }
|
||||
.row-editing, .headers-editing { grid-template-columns: auto repeat(7, 1fr); }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -25,8 +25,11 @@
|
|||
<MealPlanGrid
|
||||
:active-meal-types="activePlan.meal_types"
|
||||
:can-add-meal-type="canAddMealType"
|
||||
:meal-type-changing="mealTypeAdding"
|
||||
@slot-click="onSlotClick"
|
||||
@add-meal-type="onAddMealType"
|
||||
@remove-meal-type="onRemoveMealType"
|
||||
@reorder-meal-types="onReorderMealTypes"
|
||||
/>
|
||||
|
||||
<!-- Slot editor panel -->
|
||||
|
|
@ -273,6 +276,24 @@ async function onPickMealType(mealType: string) {
|
|||
mealTypeAdding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemoveMealType(mealType: string) {
|
||||
mealTypeAdding.value = true
|
||||
try {
|
||||
await store.removeMealType(mealType)
|
||||
} finally {
|
||||
mealTypeAdding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onReorderMealTypes(newOrder: string[]) {
|
||||
mealTypeAdding.value = true
|
||||
try {
|
||||
await store.reorderMealTypes(newOrder)
|
||||
} finally {
|
||||
mealTypeAdding.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -132,6 +132,20 @@ export const useMealPlanStore = defineStore('mealPlan', () => {
|
|||
activePlan.value = updated
|
||||
}
|
||||
|
||||
async function removeMealType(mealType: string): Promise<void> {
|
||||
if (!activePlan.value) return
|
||||
const next = activePlan.value.meal_types.filter(t => t !== mealType)
|
||||
if (next.length === 0) return // always keep at least one
|
||||
const updated = await mealPlanAPI.updateMealTypes(activePlan.value.id, next)
|
||||
activePlan.value = updated
|
||||
}
|
||||
|
||||
async function reorderMealTypes(newOrder: string[]): Promise<void> {
|
||||
if (!activePlan.value) return
|
||||
const updated = await mealPlanAPI.updateMealTypes(activePlan.value.id, newOrder)
|
||||
activePlan.value = updated
|
||||
}
|
||||
|
||||
async function updatePrepTask(taskId: number, data: Partial<Pick<PrepTask, 'duration_minutes' | 'sequence_order' | 'notes' | 'equipment'>>): Promise<void> {
|
||||
if (!activePlan.value || !prepSession.value) return
|
||||
const updated = await mealPlanAPI.updatePrepTask(activePlan.value.id, taskId, data)
|
||||
|
|
@ -152,6 +166,7 @@ export const useMealPlanStore = defineStore('mealPlan', () => {
|
|||
plans, activePlan, shoppingList, prepSession,
|
||||
loading, shoppingListLoading, prepLoading, slots,
|
||||
getSlot, loadPlans, autoSelectPlan, createPlan, setActivePlan,
|
||||
addMealType, upsertSlot, clearSlot, loadShoppingList, loadPrepSession, updatePrepTask,
|
||||
addMealType, removeMealType, reorderMealTypes,
|
||||
upsertSlot, clearSlot, loadShoppingList, loadPrepSession, updatePrepTask,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue