Files
portal/app/resources/js/stores/reminders.ts
T
Дмитрий dc1457a008 phase2(reminders-frontend): RemindersView + DealDetailDrawer + nav-badge
P0 этап 5 — frontend для reminders (после backend-этапа 4).
Пользователь может создавать/просматривать/завершать/удалять напоминания
из UI с inline-create в DealDetailDrawer.

Frontend:
- api/reminders.ts: типизированные helpers для 5 endpoints + ensureCsrfCookie
  для mutating. Types ReminderFilter/ApiReminder/ReminderCounts.
- stores/reminders.ts: Pinia с items/counts/currentFilter +
  load/refreshCounts/create/update/complete/remove. Optimistic для
  complete/remove с revert на reject.
- components/reminders/ReminderDialog.vue: dual-mode (create/edit) modal
  с native datetime-local input. Props dealId?/reminder? (edit),
  ISO-конверсия при submit.
- views/RemindersView.vue: page-head с stats (active/overdue) + reload-btn;
  4 tabs (today/upcoming/overdue/completed) с counts на бейджах
  (overdue=error color); v-list с complete-btn / dropdown
  Изменить/Удалить с confirm-dialog; empty-state.
- router: /reminders маршрут (lazy).
- AppLayout: nav-badge «Напоминания» биндит count из store
  (replace static «12»); скрыт при count=0; polling 60 сек для
  refreshCounts.
- DealDetailDrawer: секция «Напоминания» (только при tenantId+deal):
  inline + create-btn / список / complete / встроенный ReminderDialog.

Vitest +20 (369/369 за 21.20 сек):
- reminders-store 11: initial / load+reject / refreshCounts /
  create+reject / complete optimistic+revert / remove+reject / reset.
- RemindersView 7: mount / 4 tabs / counts / empty-state /
  список / reload-btn / filter=today default.
- AppLayout +2: бейдж скрыт при count=0 / показывает count при >0.

Pest 347/347 (без изменений — backend нетронут).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:41:41 +03:00

151 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ref } from 'vue';
import { defineStore } from 'pinia';
import {
type ApiReminder,
type CreateReminderPayload,
type ListRemindersParams,
type ReminderCounts,
type ReminderFilter,
type UpdateReminderPayload,
completeReminder,
createReminder,
deleteReminder,
listReminders,
updateReminder,
} from '../api/reminders';
/**
* Pinia store для напоминаний (RemindersView + nav-badge в AppLayout
* + DealDetailDrawer-секция).
*
* Стратегия: counts всегда обновляются на load() — даже если items
* фильтруются. nav-badge в AppLayout биндит counts.active.
*
* Optimistic-updates для complete/delete с revert на reject.
*/
export const useRemindersStore = defineStore('reminders', () => {
const items = ref<ApiReminder[]>([]);
const counts = ref<ReminderCounts>({ active: 0, today: 0, upcoming: 0, overdue: 0 });
const loading = ref(false);
const fetchError = ref(false);
const currentFilter = ref<ReminderFilter>('active');
async function load(params: ListRemindersParams = {}): Promise<void> {
loading.value = true;
fetchError.value = false;
try {
const response = await listReminders(params);
items.value = response.items;
counts.value = response.counts;
currentFilter.value = params.filter ?? 'active';
} catch {
fetchError.value = true;
} finally {
loading.value = false;
}
}
async function refreshCounts(): Promise<void> {
// Лёгкий запрос только для bell-badge: limit=1, нам важны counts.
try {
const response = await listReminders({ filter: 'active', limit: 1 });
counts.value = response.counts;
} catch {
/* silent */
}
}
async function create(payload: CreateReminderPayload): Promise<ApiReminder | null> {
try {
const reminder = await createReminder(payload);
// Если новый reminder подходит под текущий filter и deal — добавляем в начало.
// (load() в любом случае пересчитает counts, но optimistic покажет сразу.)
items.value = [reminder, ...items.value];
counts.value.active++;
return reminder;
} catch {
return null;
}
}
async function update(id: number, payload: UpdateReminderPayload): Promise<ApiReminder | null> {
try {
const updated = await updateReminder(id, payload);
const idx = items.value.findIndex((r) => r.id === id);
if (idx >= 0) items.value.splice(idx, 1, updated);
return updated;
} catch {
return null;
}
}
async function complete(id: number): Promise<void> {
const item = items.value.find((r) => r.id === id);
if (!item || item.completed_at !== null) return;
// Optimistic.
const previous = item.completed_at;
item.completed_at = new Date().toISOString();
counts.value.active = Math.max(0, counts.value.active - 1);
try {
const updated = await completeReminder(id);
item.completed_at = updated.completed_at;
// Если фильтр active — убираем из items.
if (
currentFilter.value === 'active' ||
currentFilter.value === 'today' ||
currentFilter.value === 'upcoming' ||
currentFilter.value === 'overdue'
) {
items.value = items.value.filter((r) => r.id !== id);
}
} catch {
// Revert.
item.completed_at = previous;
counts.value.active++;
}
}
async function remove(id: number): Promise<void> {
const item = items.value.find((r) => r.id === id);
if (!item) return;
const wasActive = item.completed_at === null;
const previousItems = [...items.value];
const previousCounts = { ...counts.value };
// Optimistic.
items.value = items.value.filter((r) => r.id !== id);
if (wasActive) counts.value.active = Math.max(0, counts.value.active - 1);
try {
await deleteReminder(id);
} catch {
// Revert.
items.value = previousItems;
counts.value = previousCounts;
}
}
function reset(): void {
items.value = [];
counts.value = { active: 0, today: 0, upcoming: 0, overdue: 0 };
fetchError.value = false;
}
return {
items,
counts,
loading,
fetchError,
currentFilter,
load,
refreshCounts,
create,
update,
complete,
remove,
reset,
};
});