add reschedule form
This commit is contained in:
parent
3a34249e0e
commit
db6b41ee42
3 changed files with 552 additions and 6 deletions
358
frontend/website/src/components/ScheduleRescheduleForm.vue
Normal file
358
frontend/website/src/components/ScheduleRescheduleForm.vue
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
workloadPlacementSchema,
|
||||
type RescheduleScheduleInput,
|
||||
type WorkloadPlacement,
|
||||
} from '@/stores/schedules'
|
||||
|
||||
type RescheduleField = 'startDate' | 'targetDate'
|
||||
type RescheduleFieldErrors = Partial<Record<RescheduleField, string>>
|
||||
|
||||
const props = defineProps<{
|
||||
currentTargetDate: string
|
||||
remainingAssignmentCount: number
|
||||
serverError: string | null
|
||||
submitting: boolean
|
||||
today: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
cancel: []
|
||||
submit: [input: RescheduleScheduleInput]
|
||||
}>()
|
||||
const form = reactive({
|
||||
startDate: props.today,
|
||||
targetDate: props.currentTargetDate < props.today ? props.today : props.currentTargetDate,
|
||||
workloadPlacement: 'middle' as WorkloadPlacement,
|
||||
})
|
||||
const fieldErrors = ref<RescheduleFieldErrors>({})
|
||||
const workloadPlacementOptions: Array<{
|
||||
value: WorkloadPlacement
|
||||
label: string
|
||||
}> = [
|
||||
{ value: 'start', label: 'At the start' },
|
||||
{ value: 'middle', label: 'In the middle' },
|
||||
{ value: 'end', label: 'At the end' },
|
||||
]
|
||||
const formSchema = z
|
||||
.object({
|
||||
startDate: z.string().min(1, 'Choose a start date.'),
|
||||
targetDate: z.string().min(1, 'Choose a target date.'),
|
||||
workloadPlacement: workloadPlacementSchema,
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.startDate !== '' && value.targetDate !== '' && value.targetDate < value.startDate) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['targetDate'],
|
||||
message: 'Target date cannot be before the start date.',
|
||||
})
|
||||
}
|
||||
})
|
||||
const showWorkloadPlacement = computed(() => {
|
||||
if (form.startDate === '' || form.targetDate === '' || form.targetDate < form.startDate) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dayCount = inclusiveDayCount(form.startDate, form.targetDate)
|
||||
|
||||
return (
|
||||
dayCount > 0 &&
|
||||
props.remainingAssignmentCount > dayCount &&
|
||||
props.remainingAssignmentCount % dayCount !== 0
|
||||
)
|
||||
})
|
||||
|
||||
function inclusiveDayCount(startDate: string, targetDate: string): number {
|
||||
const startTime = Date.parse(`${startDate}T00:00:00Z`)
|
||||
const targetTime = Date.parse(`${targetDate}T00:00:00Z`)
|
||||
if (Number.isNaN(startTime) || Number.isNaN(targetTime) || targetTime < startTime) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const millisecondsPerDay = 24 * 60 * 60 * 1000
|
||||
|
||||
return Math.round((targetTime - startTime) / millisecondsPerDay) + 1
|
||||
}
|
||||
|
||||
function submit(): void {
|
||||
fieldErrors.value = {}
|
||||
const result = formSchema.safeParse(form)
|
||||
if (!result.success) {
|
||||
const errors: RescheduleFieldErrors = {}
|
||||
for (const issue of result.error.issues) {
|
||||
const field = issue.path[0]
|
||||
if ((field === 'startDate' || field === 'targetDate') && errors[field] === undefined) {
|
||||
errors[field] = issue.message
|
||||
}
|
||||
}
|
||||
fieldErrors.value = errors
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
emit('submit', result.data)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="reschedule-panel" class="reschedule-panel" aria-labelledby="reschedule-heading">
|
||||
<header>
|
||||
<p class="reschedule-panel__eyebrow">Adjust your plan</p>
|
||||
<h2 id="reschedule-heading">Reschedule remaining assignments</h2>
|
||||
<p>
|
||||
Completed work stays on its original date. Only the
|
||||
{{ remainingAssignmentCount }} unfinished
|
||||
{{ remainingAssignmentCount === 1 ? 'assignment' : 'assignments' }}
|
||||
will move.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form data-reschedule-form novalidate @submit.prevent="submit">
|
||||
<div class="reschedule-date-fields">
|
||||
<div class="reschedule-field">
|
||||
<label for="reschedule-start-date">Start date</label>
|
||||
<input
|
||||
id="reschedule-start-date"
|
||||
v-model="form.startDate"
|
||||
type="date"
|
||||
:aria-invalid="fieldErrors.startDate !== undefined"
|
||||
:aria-describedby="fieldErrors.startDate ? 'reschedule-start-date-error' : undefined"
|
||||
:disabled="submitting"
|
||||
/>
|
||||
<p v-if="fieldErrors.startDate" id="reschedule-start-date-error" class="field-error">
|
||||
{{ fieldErrors.startDate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="reschedule-field">
|
||||
<label for="reschedule-target-date">Target date</label>
|
||||
<input
|
||||
id="reschedule-target-date"
|
||||
v-model="form.targetDate"
|
||||
type="date"
|
||||
:aria-invalid="fieldErrors.targetDate !== undefined"
|
||||
:aria-describedby="fieldErrors.targetDate ? 'reschedule-target-date-error' : undefined"
|
||||
:disabled="submitting"
|
||||
/>
|
||||
<p v-if="fieldErrors.targetDate" id="reschedule-target-date-error" class="field-error">
|
||||
{{ fieldErrors.targetDate }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset
|
||||
v-if="showWorkloadPlacement"
|
||||
class="workload-placement"
|
||||
data-workload-placement
|
||||
:disabled="submitting"
|
||||
>
|
||||
<legend>Heavier days</legend>
|
||||
<p>Some days need one extra assignment. Choose where those days appear in the schedule.</p>
|
||||
<div class="workload-placement__options">
|
||||
<label
|
||||
v-for="option in workloadPlacementOptions"
|
||||
:key="option.value"
|
||||
class="workload-placement__option"
|
||||
>
|
||||
<input
|
||||
v-model="form.workloadPlacement"
|
||||
type="radio"
|
||||
name="rescheduleWorkloadPlacement"
|
||||
:value="option.value"
|
||||
/>
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<p v-if="serverError !== null" class="form-error" role="alert">
|
||||
{{ serverError }}
|
||||
</p>
|
||||
|
||||
<div class="reschedule-actions">
|
||||
<button type="submit" class="primary-button" :disabled="submitting">
|
||||
{{ submitting ? 'Rescheduling...' : 'Reschedule assignments' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-button"
|
||||
:disabled="submitting"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reschedule-panel {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
padding: clamp(1.3rem, 4vw, 2rem);
|
||||
border: 1px solid rgb(24 48 41 / 12%);
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 253 247 / 82%);
|
||||
box-shadow: 0 1rem 2.5rem rgb(40 62 52 / 8%);
|
||||
}
|
||||
|
||||
.reschedule-panel__eyebrow {
|
||||
margin: 0 0 0.45rem;
|
||||
color: #926044;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.reschedule-panel h2 {
|
||||
margin: 0;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: clamp(1.75rem, 4vw, 2.5rem);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.reschedule-panel header > p:last-child {
|
||||
max-width: 44rem;
|
||||
margin: 0.75rem 0 0;
|
||||
color: #68776f;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.reschedule-panel form {
|
||||
display: grid;
|
||||
gap: 1.35rem;
|
||||
}
|
||||
|
||||
.reschedule-date-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.reschedule-field {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.reschedule-field label,
|
||||
.workload-placement legend {
|
||||
color: #344e45;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.reschedule-field input {
|
||||
min-width: 0;
|
||||
min-height: 2.8rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid rgb(24 48 41 / 22%);
|
||||
border-radius: 0.65rem;
|
||||
color: #183029;
|
||||
background: #fffdf7;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.field-error,
|
||||
.form-error {
|
||||
margin: 0;
|
||||
color: #9b3f32;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.workload-placement {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgb(24 48 41 / 12%);
|
||||
border-radius: 0.8rem;
|
||||
}
|
||||
|
||||
.workload-placement > p {
|
||||
margin: 0;
|
||||
color: #68776f;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.workload-placement__options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.workload-placement__option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid rgb(24 48 41 / 16%);
|
||||
border-radius: 999px;
|
||||
color: #344e45;
|
||||
background: #f9f5e9;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 750;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reschedule-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button {
|
||||
min-height: 2.75rem;
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 0.7rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
border: 1px solid #285c4e;
|
||||
color: #fffdf7;
|
||||
background: #285c4e;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
border: 1px solid rgb(24 58 49 / 28%);
|
||||
color: #183a31;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.reschedule-field input:focus-visible,
|
||||
.workload-placement__option input:focus-visible,
|
||||
.primary-button:focus-visible,
|
||||
.secondary-button:focus-visible {
|
||||
outline: 3px solid rgb(86 127 112 / 34%);
|
||||
outline-offset: 0.25rem;
|
||||
}
|
||||
|
||||
.primary-button:disabled,
|
||||
.secondary-button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
@media (max-width: 37.5rem) {
|
||||
.reschedule-date-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.reschedule-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -86,10 +86,16 @@ export type CreateScheduleInput = {
|
|||
targetDate: string
|
||||
workloadPlacement: WorkloadPlacement
|
||||
}
|
||||
export type RescheduleScheduleInput = {
|
||||
startDate: string
|
||||
targetDate: string
|
||||
workloadPlacement: WorkloadPlacement
|
||||
}
|
||||
|
||||
const LIST_ERROR = "We couldn't load your schedules."
|
||||
const DETAIL_ERROR = "We couldn't load this schedule."
|
||||
const CREATE_ERROR = "We couldn't create this schedule."
|
||||
const RESCHEDULE_ERROR = "We couldn't reschedule this schedule."
|
||||
const ASSIGNMENTS_ERROR = "We couldn't load today's assignments."
|
||||
const COMPLETION_ERROR = "We couldn't update this assignment."
|
||||
|
||||
|
|
@ -103,6 +109,8 @@ export const useSchedulesStore = defineStore('schedules', () => {
|
|||
const detailNotFound = ref(false)
|
||||
const creating = ref(false)
|
||||
const createError = ref<string | null>(null)
|
||||
const rescheduling = ref(false)
|
||||
const rescheduleError = ref<string | null>(null)
|
||||
const assignmentsForDate = ref<AssignmentForDate[]>([])
|
||||
const assignmentsLoading = ref(false)
|
||||
const assignmentsError = ref<string | null>(null)
|
||||
|
|
@ -241,6 +249,56 @@ export const useSchedulesStore = defineStore('schedules', () => {
|
|||
}
|
||||
}
|
||||
|
||||
async function rescheduleSchedule(
|
||||
scheduleId: number,
|
||||
input: RescheduleScheduleInput,
|
||||
): Promise<ScheduleDetail | null> {
|
||||
rescheduling.value = true
|
||||
rescheduleError.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/schedules/${scheduleId}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
const responseBody: unknown = await response.json()
|
||||
|
||||
if (response.status !== 200) {
|
||||
const parsedError = errorResponseSchema.safeParse(responseBody)
|
||||
rescheduleError.value = parsedError.success ? parsedError.data.error : RESCHEDULE_ERROR
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const rescheduled = scheduleResponseSchema.parse(responseBody).schedule
|
||||
if (rescheduled.id !== scheduleId) {
|
||||
throw new Error('schedule response did not match requested schedule')
|
||||
}
|
||||
activeSchedule.value = rescheduled
|
||||
const summary = scheduleSummarySchema.parse(rescheduled)
|
||||
schedules.value = schedules.value.map((schedule) =>
|
||||
schedule.id === scheduleId ? summary : schedule,
|
||||
)
|
||||
|
||||
return rescheduled
|
||||
} catch {
|
||||
rescheduleError.value = RESCHEDULE_ERROR
|
||||
|
||||
return null
|
||||
} finally {
|
||||
rescheduling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearRescheduleError(): void {
|
||||
rescheduleError.value = null
|
||||
}
|
||||
|
||||
async function fetchAssignmentsForDate(date: string): Promise<boolean> {
|
||||
const requestId = ++assignmentsRequestId
|
||||
assignmentsForDate.value = []
|
||||
|
|
@ -381,6 +439,8 @@ export const useSchedulesStore = defineStore('schedules', () => {
|
|||
detailNotFound,
|
||||
creating,
|
||||
createError,
|
||||
rescheduling,
|
||||
rescheduleError,
|
||||
assignmentsForDate,
|
||||
assignmentsLoading,
|
||||
assignmentsError,
|
||||
|
|
@ -389,6 +449,8 @@ export const useSchedulesStore = defineStore('schedules', () => {
|
|||
fetchSchedules,
|
||||
fetchSchedule,
|
||||
createSchedule,
|
||||
rescheduleSchedule,
|
||||
clearRescheduleError,
|
||||
fetchAssignmentsForDate,
|
||||
setAssignmentCompleted,
|
||||
isAssignmentCompletionPending,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@ import { useRoute } from 'vue-router'
|
|||
|
||||
import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue'
|
||||
import ScheduleAssignmentTimeline from '@/components/ScheduleAssignmentTimeline.vue'
|
||||
import { useSchedulesStore, type ScheduleDetail } from '@/stores/schedules'
|
||||
import ScheduleRescheduleForm from '@/components/ScheduleRescheduleForm.vue'
|
||||
import {
|
||||
useSchedulesStore,
|
||||
type RescheduleScheduleInput,
|
||||
type ScheduleDetail,
|
||||
} from '@/stores/schedules'
|
||||
|
||||
type ScheduleDay = ScheduleDetail['days'][number]
|
||||
|
||||
|
|
@ -18,9 +23,13 @@ const {
|
|||
detailLoading,
|
||||
detailError,
|
||||
detailNotFound,
|
||||
rescheduling,
|
||||
rescheduleError,
|
||||
} = storeToRefs(schedulesStore)
|
||||
const currentScheduleId = ref<number | null>(null)
|
||||
const completionAnnouncement = ref('')
|
||||
const rescheduleAnnouncement = ref('')
|
||||
const rescheduleFormOpen = ref(false)
|
||||
const todayDate = browserDate(new Date())
|
||||
|
||||
const remainingDays = computed<ScheduleDay[]>(() => {
|
||||
|
|
@ -28,9 +37,12 @@ const remainingDays = computed<ScheduleDay[]>(() => {
|
|||
return []
|
||||
}
|
||||
|
||||
return activeSchedule.value.days.flatMap((day) => {
|
||||
const schedule = activeSchedule.value
|
||||
|
||||
return schedule.days.flatMap((day) => {
|
||||
const assignments = day.assignments.filter((assignment) => assignment.completedAt === null)
|
||||
const isRestDay = day.assignments.length === 0
|
||||
const isActiveDate = day.date >= schedule.startDate && day.date <= schedule.targetDate
|
||||
const isRestDay = isActiveDate && day.assignments.length === 0
|
||||
|
||||
return assignments.length > 0 || isRestDay ? [{ date: day.date, assignments }] : []
|
||||
})
|
||||
|
|
@ -50,6 +62,13 @@ const completedDays = computed<ScheduleDay[]>(() => {
|
|||
|
||||
const remainingAssignmentCount = computed(() => assignmentCount(remainingDays.value))
|
||||
const completedAssignmentCount = computed(() => assignmentCount(completedDays.value))
|
||||
const activeDayCount = computed(() => {
|
||||
if (activeSchedule.value === null) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return inclusiveDayCount(activeSchedule.value.startDate, activeSchedule.value.targetDate)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.params.scheduleId,
|
||||
|
|
@ -59,6 +78,9 @@ watch(
|
|||
: scheduleIdParameter
|
||||
const scheduleId = Number(rawScheduleId)
|
||||
currentScheduleId.value = scheduleId
|
||||
rescheduleFormOpen.value = false
|
||||
rescheduleAnnouncement.value = ''
|
||||
schedulesStore.clearRescheduleError()
|
||||
|
||||
if (activeSchedule.value?.id !== scheduleId) {
|
||||
await schedulesStore.fetchSchedule(scheduleId)
|
||||
|
|
@ -92,6 +114,48 @@ function assignmentCount(days: ScheduleDay[]): number {
|
|||
return days.reduce((count, day) => count + day.assignments.length, 0)
|
||||
}
|
||||
|
||||
function inclusiveDayCount(startDate: string, targetDate: string): number {
|
||||
const startTime = Date.parse(`${startDate}T00:00:00Z`)
|
||||
const targetTime = Date.parse(`${targetDate}T00:00:00Z`)
|
||||
if (Number.isNaN(startTime) || Number.isNaN(targetTime) || targetTime < startTime) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const millisecondsPerDay = 24 * 60 * 60 * 1000
|
||||
|
||||
return Math.round((targetTime - startTime) / millisecondsPerDay) + 1
|
||||
}
|
||||
|
||||
function openRescheduleForm(): void {
|
||||
if (activeSchedule.value === null) {
|
||||
return
|
||||
}
|
||||
|
||||
rescheduleAnnouncement.value = ''
|
||||
schedulesStore.clearRescheduleError()
|
||||
rescheduleFormOpen.value = true
|
||||
}
|
||||
|
||||
function cancelReschedule(): void {
|
||||
schedulesStore.clearRescheduleError()
|
||||
rescheduleFormOpen.value = false
|
||||
}
|
||||
|
||||
async function submitReschedule(input: RescheduleScheduleInput): Promise<void> {
|
||||
if (currentScheduleId.value === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const rescheduledAssignmentCount = remainingAssignmentCount.value
|
||||
const schedule = await schedulesStore.rescheduleSchedule(currentScheduleId.value, input)
|
||||
if (schedule !== null) {
|
||||
rescheduleFormOpen.value = false
|
||||
rescheduleAnnouncement.value = `${rescheduledAssignmentCount} remaining ${
|
||||
rescheduledAssignmentCount === 1 ? 'assignment' : 'assignments'
|
||||
} rescheduled.`
|
||||
}
|
||||
}
|
||||
|
||||
async function setAssignmentCompleted(assignmentId: number, completed: boolean): Promise<void> {
|
||||
completionAnnouncement.value = ''
|
||||
const updated = await schedulesStore.setAssignmentCompleted(assignmentId, completed)
|
||||
|
|
@ -136,15 +200,47 @@ async function setAssignmentCompleted(assignmentId: number, completed: boolean):
|
|||
{{ activeSchedule.assignmentCount }}
|
||||
{{ activeSchedule.elementKind }}
|
||||
{{ activeSchedule.assignmentCount === 1 ? 'assignment' : 'assignments' }} across
|
||||
{{ activeSchedule.days.length }}
|
||||
{{ activeSchedule.days.length === 1 ? 'day' : 'days' }}
|
||||
{{ activeDayCount }}
|
||||
{{ activeDayCount === 1 ? 'day' : 'days' }}
|
||||
</p>
|
||||
<p class="schedule-range">
|
||||
{{ formatDate(activeSchedule.startDate) }} to
|
||||
{{ formatDate(activeSchedule.targetDate) }}
|
||||
</p>
|
||||
<button
|
||||
v-if="remainingAssignmentCount > 0"
|
||||
type="button"
|
||||
class="reschedule-toggle"
|
||||
data-reschedule-toggle
|
||||
:aria-expanded="rescheduleFormOpen"
|
||||
aria-controls="reschedule-panel"
|
||||
:disabled="rescheduling"
|
||||
@click="rescheduleFormOpen ? cancelReschedule() : openRescheduleForm()"
|
||||
>
|
||||
{{ rescheduleFormOpen ? 'Close rescheduling' : 'Reschedule remaining' }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p
|
||||
class="reschedule-announcement"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-reschedule-announcement
|
||||
>
|
||||
{{ rescheduleAnnouncement }}
|
||||
</p>
|
||||
|
||||
<ScheduleRescheduleForm
|
||||
v-if="rescheduleFormOpen"
|
||||
:current-target-date="activeSchedule.targetDate"
|
||||
:remaining-assignment-count="remainingAssignmentCount"
|
||||
:server-error="rescheduleError"
|
||||
:submitting="rescheduling"
|
||||
:today="todayDate"
|
||||
@cancel="cancelReschedule"
|
||||
@submit="submitReschedule"
|
||||
/>
|
||||
|
||||
<p
|
||||
class="completion-announcement"
|
||||
role="status"
|
||||
|
|
@ -252,7 +348,8 @@ async function setAssignmentCompleted(assignmentId: number, completed: boolean):
|
|||
}
|
||||
|
||||
.back-link:focus-visible,
|
||||
.retry-button:focus-visible {
|
||||
.retry-button:focus-visible,
|
||||
.reschedule-toggle:focus-visible {
|
||||
outline: 3px solid rgb(86 127 112 / 34%);
|
||||
outline-offset: 0.25rem;
|
||||
}
|
||||
|
|
@ -294,6 +391,35 @@ h1 {
|
|||
color: #68776f;
|
||||
}
|
||||
|
||||
.reschedule-toggle {
|
||||
min-height: 2.65rem;
|
||||
margin-top: 1.25rem;
|
||||
padding: 0.65rem 1rem;
|
||||
border: 1px solid rgb(24 58 49 / 28%);
|
||||
border-radius: 0.7rem;
|
||||
color: #183a31;
|
||||
background: #fffdf7;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 750;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reschedule-toggle:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.reschedule-announcement:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reschedule-announcement {
|
||||
margin: 1rem 0 0;
|
||||
color: #285c4e;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.completion-announcement {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue