add workload placement

This commit is contained in:
Yisroel Baum 2026-08-19 09:06:56 +03:00
parent 465db4a5b8
commit 46ba265f38
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
8 changed files with 291 additions and 34 deletions

View file

@ -6,6 +6,8 @@ import { API_BASE } from '@/utils/apiBase'
const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
export const workloadPlacementSchema = z.enum(['start', 'middle', 'end'])
export const scheduleSummarySchema = z.object({
id: z.number().int().positive(),
set: z.object({
@ -76,11 +78,13 @@ const errorResponseSchema = z.object({
export type ScheduleSummary = z.infer<typeof scheduleSummarySchema>
export type ScheduleDetail = z.infer<typeof scheduleDetailSchema>
export type AssignmentForDate = z.infer<typeof assignmentForDateSchema>
export type WorkloadPlacement = z.infer<typeof workloadPlacementSchema>
export type CreateScheduleInput = {
setId: number
levelId: number
startDate: string
targetDate: string
workloadPlacement: WorkloadPlacement
}
const LIST_ERROR = "We couldn't load your schedules."

View file

@ -5,7 +5,11 @@ import { useRoute, useRouter } from 'vue-router'
import { z } from 'zod'
import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue'
import { useSchedulesStore } from '@/stores/schedules'
import {
useSchedulesStore,
workloadPlacementSchema,
type WorkloadPlacement,
} from '@/stores/schedules'
import { useSetLayoutStore } from '@/stores/setLayout'
type ScheduleField = 'levelId' | 'startDate' | 'targetDate'
@ -22,18 +26,51 @@ const form = reactive({
levelId: null as number | null,
startDate: '',
targetDate: '',
workloadPlacement: 'middle' as WorkloadPlacement,
})
const fieldErrors = ref<ScheduleFieldErrors>({})
const levels = computed(() =>
(layout.value?.levels ?? []).filter((level) => level.elementCount > 0),
)
const selectedLevel = computed(() => levels.value.find((level) => level.id === form.levelId))
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 showWorkloadPlacement = computed(() => {
if (
selectedLevel.value === undefined ||
form.startDate === '' ||
form.targetDate === '' ||
form.targetDate < form.startDate
) {
return false
}
const startTime = Date.parse(`${form.startDate}T00:00:00Z`)
const targetTime = Date.parse(`${form.targetDate}T00:00:00Z`)
if (Number.isNaN(startTime) || Number.isNaN(targetTime)) {
return false
}
const millisecondsPerDay = 24 * 60 * 60 * 1000
const dayCount = Math.round((targetTime - startTime) / millisecondsPerDay) + 1
const assignmentCount = selectedLevel.value.elementCount
return assignmentCount > dayCount && assignmentCount % dayCount !== 0
})
const scheduleFormSchema = z
.object({
levelId: z.number('Choose a level to schedule.').int().positive(),
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) {
@ -194,6 +231,33 @@ async function retry(): Promise<void> {
</div>
</div>
<fieldset
v-if="showWorkloadPlacement"
class="workload-placement"
data-workload-placement
:disabled="creating"
>
<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="workloadPlacement"
:value="option.value"
/>
<span>{{ option.label }}</span>
</label>
</div>
</fieldset>
<p v-if="createError !== null" class="form-error" role="alert">{{ createError }}</p>
<button type="submit" class="primary-button" :disabled="creating">
@ -290,6 +354,57 @@ h1 {
gap: 1rem;
}
.workload-placement {
display: grid;
gap: 0.75rem;
margin: 0;
padding: 0;
border: 0;
}
.workload-placement legend {
padding: 0;
font-size: 0.78rem;
font-weight: 800;
}
.workload-placement > p {
margin: 0;
color: #68776f;
font-size: 0.8rem;
line-height: 1.5;
}
.workload-placement__options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem;
}
.workload-placement__option {
display: flex;
align-items: center;
gap: 0.55rem;
min-height: 3rem;
padding: 0.7rem 0.8rem;
border: 1px solid rgb(24 58 49 / 22%);
border-radius: 0.7rem;
background: #fffdf7;
cursor: pointer;
}
.workload-placement__option:has(input:checked) {
border-color: #183a31;
box-shadow: 0 0 0 1px #183a31;
}
.workload-placement__option input {
width: auto;
min-height: 0;
margin: 0;
padding: 0;
}
label {
font-size: 0.78rem;
font-weight: 800;
@ -380,5 +495,9 @@ input[aria-invalid='true'] {
.date-fields {
grid-template-columns: 1fr;
}
.workload-placement__options {
grid-template-columns: 1fr;
}
}
</style>