feat(interviews): add MoveToSheet bottom sheet / dialog component
This commit is contained in:
parent
d29ee1b7f8
commit
0394366b1a
1 changed files with 174 additions and 0 deletions
174
web/src/components/MoveToSheet.vue
Normal file
174
web/src/components/MoveToSheet.vue
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { STAGE_LABELS, PIPELINE_STAGES } from '../stores/interviews'
|
||||||
|
import type { PipelineStage } from '../stores/interviews'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
currentStatus: string
|
||||||
|
jobTitle: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
move: [stage: PipelineStage, opts: { interview_date?: string; rejection_stage?: string }]
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const selectedStage = ref<PipelineStage | null>(null)
|
||||||
|
const interviewDate = ref('')
|
||||||
|
const rejectionStage = ref('')
|
||||||
|
const focusIndex = ref(0)
|
||||||
|
const firstOptionEl = ref<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
|
const stages = computed(() =>
|
||||||
|
PIPELINE_STAGES.filter(s => s !== props.currentStatus)
|
||||||
|
)
|
||||||
|
|
||||||
|
function select(stage: PipelineStage) {
|
||||||
|
selectedStage.value = stage
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
if (!selectedStage.value) return
|
||||||
|
const opts: { interview_date?: string; rejection_stage?: string } = {}
|
||||||
|
if (interviewDate.value) opts.interview_date = new Date(interviewDate.value).toISOString()
|
||||||
|
if (rejectionStage.value) opts.rejection_stage = rejectionStage.value
|
||||||
|
emit('move', selectedStage.value, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') { emit('close'); return }
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault()
|
||||||
|
focusIndex.value = Math.min(focusIndex.value + 1, stages.value.length - 1)
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault()
|
||||||
|
focusIndex.value = Math.max(focusIndex.value - 1, 0)
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && stages.value[focusIndex.value]) {
|
||||||
|
select(stages.value[focusIndex.value])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', onKeydown)
|
||||||
|
nextTick(() => firstOptionEl.value?.focus())
|
||||||
|
})
|
||||||
|
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
class="sheet-backdrop"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="`Move ${jobTitle}`"
|
||||||
|
@click.self="emit('close')"
|
||||||
|
>
|
||||||
|
<div class="sheet-panel">
|
||||||
|
<div class="sheet-header">
|
||||||
|
<span class="sheet-title">Move to…</span>
|
||||||
|
<button class="sheet-close" @click="emit('close')" aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet-stages" role="listbox">
|
||||||
|
<button
|
||||||
|
v-for="(stage, i) in stages"
|
||||||
|
:key="stage"
|
||||||
|
:ref="i === 0 ? (el) => { firstOptionEl = el as HTMLButtonElement } : undefined"
|
||||||
|
class="stage-option"
|
||||||
|
:class="{
|
||||||
|
'stage-option--selected': selectedStage === stage,
|
||||||
|
'stage-option--focused': focusIndex === i,
|
||||||
|
}"
|
||||||
|
role="option"
|
||||||
|
:aria-selected="selectedStage === stage"
|
||||||
|
@click="select(stage)"
|
||||||
|
>
|
||||||
|
{{ STAGE_LABELS[stage] }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="selectedStage === 'phone_screen' || selectedStage === 'interviewing'"
|
||||||
|
class="sheet-extras"
|
||||||
|
>
|
||||||
|
<label class="field-label">
|
||||||
|
Interview date/time (optional)
|
||||||
|
<input type="datetime-local" v-model="interviewDate" class="field-input" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedStage === 'interview_rejected'" class="sheet-extras">
|
||||||
|
<label class="field-label">
|
||||||
|
Rejected after…
|
||||||
|
<select v-model="rejectionStage" class="field-input">
|
||||||
|
<option value="">— select —</option>
|
||||||
|
<option>Application</option>
|
||||||
|
<option>Phone screen</option>
|
||||||
|
<option>Interviewing</option>
|
||||||
|
<option>Offer stage</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet-actions">
|
||||||
|
<button class="btn-cancel" @click="emit('close')">Cancel</button>
|
||||||
|
<button class="btn-confirm" :disabled="!selectedStage" @click="confirm">Move →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sheet-backdrop {
|
||||||
|
position: fixed; inset: 0; z-index: 200;
|
||||||
|
background: rgba(0,0,0,.45);
|
||||||
|
display: flex; align-items: flex-end; justify-content: center;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.sheet-backdrop { align-items: center; }
|
||||||
|
}
|
||||||
|
.sheet-panel {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
padding: var(--space-4) var(--space-4) var(--space-6);
|
||||||
|
width: 100%; max-width: 480px;
|
||||||
|
display: flex; flex-direction: column; gap: var(--space-3);
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.sheet-panel { border-radius: 12px; }
|
||||||
|
}
|
||||||
|
.sheet-header { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.sheet-title { font-weight: 700; font-size: 1rem; }
|
||||||
|
.sheet-close { background: none; border: none; cursor: pointer; font-size: 1rem; color: var(--color-text-muted); }
|
||||||
|
.sheet-stages { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||||
|
.stage-option {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 8px; padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: 0.9rem; font-weight: 600; text-align: left;
|
||||||
|
cursor: pointer; color: var(--color-text);
|
||||||
|
transition: border-color 120ms, background 120ms;
|
||||||
|
}
|
||||||
|
.stage-option:hover { background: var(--color-surface-alt); }
|
||||||
|
.stage-option--selected { border-color: var(--color-primary); background: var(--color-primary-light); }
|
||||||
|
.stage-option--focused { outline: 2px solid var(--color-primary); outline-offset: 1px; }
|
||||||
|
.sheet-extras { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||||
|
.field-label { font-size: 0.8rem; font-weight: 600; color: var(--color-text-muted); display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.field-input { padding: var(--space-2); border: 1px solid var(--color-border); border-radius: 6px; background: var(--color-surface); font-size: 0.875rem; color: var(--color-text); }
|
||||||
|
.sheet-actions { display: flex; gap: var(--space-2); justify-content: flex-end; margin-top: var(--space-2); }
|
||||||
|
.btn-cancel {
|
||||||
|
background: var(--color-surface-alt); border: none; border-radius: 8px;
|
||||||
|
padding: var(--space-2) var(--space-4); font-weight: 600; cursor: pointer;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.btn-confirm {
|
||||||
|
background: var(--color-primary); border: none; border-radius: 8px;
|
||||||
|
padding: var(--space-2) var(--space-4); font-weight: 700; cursor: pointer;
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
}
|
||||||
|
.btn-confirm:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue