2d7d7d1188
Sprint 2 Phase B (modernization). Закрытие audit O-stack-04/05/07 + O-perf-06:
- O-stack-04: Vue 3.5 defineModel() в 3 диалогах (NewDealDialog,
ImpersonationDialog, ReminderDialog) — boilerplate −5 строк/файл.
+ useTemplateRef() в TwoFactorView (input v-for refs).
- O-stack-05: Vuetify 3.12 типизированные слоты VDataTable
(DealsView + AdminTenantsView) — inline-аннотации `{ item }: { item: T }`
на 6+7 scoped-slot bindings; vue-tsc проверяет доступ к полям статически.
- O-stack-07: ESLint flat-config verified — header-comment добавлен
в eslint.config.js. Legacy .eslintrc.json не используется.
- O-perf-06: defineAsyncComponent() для тяжёлых диалогов в 3 местах:
DealsView (DealDetailDrawer + NewDealDialog), DealDetailDrawer
(ReminderDialog), RemindersView (ReminderDialog). KanbanView оставлен
sync — async-загрузка приводила к EnvironmentTeardownError в jsdom
(KanbanView.spec.ts), see in-file comment. Сборка показывает chunk'и
ImpersonationDialog (7.61 kB), DealDetailDrawer (11.12 kB), NewDealDialog
и ReminderDialog как отдельные lazy-bundles.
vue-tsc: 0 errors. ESLint: 0. Vitest: 416/416 PASS. Build: success.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
199 lines
6.3 KiB
Vue
199 lines
6.3 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* Dialog создания/редактирования напоминания.
|
|
*
|
|
* Поддерживает 2 режима:
|
|
* - Create: props.dealId передаётся, props.reminder=null;
|
|
* submit → create + emit('saved', newReminder).
|
|
* - Edit: props.reminder=ApiReminder, props.dealId опционален;
|
|
* submit → update + emit('saved', updatedReminder).
|
|
*
|
|
* Datetime-input через native HTML5 type="datetime-local" — без
|
|
* лишней библиотеки picker'а. Конвертация в ISO происходит при submit.
|
|
*/
|
|
import { computed, ref, watch } from 'vue';
|
|
import type { ApiReminder } from '../../api/reminders';
|
|
import { useRemindersStore } from '../../stores/reminders';
|
|
|
|
// Vue 3.5 defineModel() — Sprint 2 Phase B / O-stack-04.
|
|
const dialogOpen = defineModel<boolean>({ required: true });
|
|
|
|
const props = defineProps<{
|
|
dealId?: number | null;
|
|
reminder?: ApiReminder | null;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'saved', reminder: ApiReminder): void;
|
|
}>();
|
|
|
|
const store = useRemindersStore();
|
|
|
|
const isEditing = computed(() => props.reminder !== null && props.reminder !== undefined);
|
|
const dialogTitle = computed(() => (isEditing.value ? 'Редактировать напоминание' : 'Новое напоминание'));
|
|
|
|
const text = ref('');
|
|
const remindAt = ref(''); // ISO local
|
|
const error = ref<string | null>(null);
|
|
const submitting = ref(false);
|
|
|
|
function defaultRemindAt(): string {
|
|
// Default: через 1 час от текущего времени, в формате datetime-local.
|
|
const d = new Date(Date.now() + 60 * 60 * 1000);
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
}
|
|
|
|
function isoToLocalInput(iso: string | null): string {
|
|
if (!iso) return defaultRemindAt();
|
|
const d = new Date(iso);
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
}
|
|
|
|
watch(
|
|
dialogOpen,
|
|
(open) => {
|
|
if (open) {
|
|
if (isEditing.value && props.reminder) {
|
|
text.value = props.reminder.text ?? '';
|
|
remindAt.value = isoToLocalInput(props.reminder.remind_at);
|
|
} else {
|
|
text.value = '';
|
|
remindAt.value = defaultRemindAt();
|
|
}
|
|
error.value = null;
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
async function submit(): Promise<void> {
|
|
error.value = null;
|
|
|
|
if (!remindAt.value) {
|
|
error.value = 'Укажите дату и время напоминания.';
|
|
return;
|
|
}
|
|
|
|
const remindAtIso = new Date(remindAt.value).toISOString();
|
|
|
|
submitting.value = true;
|
|
try {
|
|
let saved: ApiReminder | null = null;
|
|
|
|
if (isEditing.value && props.reminder) {
|
|
saved = await store.update(props.reminder.id, {
|
|
text: text.value.trim() || null,
|
|
remind_at: remindAtIso,
|
|
});
|
|
} else {
|
|
if (!props.dealId) {
|
|
error.value = 'Не указан deal_id для создания.';
|
|
return;
|
|
}
|
|
saved = await store.create({
|
|
deal_id: props.dealId,
|
|
text: text.value.trim() || null,
|
|
remind_at: remindAtIso,
|
|
});
|
|
}
|
|
|
|
if (saved === null) {
|
|
error.value = 'Не удалось сохранить. Попробуйте позже.';
|
|
return;
|
|
}
|
|
|
|
emit('saved', saved);
|
|
dialogOpen.value = false;
|
|
} finally {
|
|
submitting.value = false;
|
|
}
|
|
}
|
|
|
|
function cancel(): void {
|
|
dialogOpen.value = false;
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<v-dialog v-model="dialogOpen" max-width="480" persistent>
|
|
<v-card>
|
|
<v-card-title>
|
|
{{ dialogTitle }}
|
|
</v-card-title>
|
|
<v-card-text>
|
|
<v-form @submit.prevent="submit">
|
|
<v-textarea
|
|
v-model="text"
|
|
label="Описание"
|
|
placeholder="Перезвонить клиенту, обсудить условия..."
|
|
rows="3"
|
|
counter
|
|
maxlength="255"
|
|
data-testid="reminder-text"
|
|
/>
|
|
<div class="datetime-row">
|
|
<label class="datetime-label" for="reminder-at-input">Когда напомнить</label>
|
|
<input
|
|
id="reminder-at-input"
|
|
v-model="remindAt"
|
|
type="datetime-local"
|
|
class="datetime-input"
|
|
data-testid="reminder-at"
|
|
/>
|
|
</div>
|
|
<v-alert
|
|
v-if="error"
|
|
type="warning"
|
|
variant="tonal"
|
|
density="compact"
|
|
class="mt-3"
|
|
data-testid="reminder-error"
|
|
>
|
|
{{ error }}
|
|
</v-alert>
|
|
</v-form>
|
|
</v-card-text>
|
|
<v-card-actions>
|
|
<v-spacer />
|
|
<v-btn variant="text" @click="cancel">Отмена</v-btn>
|
|
<v-btn color="primary" :loading="submitting" data-testid="reminder-submit" @click="submit">
|
|
{{ isEditing ? 'Сохранить' : 'Создать' }}
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.datetime-row {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.datetime-label {
|
|
font-size: 12px;
|
|
color: #66635c;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.datetime-input {
|
|
width: 100%;
|
|
padding: 10px 12px;
|
|
border: 1px solid #d9d5cd;
|
|
border-radius: 6px;
|
|
font-family: inherit;
|
|
font-size: 14px;
|
|
background: #ffffff;
|
|
}
|
|
|
|
.datetime-input:focus {
|
|
outline: 2px solid #0f6e56;
|
|
outline-offset: -1px;
|
|
border-color: #0f6e56;
|
|
}
|
|
</style>
|