Files
portal/app/resources/js/components/reminders/ReminderDialog.vue
T
Дмитрий 2d7d7d1188 feat(frontend): Sprint 2 Phase B — Vue 3.5 defineModel + Vuetify 3.12 typed slots + lazy-imports + ESLint check
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>
2026-05-09 19:36:02 +03:00

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>