All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m0s
- Add backend routines management with add, get, update, delete, and list functionalities. - Create models for Routine, RoutineItem, RoutineSchedule, and RoutineExtension. - Develop event types for routine confirmation and modification. - Implement frontend components for routine assignment, confirmation dialog, and routine management views. - Add unit tests for routine API and integration tests for routine CRUD flow. - Create end-to-end test plan for routines feature covering parent and child interactions.
777 lines
21 KiB
Vue
777 lines
21 KiB
Vue
<template>
|
||
<ModalDialog :image-url="entity.image_url" :title="scheduleTitle" :subtitle="entity.name">
|
||
<!-- Enable/disable toggle row -->
|
||
<div class="schedule-toggle-row">
|
||
<span class="toggle-label">{{ scheduleEnabled ? 'Enabled' : 'Paused' }}</span>
|
||
<button
|
||
type="button"
|
||
class="toggle-track"
|
||
:class="{ on: scheduleEnabled }"
|
||
role="switch"
|
||
:aria-checked="scheduleEnabled"
|
||
@click="scheduleEnabled = !scheduleEnabled"
|
||
>
|
||
<span class="toggle-thumb" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Form body dims when paused -->
|
||
<div class="schedule-body" :class="{ disabled: !scheduleEnabled }">
|
||
<!-- Mode toggle -->
|
||
<div class="mode-toggle">
|
||
<button :class="['mode-btn', { active: mode === 'days' }]" @click="mode = 'days'">
|
||
Specific Days
|
||
</button>
|
||
<button :class="['mode-btn', { active: mode === 'interval' }]" @click="mode = 'interval'">
|
||
Every X Days
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Specific Days form -->
|
||
<div v-if="mode === 'days'" class="days-form">
|
||
<!-- Day chips -->
|
||
<div class="day-chips">
|
||
<button
|
||
v-for="(label, idx) in DAY_CHIP_LABELS"
|
||
:key="idx"
|
||
type="button"
|
||
:class="['chip', { active: selectedDays.has(idx) }]"
|
||
@click="toggleDay(idx)"
|
||
>
|
||
{{ label }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Default deadline -->
|
||
<div v-if="selectedDays.size > 0" class="default-deadline-row">
|
||
<span class="field-label">Deadline</span>
|
||
<TimePickerPopover
|
||
v-if="hasDefaultDeadline"
|
||
:modelValue="defaultTime"
|
||
@update:modelValue="onDefaultTimeChanged"
|
||
/>
|
||
<span v-else class="anytime-label">Anytime</span>
|
||
<button type="button" class="link-btn" @click="hasDefaultDeadline = !hasDefaultDeadline">
|
||
{{ hasDefaultDeadline ? 'Clear (Anytime)' : 'Set deadline' }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Selected day exception list -->
|
||
<div v-if="selectedDays.size > 0" class="exception-list">
|
||
<div v-for="idx in sortedSelectedDays" :key="idx" class="exception-row">
|
||
<span class="exception-day-name">{{ DAY_LABELS[idx] }}</span>
|
||
<div class="exception-right">
|
||
<template v-if="exceptions.has(idx)">
|
||
<TimePickerPopover
|
||
:modelValue="exceptions.get(idx)!"
|
||
@update:modelValue="(val) => exceptions.set(idx, val)"
|
||
/>
|
||
<button type="button" class="link-btn reset-btn" @click="exceptions.delete(idx)">
|
||
Reset to default
|
||
</button>
|
||
</template>
|
||
<template v-else>
|
||
<span class="default-label">{{
|
||
hasDefaultDeadline ? formatDefaultTime : 'Anytime'
|
||
}}</span>
|
||
<button
|
||
type="button"
|
||
class="link-btn"
|
||
@click="exceptions.set(idx, { ...defaultTime })"
|
||
>
|
||
Set different time
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Interval form -->
|
||
<div v-else class="interval-form">
|
||
<!-- Frequency stepper + anchor date -->
|
||
<div class="interval-group">
|
||
<div class="interval-row">
|
||
<label class="field-label">Every</label>
|
||
<div class="stepper">
|
||
<button
|
||
type="button"
|
||
class="stepper-btn"
|
||
@click="decrementInterval"
|
||
:disabled="intervalDays <= 1"
|
||
>
|
||
−
|
||
</button>
|
||
<span class="stepper-value">{{ intervalDays }}</span>
|
||
<button
|
||
type="button"
|
||
class="stepper-btn"
|
||
@click="incrementInterval"
|
||
:disabled="intervalDays >= 7"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
<span class="field-label">{{ intervalDays === 1 ? 'day' : 'days' }}</span>
|
||
</div>
|
||
<div class="interval-row">
|
||
<label class="field-label">Starting on</label>
|
||
<DateInputField
|
||
:modelValue="anchorDate"
|
||
:min="todayISO"
|
||
@update:modelValue="anchorDate = $event"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Next occurrence preview -->
|
||
<div v-if="nextOccurrence" class="next-occurrence-row">
|
||
<span class="next-occurrence-label">Next occurrence: {{ nextOccurrence }}</span>
|
||
</div>
|
||
|
||
<!-- Deadline row -->
|
||
<div class="interval-time-row">
|
||
<label class="field-label">Deadline</label>
|
||
<TimePickerPopover
|
||
v-if="hasDeadline"
|
||
:modelValue="intervalTime"
|
||
@update:modelValue="intervalTime = $event"
|
||
/>
|
||
<span v-else class="anytime-label">Anytime</span>
|
||
<button type="button" class="link-btn" @click="toggleDeadline">
|
||
{{ hasDeadline ? 'Clear (Anytime)' : 'Set deadline' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
|
||
</div>
|
||
<!-- /schedule-body -->
|
||
|
||
<!-- Actions outside dim wrapper so they stay interactive -->
|
||
<div class="modal-actions">
|
||
<button class="btn btn-secondary" @click="$emit('cancelled')" :disabled="saving">
|
||
Cancel
|
||
</button>
|
||
<button class="btn-primary" @click="save" :disabled="saving || !isValid || !isDirty">
|
||
{{ saving ? 'Saving…' : 'Save' }}
|
||
</button>
|
||
</div>
|
||
</ModalDialog>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed } from 'vue'
|
||
import ModalDialog from './ModalDialog.vue'
|
||
import TimePickerPopover from './TimePickerPopover.vue'
|
||
import DateInputField from './DateInputField.vue'
|
||
import {
|
||
setChoreSchedule,
|
||
deleteChoreSchedule,
|
||
setRoutineSchedule,
|
||
deleteRoutineSchedule,
|
||
parseErrorResponse,
|
||
} from '@/common/api'
|
||
import type {
|
||
ChildTask,
|
||
ChildRoutine,
|
||
ChoreSchedule,
|
||
RoutineSchedule,
|
||
DayConfig,
|
||
} from '@/common/models'
|
||
|
||
interface TimeValue {
|
||
hour: number
|
||
minute: number
|
||
}
|
||
|
||
const props = defineProps<{
|
||
entity: ChildTask | ChildRoutine
|
||
entityType: 'task' | 'routine'
|
||
childId: string
|
||
schedule: ChoreSchedule | RoutineSchedule | null
|
||
}>()
|
||
|
||
const scheduleTitle = computed(() =>
|
||
props.entityType === 'task' ? 'Schedule Chore' : 'Schedule Routine',
|
||
)
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'saved'): void
|
||
(e: 'cancelled'): void
|
||
}>()
|
||
|
||
const DAY_LABELS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||
const DAY_CHIP_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
|
||
|
||
// ── local state ──────────────────────────────────────────────────────────────
|
||
|
||
const mode = ref<'days' | 'interval'>(props.schedule?.mode ?? 'days')
|
||
|
||
// days mode — three-layer state
|
||
function initDaysState(schedule: ChoreSchedule | null): {
|
||
days: Set<number>
|
||
base: TimeValue
|
||
exMap: Map<number, TimeValue>
|
||
} {
|
||
const days = new Set<number>()
|
||
const exMap = new Map<number, TimeValue>()
|
||
let base: TimeValue = { hour: 8, minute: 0 }
|
||
|
||
if (schedule?.mode === 'days') {
|
||
// Load the persisted default time (falls back to 8:00 AM for old records)
|
||
base = { hour: schedule.default_hour ?? 8, minute: schedule.default_minute ?? 0 }
|
||
for (const dc of schedule.day_configs) {
|
||
days.add(dc.day)
|
||
if (dc.hour !== base.hour || dc.minute !== base.minute) {
|
||
exMap.set(dc.day, { hour: dc.hour, minute: dc.minute })
|
||
}
|
||
}
|
||
}
|
||
|
||
return { days, base, exMap }
|
||
}
|
||
|
||
const { days: _days, base: _base, exMap: _exMap } = initDaysState(props.schedule)
|
||
const selectedDays = ref<Set<number>>(_days)
|
||
const defaultTime = ref<TimeValue>(_base)
|
||
const exceptions = ref<Map<number, TimeValue>>(_exMap)
|
||
const hasDefaultDeadline = ref<boolean>(props.schedule?.default_has_deadline ?? true)
|
||
const scheduleEnabled = ref<boolean>(props.schedule?.enabled ?? true)
|
||
|
||
// ── helpers (date) ───────────────────────────────────────────────────────────
|
||
|
||
function getTodayISO(): string {
|
||
const d = new Date()
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||
}
|
||
|
||
// interval mode
|
||
const intervalDays = ref<number>(Math.max(1, props.schedule?.interval_days ?? 1))
|
||
const anchorDate = ref<string>(props.schedule?.anchor_date || getTodayISO())
|
||
const hasDeadline = ref<boolean>(props.schedule?.interval_has_deadline ?? true)
|
||
const intervalTime = ref<TimeValue>({
|
||
hour: props.schedule?.interval_hour ?? 8,
|
||
minute: props.schedule?.interval_minute ?? 0,
|
||
})
|
||
|
||
const saving = ref(false)
|
||
const errorMsg = ref<string | null>(null)
|
||
|
||
// ── original snapshot (for dirty detection) ──────────────────────────────────
|
||
|
||
const origMode = props.schedule?.mode ?? 'days'
|
||
const origDays = new Set(_days)
|
||
const origDefaultTime: TimeValue = { ..._base }
|
||
const origHasDefaultDeadline = props.schedule?.default_has_deadline ?? true
|
||
const origExceptions = new Map<number, TimeValue>(
|
||
Array.from(_exMap.entries()).map(([k, v]) => [k, { ...v }]),
|
||
)
|
||
const origIntervalDays = Math.max(1, props.schedule?.interval_days ?? 1)
|
||
const origAnchorDate = props.schedule?.anchor_date || getTodayISO()
|
||
const origHasDeadline = props.schedule?.interval_has_deadline ?? true
|
||
const origIntervalTime: TimeValue = {
|
||
hour: props.schedule?.interval_hour ?? 8,
|
||
minute: props.schedule?.interval_minute ?? 0,
|
||
}
|
||
const origEnabled = props.schedule?.enabled ?? true
|
||
|
||
// ── computed ─────────────────────────────────────────────────────────────────
|
||
|
||
const sortedSelectedDays = computed(() => Array.from(selectedDays.value).sort((a, b) => a - b))
|
||
|
||
const todayISO = getTodayISO()
|
||
|
||
const nextOccurrence = computed((): string | null => {
|
||
if (!anchorDate.value) return null
|
||
const [y, m, d] = anchorDate.value.split('-').map(Number)
|
||
const anchor = new Date(y, m - 1, d, 0, 0, 0, 0)
|
||
if (isNaN(anchor.getTime())) return null
|
||
const today = new Date()
|
||
today.setHours(0, 0, 0, 0)
|
||
|
||
const fmt = (dt: Date) =>
|
||
dt.toLocaleDateString('en-US', {
|
||
weekday: 'short',
|
||
month: 'short',
|
||
day: 'numeric',
|
||
year: 'numeric',
|
||
})
|
||
|
||
// Find the first hit date strictly after today
|
||
for (let i = 1; i <= 365; i++) {
|
||
const cur = new Date(anchor.getTime() + i * 86_400_000)
|
||
const diffDays = Math.round((cur.getTime() - anchor.getTime()) / 86_400_000)
|
||
if (diffDays % intervalDays.value === 0 && cur >= today) {
|
||
return fmt(cur)
|
||
}
|
||
}
|
||
return null
|
||
})
|
||
|
||
const formatDefaultTime = computed(() => {
|
||
const h = defaultTime.value.hour % 12 || 12
|
||
const m = String(defaultTime.value.minute).padStart(2, '0')
|
||
const period = defaultTime.value.hour < 12 ? 'AM' : 'PM'
|
||
return `${String(h).padStart(2, '0')}:${m} ${period}`
|
||
})
|
||
|
||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function toggleDay(dayIdx: number) {
|
||
if (selectedDays.value.has(dayIdx)) {
|
||
selectedDays.value.delete(dayIdx)
|
||
exceptions.value.delete(dayIdx)
|
||
// trigger reactivity
|
||
selectedDays.value = new Set(selectedDays.value)
|
||
} else {
|
||
const next = new Set(selectedDays.value)
|
||
next.add(dayIdx)
|
||
selectedDays.value = next
|
||
}
|
||
}
|
||
|
||
function onDefaultTimeChanged(val: TimeValue) {
|
||
defaultTime.value = val
|
||
}
|
||
|
||
function decrementInterval() {
|
||
if (intervalDays.value > 1) intervalDays.value--
|
||
}
|
||
|
||
function incrementInterval() {
|
||
if (intervalDays.value < 7) intervalDays.value++
|
||
}
|
||
|
||
function toggleDeadline() {
|
||
hasDeadline.value = !hasDeadline.value
|
||
}
|
||
|
||
// ── validation + dirty ───────────────────────────────────────────────────────
|
||
|
||
const isValid = computed(() => {
|
||
// days mode: 0 selections is valid — means "unschedule" (always active)
|
||
if (mode.value === 'interval') {
|
||
return intervalDays.value >= 1 && intervalDays.value <= 7
|
||
}
|
||
return true
|
||
})
|
||
|
||
const isDirty = computed(() => {
|
||
if (scheduleEnabled.value !== origEnabled) return true
|
||
if (mode.value !== origMode) return true
|
||
|
||
if (mode.value === 'days') {
|
||
// Selected days set changed
|
||
if (selectedDays.value.size !== origDays.size) return true
|
||
for (const d of selectedDays.value) {
|
||
if (!origDays.has(d)) return true
|
||
}
|
||
// Default time changed
|
||
if (
|
||
defaultTime.value.hour !== origDefaultTime.hour ||
|
||
defaultTime.value.minute !== origDefaultTime.minute
|
||
)
|
||
return true
|
||
// Default deadline toggled
|
||
if (hasDefaultDeadline.value !== origHasDefaultDeadline) return true
|
||
// Exceptions changed
|
||
if (exceptions.value.size !== origExceptions.size) return true
|
||
for (const [day, t] of exceptions.value) {
|
||
const orig = origExceptions.get(day)
|
||
if (!orig || orig.hour !== t.hour || orig.minute !== t.minute) return true
|
||
}
|
||
return false
|
||
} else {
|
||
if (intervalDays.value !== origIntervalDays) return true
|
||
if (anchorDate.value !== origAnchorDate) return true
|
||
if (hasDeadline.value !== origHasDeadline) return true
|
||
if (
|
||
hasDeadline.value &&
|
||
(intervalTime.value.hour !== origIntervalTime.hour ||
|
||
intervalTime.value.minute !== origIntervalTime.minute)
|
||
)
|
||
return true
|
||
return false
|
||
}
|
||
})
|
||
|
||
// ── save ─────────────────────────────────────────────────────────────────────
|
||
|
||
async function save() {
|
||
if (!isValid.value || !isDirty.value || saving.value) return
|
||
errorMsg.value = null
|
||
saving.value = true
|
||
|
||
const entityId = props.entity.id
|
||
const saveEntitySchedule = (payload: object) =>
|
||
props.entityType === 'task'
|
||
? setChoreSchedule(props.childId, entityId, payload)
|
||
: setRoutineSchedule(props.childId, entityId, payload)
|
||
const deleteEntitySchedule = () =>
|
||
props.entityType === 'task'
|
||
? deleteChoreSchedule(props.childId, entityId)
|
||
: deleteRoutineSchedule(props.childId, entityId)
|
||
|
||
let res: Response
|
||
if (mode.value === 'days' && selectedDays.value.size === 0) {
|
||
// 0 days = remove schedule entirely so the item becomes always active
|
||
res = await deleteEntitySchedule()
|
||
} else if (mode.value === 'days') {
|
||
const day_configs: DayConfig[] = Array.from(selectedDays.value).map((day) => {
|
||
const override = exceptions.value.get(day)
|
||
return {
|
||
day,
|
||
hour: override?.hour ?? defaultTime.value.hour,
|
||
minute: override?.minute ?? defaultTime.value.minute,
|
||
}
|
||
})
|
||
res = await saveEntitySchedule({
|
||
mode: 'days',
|
||
enabled: scheduleEnabled.value,
|
||
day_configs,
|
||
default_hour: defaultTime.value.hour,
|
||
default_minute: defaultTime.value.minute,
|
||
default_has_deadline: hasDefaultDeadline.value,
|
||
})
|
||
} else {
|
||
res = await saveEntitySchedule({
|
||
mode: 'interval',
|
||
enabled: scheduleEnabled.value,
|
||
interval_days: intervalDays.value,
|
||
anchor_date: anchorDate.value,
|
||
interval_has_deadline: hasDeadline.value,
|
||
interval_hour: intervalTime.value.hour,
|
||
interval_minute: intervalTime.value.minute,
|
||
})
|
||
}
|
||
saving.value = false
|
||
|
||
if (res.ok) {
|
||
emit('saved')
|
||
} else {
|
||
const { msg } = await parseErrorResponse(res)
|
||
errorMsg.value = msg
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.mode-toggle {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
margin-bottom: 1.2rem;
|
||
}
|
||
|
||
.mode-btn {
|
||
flex: 1;
|
||
padding: 0.55rem 0.4rem;
|
||
border: 1.5px solid var(--primary, #667eea);
|
||
border-radius: 6px;
|
||
background: transparent;
|
||
color: var(--primary, #667eea);
|
||
font-weight: 600;
|
||
font-size: 0.9rem;
|
||
cursor: pointer;
|
||
transition:
|
||
background 0.15s,
|
||
color 0.15s;
|
||
}
|
||
|
||
.mode-btn.active {
|
||
background: var(--btn-primary, #667eea);
|
||
color: #fff;
|
||
}
|
||
|
||
/* Days form */
|
||
.days-form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.day-chips {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.chip {
|
||
min-width: 2.4rem;
|
||
padding: 0.4rem 0.5rem;
|
||
border: 1.5px solid var(--primary, #667eea);
|
||
border-radius: 999px;
|
||
background: transparent;
|
||
color: var(--primary, #667eea);
|
||
font-size: 0.85rem;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
text-align: center;
|
||
transition:
|
||
background 0.15s,
|
||
color 0.15s;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.chip:hover {
|
||
background: color-mix(in srgb, var(--btn-primary, #667eea) 12%, transparent);
|
||
}
|
||
|
||
.chip.active {
|
||
background: var(--btn-primary, #667eea);
|
||
color: #fff;
|
||
}
|
||
|
||
.default-deadline-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.exception-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.55rem;
|
||
}
|
||
|
||
.exception-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
}
|
||
|
||
.exception-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.exception-day-name {
|
||
font-size: 0.9rem;
|
||
font-weight: 700;
|
||
color: var(--secondary, #7257b3);
|
||
min-width: 75px;
|
||
}
|
||
|
||
.default-label {
|
||
font-size: 0.85rem;
|
||
color: var(--form-label, #888);
|
||
font-style: italic;
|
||
}
|
||
|
||
.link-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
background: none;
|
||
border: none;
|
||
padding: 0.6rem 0.5rem;
|
||
margin: -0.6rem -0.5rem;
|
||
min-height: 44px;
|
||
cursor: pointer;
|
||
font-size: 0.82rem;
|
||
font-weight: 600;
|
||
color: var(--primary, #667eea);
|
||
text-decoration: underline;
|
||
font-family: inherit;
|
||
transition: opacity 0.12s;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.link-btn:hover {
|
||
opacity: 0.75;
|
||
}
|
||
|
||
.reset-btn {
|
||
color: var(--error, #e53e3e);
|
||
}
|
||
|
||
/* Interval form */
|
||
.interval-form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.interval-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.4rem;
|
||
}
|
||
|
||
.interval-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
}
|
||
|
||
.interval-time-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.field-label {
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
color: var(--secondary, #7257b3);
|
||
}
|
||
|
||
.stepper {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0;
|
||
border: 1.5px solid var(--primary, #667eea);
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.stepper-btn {
|
||
min-width: 2.75rem;
|
||
min-height: 2.75rem;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--primary, #667eea);
|
||
font-size: 1.1rem;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
transition: background 0.12s;
|
||
font-family: inherit;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.stepper-btn:hover:not(:disabled) {
|
||
background: color-mix(in srgb, var(--btn-primary, #667eea) 12%, transparent);
|
||
}
|
||
|
||
.stepper-btn:disabled {
|
||
opacity: 0.35;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.stepper-value {
|
||
min-width: 1.6rem;
|
||
text-align: center;
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
color: var(--secondary, #7257b3);
|
||
padding: 0 0.25rem;
|
||
}
|
||
|
||
.anytime-label {
|
||
font-size: 0.85rem;
|
||
color: var(--form-label, #888);
|
||
font-style: italic;
|
||
}
|
||
|
||
.next-occurrence-row {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.next-occurrence-label {
|
||
font-size: 0.85rem;
|
||
color: var(--form-label, #888);
|
||
font-style: italic;
|
||
}
|
||
|
||
/* Error */
|
||
.error-msg {
|
||
color: var(--error, #e53e3e);
|
||
font-size: 0.9rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
/* Actions */
|
||
.modal-actions {
|
||
display: flex;
|
||
gap: 1rem;
|
||
justify-content: center;
|
||
margin-top: 1rem;
|
||
}
|
||
|
||
.btn-primary {
|
||
background: var(--btn-primary, #667eea);
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 8px;
|
||
padding: 0.55rem 1.4rem;
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.btn-primary:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
/* ── Enable/Disable Toggle ────────────────────── */
|
||
|
||
.schedule-toggle-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 0.6rem;
|
||
margin-bottom: 1rem;
|
||
min-height: 44px;
|
||
}
|
||
|
||
.toggle-label {
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
color: var(--form-label, #444);
|
||
user-select: none;
|
||
}
|
||
|
||
.toggle-track {
|
||
position: relative;
|
||
width: 48px;
|
||
height: 24px;
|
||
border-radius: 999px;
|
||
border: none;
|
||
background: var(--form-input-border, #cbd5e1);
|
||
cursor: pointer;
|
||
transition: background 0.2s ease;
|
||
padding: 0;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.toggle-track.on {
|
||
background: var(--btn-primary, #667eea);
|
||
}
|
||
|
||
.toggle-thumb {
|
||
position: absolute;
|
||
top: 2px;
|
||
left: 2px;
|
||
width: 20px;
|
||
height: 20px;
|
||
border-radius: 50%;
|
||
background: #fff;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
|
||
transition: transform 0.2s ease;
|
||
}
|
||
|
||
.toggle-track.on .toggle-thumb {
|
||
transform: translateX(24px);
|
||
}
|
||
|
||
/* ── Dim wrapper when paused ──────────────────── */
|
||
|
||
.schedule-body {
|
||
transition: opacity 0.2s ease;
|
||
}
|
||
|
||
.schedule-body.disabled {
|
||
opacity: 0.45;
|
||
pointer-events: none;
|
||
}
|
||
</style>
|