feat(platform): monotonic numbering, pagination invariants, route tabs deep linking, and module persistence
This commit is contained in:
parent
5200b7ebd5
commit
d1ccc1c197
10 changed files with 519 additions and 54 deletions
127
src/app/router/__tests__/routeTabsDeepLinks.test.tsx
Normal file
127
src/app/router/__tests__/routeTabsDeepLinks.test.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||||
|
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
import { FinanceModule } from '../../../features/finance/FinanceModule';
|
||||||
|
import { OperationsModule } from '../../../features/operations/OperationsModule';
|
||||||
|
import { useSessionStore } from '../../../core/session/sessionStore';
|
||||||
|
import { useDemoStore } from '../../../stores/demo';
|
||||||
|
|
||||||
|
describe('Route Tabs & Deep Links Synchronization (F17)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().resetSession();
|
||||||
|
useDemoStore.getState().resetDemoControls();
|
||||||
|
useSessionStore.getState().setCurrentRoleId('owner');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FinanceModule Deep Links', () => {
|
||||||
|
it('1. /finance?tab=pl activates P&L report tab directly from URL', () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/finance?tab=pl']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/finance" element={<FinanceModule />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify P&L report content is rendered
|
||||||
|
expect(screen.getByText('Отчет о прибылях и убытках (P&L)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('2. Валовая прибыль (Gross Profit)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('3. Чистая прибыль (Net Profit)')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2. /finance?tab=cashflow activates Accounts & Cashflow tab directly from URL', () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/finance?tab=cashflow']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/finance" element={<FinanceModule />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify Accounts table is rendered
|
||||||
|
expect(screen.getByText('Наименование счета')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Тип счета')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Текущий баланс')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3. /finance without params defaults to Cashflow Transactions (ДДС)', () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/finance']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/finance" element={<FinanceModule />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Search input for transactions is rendered
|
||||||
|
expect(screen.getByPlaceholderText('Поиск по описанию, счету...')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Все операции')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('4. Clicking tabs updates activeTab and renders corresponding view', () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/finance']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/finance" element={<FinanceModule />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click on P&L button
|
||||||
|
const pnlButton = screen.getByText('Отчет о прибылях и убытках (P&L)');
|
||||||
|
fireEvent.click(pnlButton);
|
||||||
|
|
||||||
|
expect(screen.getByText('2. Валовая прибыль (Gross Profit)')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click on Accounts button
|
||||||
|
const accButton = screen.getByText(/Банковские счета и кассы/);
|
||||||
|
fireEvent.click(accButton);
|
||||||
|
|
||||||
|
expect(screen.getByText('Наименование счета')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OperationsModule Deep Links', () => {
|
||||||
|
it('5. /operations?tab=pos renders POS terminal view', () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/operations?tab=pos']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/operations" element={<OperationsModule />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Текущий чек')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('В зале')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('6. /operations?tab=orders renders Sales & Orders module', () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/operations?tab=orders']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/operations" element={<OperationsModule />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Коммерция & Продажи: Управление Заказами')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('7. /operations?tab=kds renders Kitchen Display System (KDS)', () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/operations?tab=kds']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/operations" element={<OperationsModule />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Кухонный монитор заказов & Экран цехов/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { INITIAL_EMPLOYEES, EmployeeEntity } from '../../mock/mockData';
|
import { INITIAL_EMPLOYEES, EmployeeEntity } from '../../mock/mockData';
|
||||||
|
import { getStoreItem, putStoreItem } from '../../services/storage/indexedDb';
|
||||||
import { Badge, Button, MaturityBadge, Modal, useToast } from '../../shared/ui';
|
import { Badge, Button, MaturityBadge, Modal, useToast } from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
|
|
@ -51,6 +52,35 @@ export const EmployeesModule: React.FC = () => {
|
||||||
const [shifts, setShifts] = useState<ShiftPlanItem[]>(INITIAL_SHIFTS);
|
const [shifts, setShifts] = useState<ShiftPlanItem[]>(INITIAL_SHIFTS);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
// Load persisted employees and shifts on mount
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
const savedEmp = await getStoreItem<{ key: string; employees: EmployeeEntity[] }>('settings', 'krona_persisted_employees');
|
||||||
|
if (savedEmp?.employees && Array.isArray(savedEmp.employees) && savedEmp.employees.length > 0) {
|
||||||
|
setEmployees(savedEmp.employees);
|
||||||
|
}
|
||||||
|
const savedShifts = await getStoreItem<{ key: string; shifts: ShiftPlanItem[] }>('settings', 'krona_persisted_shifts');
|
||||||
|
if (savedShifts?.shifts && Array.isArray(savedShifts.shifts) && savedShifts.shifts.length > 0) {
|
||||||
|
setShifts(savedShifts.shifts);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep initial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const persistEmployees = useCallback((updated: EmployeeEntity[]) => {
|
||||||
|
setEmployees(updated);
|
||||||
|
void putStoreItem('settings', { key: 'krona_persisted_employees', employees: updated }).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const persistShifts = useCallback((updated: ShiftPlanItem[]) => {
|
||||||
|
setShifts(updated);
|
||||||
|
void putStoreItem('settings', { key: 'krona_persisted_shifts', shifts: updated }).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// New Employee Modal
|
// New Employee Modal
|
||||||
const [isNewEmpModalOpen, setIsNewEmpModalOpen] = useState(false);
|
const [isNewEmpModalOpen, setIsNewEmpModalOpen] = useState(false);
|
||||||
const [newEmpName, setNewEmpName] = useState('');
|
const [newEmpName, setNewEmpName] = useState('');
|
||||||
|
|
@ -83,7 +113,8 @@ export const EmployeesModule: React.FC = () => {
|
||||||
avatarInitials: initials,
|
avatarInitials: initials,
|
||||||
};
|
};
|
||||||
|
|
||||||
setEmployees((prev) => [newEmp, ...prev]);
|
const updatedEmployees = [newEmp, ...employees];
|
||||||
|
persistEmployees(updatedEmployees);
|
||||||
setIsNewEmpModalOpen(false);
|
setIsNewEmpModalOpen(false);
|
||||||
setNewEmpName('');
|
setNewEmpName('');
|
||||||
showToast({
|
showToast({
|
||||||
|
|
@ -99,14 +130,12 @@ export const EmployeesModule: React.FC = () => {
|
||||||
const hasConflict = editingShift.hours > 12;
|
const hasConflict = editingShift.hours > 12;
|
||||||
const conflictReason = hasConflict ? 'Смена превышает 12 ч (ТК РФ)' : undefined;
|
const conflictReason = hasConflict ? 'Смена превышает 12 ч (ТК РФ)' : undefined;
|
||||||
|
|
||||||
setShifts((prev) => {
|
const filtered = shifts.filter(
|
||||||
const filtered = prev.filter(
|
|
||||||
(s) => !(s.employeeId === editingShift.empId && s.day === editingShift.day)
|
(s) => !(s.employeeId === editingShift.empId && s.day === editingShift.day)
|
||||||
);
|
);
|
||||||
if (editingShift.shiftText === 'Выходной' || editingShift.hours === 0) {
|
const updatedShifts = (editingShift.shiftText === 'Выходной' || editingShift.hours === 0)
|
||||||
return filtered;
|
? filtered
|
||||||
}
|
: [
|
||||||
return [
|
|
||||||
...filtered,
|
...filtered,
|
||||||
{
|
{
|
||||||
employeeId: editingShift.empId,
|
employeeId: editingShift.empId,
|
||||||
|
|
@ -117,8 +146,8 @@ export const EmployeesModule: React.FC = () => {
|
||||||
conflictReason,
|
conflictReason,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
|
||||||
|
|
||||||
|
persistShifts(updatedShifts);
|
||||||
setEditingShift(null);
|
setEditingShift(null);
|
||||||
showToast({
|
showToast({
|
||||||
title: 'Смена обновлена',
|
title: 'Смена обновлена',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { useFinance } from '../../services/hooks/useFinance';
|
import { useFinance } from '../../services/hooks/useFinance';
|
||||||
import { TransactionCategory } from '../../services/contracts/finance';
|
import { TransactionCategory } from '../../services/contracts/finance';
|
||||||
import { Badge } from '../../shared/ui/Badge';
|
import { Badge } from '../../shared/ui/Badge';
|
||||||
|
|
@ -24,6 +25,7 @@ import {
|
||||||
|
|
||||||
export const FinanceModule: React.FC = () => {
|
export const FinanceModule: React.FC = () => {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const {
|
const {
|
||||||
accounts,
|
accounts,
|
||||||
transactions,
|
transactions,
|
||||||
|
|
@ -31,7 +33,19 @@ export const FinanceModule: React.FC = () => {
|
||||||
createManualTransaction,
|
createManualTransaction,
|
||||||
} = useFinance();
|
} = useFinance();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'transactions' | 'pnl' | 'accounts'>('transactions');
|
const rawTab = (searchParams.get('tab') || '').toLowerCase();
|
||||||
|
const activeTab: 'transactions' | 'pnl' | 'accounts' =
|
||||||
|
rawTab === 'pl' || rawTab === 'pnl'
|
||||||
|
? 'pnl'
|
||||||
|
: rawTab === 'accounts' || rawTab === 'cashflow'
|
||||||
|
? 'accounts'
|
||||||
|
: 'transactions';
|
||||||
|
|
||||||
|
const setActiveTab = (tab: 'transactions' | 'pnl' | 'accounts') => {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
next.set('tab', tab === 'pnl' ? 'pl' : tab === 'accounts' ? 'cashflow' : 'transactions');
|
||||||
|
setSearchParams(next, { replace: false });
|
||||||
|
};
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [filterType, setFilterType] = useState<'all' | 'inflow' | 'outflow'>('all');
|
const [filterType, setFilterType] = useState<'all' | 'inflow' | 'outflow'>('all');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { INITIAL_TICKETS, TicketEntity } from '../../mock/mockData';
|
import { INITIAL_TICKETS, TicketEntity } from '../../mock/mockData';
|
||||||
|
import { getStoreItem, putStoreItem } from '../../services/storage/indexedDb';
|
||||||
import { Badge, Button, MaturityBadge, Modal, useToast } from '../../shared/ui';
|
import { Badge, Button, MaturityBadge, Modal, useToast } from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
|
@ -33,6 +34,27 @@ const ASSIGNEES = ['Алина Соколова', 'Артем Васильев',
|
||||||
export const HelpDeskModule: React.FC = () => {
|
export const HelpDeskModule: React.FC = () => {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [tickets, setTickets] = useState<ExtendedTicket[]>(INITIAL_EXTENDED_TICKETS);
|
const [tickets, setTickets] = useState<ExtendedTicket[]>(INITIAL_EXTENDED_TICKETS);
|
||||||
|
|
||||||
|
// Load persisted tickets on mount
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadTickets() {
|
||||||
|
try {
|
||||||
|
const saved = await getStoreItem<{ key: string; tickets: ExtendedTicket[] }>('settings', 'krona_persisted_tickets');
|
||||||
|
if (saved?.tickets && Array.isArray(saved.tickets) && saved.tickets.length > 0) {
|
||||||
|
setTickets(saved.tickets);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep initial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadTickets();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const persistTickets = useCallback((updated: ExtendedTicket[]) => {
|
||||||
|
setTickets(updated);
|
||||||
|
void putStoreItem('settings', { key: 'krona_persisted_tickets', tickets: updated }).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [selectedTicket, setSelectedTicket] = useState<ExtendedTicket | null>(null);
|
const [selectedTicket, setSelectedTicket] = useState<ExtendedTicket | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||||
|
|
@ -109,9 +131,8 @@ export const HelpDeskModule: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateTicketStatus = (ticketId: string, nextStatus: ExtendedTicket['status']) => {
|
const handleUpdateTicketStatus = (ticketId: string, nextStatus: ExtendedTicket['status']) => {
|
||||||
setTickets((prev) =>
|
const updated = tickets.map((t) => (t.id === ticketId ? { ...t, status: nextStatus } : t));
|
||||||
prev.map((t) => (t.id === ticketId ? { ...t, status: nextStatus } : t))
|
persistTickets(updated);
|
||||||
);
|
|
||||||
if (selectedTicket && selectedTicket.id === ticketId) {
|
if (selectedTicket && selectedTicket.id === ticketId) {
|
||||||
setSelectedTicket({ ...selectedTicket, status: nextStatus });
|
setSelectedTicket({ ...selectedTicket, status: nextStatus });
|
||||||
}
|
}
|
||||||
|
|
@ -123,9 +144,8 @@ export const HelpDeskModule: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateAssignee = (ticketId: string, assignee: string) => {
|
const handleUpdateAssignee = (ticketId: string, assignee: string) => {
|
||||||
setTickets((prev) =>
|
const updated = tickets.map((t) => (t.id === ticketId ? { ...t, assignee } : t));
|
||||||
prev.map((t) => (t.id === ticketId ? { ...t, assignee } : t))
|
persistTickets(updated);
|
||||||
);
|
|
||||||
if (selectedTicket && selectedTicket.id === ticketId) {
|
if (selectedTicket && selectedTicket.id === ticketId) {
|
||||||
setSelectedTicket({ ...selectedTicket, assignee });
|
setSelectedTicket({ ...selectedTicket, assignee });
|
||||||
}
|
}
|
||||||
|
|
@ -153,7 +173,8 @@ export const HelpDeskModule: React.FC = () => {
|
||||||
: { publicReplies: [...(selectedTicket.publicReplies || []), newNote] }),
|
: { publicReplies: [...(selectedTicket.publicReplies || []), newNote] }),
|
||||||
};
|
};
|
||||||
|
|
||||||
setTickets((prev) => prev.map((t) => (t.id === selectedTicket.id ? updatedTicket : t)));
|
const updatedTickets = tickets.map((t) => (t.id === selectedTicket.id ? updatedTicket : t));
|
||||||
|
persistTickets(updatedTickets);
|
||||||
setSelectedTicket(updatedTicket);
|
setSelectedTicket(updatedTicket);
|
||||||
setReplyText('');
|
setReplyText('');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -130,9 +130,9 @@ export const MarketingModule: React.FC = () => {
|
||||||
revenue: 74800,
|
revenue: 74800,
|
||||||
});
|
});
|
||||||
showToast({
|
showToast({
|
||||||
title: 'Рассылка успешно завершена!',
|
title: 'Имитация рассылки завершена (Демо)',
|
||||||
message: '340 сообщений отправлено в Telegram-бот. Оформлено 34 новых заказа.',
|
message: '340 сообщений смоделировано. Реальная отправка ожидает интеграции внешнего провайдера.',
|
||||||
type: 'success',
|
type: 'info',
|
||||||
});
|
});
|
||||||
}, 1200);
|
}, 1200);
|
||||||
};
|
};
|
||||||
|
|
@ -241,12 +241,15 @@ export const MarketingModule: React.FC = () => {
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="w-4 h-4 text-krona-gold" />
|
<Sparkles className="w-4 h-4 text-krona-gold" />
|
||||||
<h4 className="font-bold text-xs text-krona-primary">
|
<h4 className="font-bold text-xs text-krona-primary flex items-center gap-1.5">
|
||||||
Интерактивная симуляция запуска триггерной рассылки
|
Интерактивная симуляция запуска триггерной рассылки
|
||||||
|
<span className="text-[10px] font-normal px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-700 dark:text-amber-300 border border-amber-500/20">
|
||||||
|
Демо-модель
|
||||||
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-krona-secondary mt-0.5">
|
<p className="text-[11px] text-krona-secondary mt-0.5">
|
||||||
Протестируйте отправку рассылки с промокодом по сегменту «Спящие клиенты» (340 получателей в базе)
|
Протестируйте отправку рассылки с промокодом по сегменту «Спящие клиенты» (340 получателей в базе). Подключение внешнего провайдера ожидает интеграции бэкенда.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useDemoStore } from '../../mock/store';
|
import { useDemoStore } from '../../mock/store';
|
||||||
import { PosView } from './PosView';
|
import { PosView } from './PosView';
|
||||||
import { KdsView } from './KdsView';
|
import { KdsView } from './KdsView';
|
||||||
|
|
@ -20,47 +20,49 @@ import { formatCurrency } from '../../shared/utils/formatters';
|
||||||
export const OperationsModule: React.FC = () => {
|
export const OperationsModule: React.FC = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { getActiveProfile, getActiveCapabilities, setIsQuickActionsOpen, activeLocation } = useDemoStore();
|
const { getActiveProfile, getActiveCapabilities, setIsQuickActionsOpen, activeLocation } = useDemoStore();
|
||||||
const profile = getActiveProfile();
|
const profile = getActiveProfile();
|
||||||
const capabilities = getActiveCapabilities();
|
const capabilities = getActiveCapabilities();
|
||||||
|
|
||||||
const currentPath = location.pathname;
|
const currentPath = location.pathname;
|
||||||
|
const tabParam = (searchParams.get('tab') || '').toLowerCase();
|
||||||
|
|
||||||
// Sub-routes dispatcher
|
// Sub-routes dispatcher (supports both direct path and deep link query params)
|
||||||
if (currentPath === '/operations/pos') {
|
if (currentPath === '/operations/pos' || tabParam === 'pos') {
|
||||||
return <PosView />;
|
return <PosView />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/kds') {
|
if (currentPath === '/operations/kds' || tabParam === 'kds') {
|
||||||
return <KdsView />;
|
return <KdsView />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/dining') {
|
if (currentPath === '/operations/dining' || tabParam === 'dining') {
|
||||||
return <DiningView />;
|
return <DiningView />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/work-orders') {
|
if (currentPath === '/operations/work-orders' || tabParam === 'work-orders') {
|
||||||
return <WorkOrdersView />;
|
return <WorkOrdersView />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/booking') {
|
if (currentPath === '/operations/booking' || tabParam === 'booking') {
|
||||||
return <BookingCalendarView />;
|
return <BookingCalendarView />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/delivery') {
|
if (currentPath === '/operations/delivery' || tabParam === 'delivery') {
|
||||||
return <DeliveryView />;
|
return <DeliveryView />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/courier') {
|
if (currentPath === '/operations/courier' || tabParam === 'courier') {
|
||||||
return <DeliveryView initialMobile={true} />;
|
return <DeliveryView initialMobile={true} />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/recipes') {
|
if (currentPath === '/operations/recipes' || tabParam === 'recipes') {
|
||||||
return <RecipesView />;
|
return <RecipesView />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/inventory') {
|
if (currentPath === '/operations/inventory' || tabParam === 'inventory') {
|
||||||
return <InventoryModule />;
|
return <InventoryModule />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/orders') {
|
if (currentPath === '/operations/orders' || tabParam === 'orders') {
|
||||||
return <SalesModule />;
|
return <SalesModule />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/purchasing') {
|
if (currentPath === '/operations/purchasing' || tabParam === 'purchasing') {
|
||||||
return <PurchasingView />;
|
return <PurchasingView />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/bays') {
|
if (currentPath === '/operations/bays' || tabParam === 'bays') {
|
||||||
return <BaysView />;
|
return <BaysView />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { INITIAL_TASKS, TaskEntity } from '../../mock/mockData';
|
import { INITIAL_TASKS, TaskEntity } from '../../mock/mockData';
|
||||||
|
import { getStoreItem, putStoreItem } from '../../services/storage/indexedDb';
|
||||||
import { Badge, MaturityBadge, Button, Modal } from '../../shared/ui';
|
import { Badge, MaturityBadge, Button, Modal } from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
|
|
@ -27,6 +28,26 @@ export const TasksModule: React.FC = () => {
|
||||||
const [tasks, setTasks] = useState<TaskEntity[]>(INITIAL_TASKS);
|
const [tasks, setTasks] = useState<TaskEntity[]>(INITIAL_TASKS);
|
||||||
const [activeView, setActiveView] = useState<'kanban' | 'list'>('kanban');
|
const [activeView, setActiveView] = useState<'kanban' | 'list'>('kanban');
|
||||||
|
|
||||||
|
// Load persisted tasks on mount
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadTasks() {
|
||||||
|
try {
|
||||||
|
const saved = await getStoreItem<{ key: string; tasks: TaskEntity[] }>('settings', 'krona_persisted_tasks');
|
||||||
|
if (saved?.tasks && Array.isArray(saved.tasks) && saved.tasks.length > 0) {
|
||||||
|
setTasks(saved.tasks);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep initial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadTasks();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const persistTasks = useCallback((updated: TaskEntity[]) => {
|
||||||
|
setTasks(updated);
|
||||||
|
void putStoreItem('settings', { key: 'krona_persisted_tasks', tasks: updated }).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Selected task detail state
|
// Selected task detail state
|
||||||
const [selectedTask, setSelectedTask] = useState<TaskEntity | null>(null);
|
const [selectedTask, setSelectedTask] = useState<TaskEntity | null>(null);
|
||||||
const [subtasks, setSubtasks] = useState<SubtaskItem[]>([
|
const [subtasks, setSubtasks] = useState<SubtaskItem[]>([
|
||||||
|
|
@ -41,7 +62,8 @@ export const TasksModule: React.FC = () => {
|
||||||
const [newComment, setNewComment] = useState('');
|
const [newComment, setNewComment] = useState('');
|
||||||
|
|
||||||
const handleStatusChange = (taskId: string, nextStatus: TaskEntity['status']) => {
|
const handleStatusChange = (taskId: string, nextStatus: TaskEntity['status']) => {
|
||||||
setTasks(tasks.map(t => t.id === taskId ? { ...t, status: nextStatus } : t));
|
const updated = tasks.map((t) => (t.id === taskId ? { ...t, status: nextStatus } : t));
|
||||||
|
persistTasks(updated);
|
||||||
if (selectedTask && selectedTask.id === taskId) {
|
if (selectedTask && selectedTask.id === taskId) {
|
||||||
setSelectedTask({ ...selectedTask, status: nextStatus });
|
setSelectedTask({ ...selectedTask, status: nextStatus });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
192
src/services/__tests__/paginationAndNumbering.test.ts
Normal file
192
src/services/__tests__/paginationAndNumbering.test.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
resetDatabase,
|
||||||
|
generateMonotonicNumber,
|
||||||
|
getRecordsByEntity,
|
||||||
|
putRecord,
|
||||||
|
} from '../storage/indexedDb';
|
||||||
|
import { getServices } from '../mock';
|
||||||
|
import { createRequestContext } from '../contracts/context';
|
||||||
|
|
||||||
|
describe('Pagination & Monotonic Numbering Integrity (F16)', () => {
|
||||||
|
const { commerce } = getServices();
|
||||||
|
const TENANT_A = 'tenant_num_a';
|
||||||
|
const TENANT_B = 'tenant_num_b';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Monotonic Sequence Generator', () => {
|
||||||
|
it('1. Generates strictly incremental sequence (0001, 0002, 0003)', async () => {
|
||||||
|
const n1 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
||||||
|
const n2 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
||||||
|
const n3 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
||||||
|
|
||||||
|
expect(n1).toBe('ORD-0001');
|
||||||
|
expect(n2).toBe('ORD-0002');
|
||||||
|
expect(n3).toBe('ORD-0003');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2. Sequence never rewinds or collides even after records are deleted', async () => {
|
||||||
|
const n1 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
||||||
|
const n2 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
||||||
|
const n3 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
||||||
|
expect(n1).toBe('ORD-0001');
|
||||||
|
expect(n2).toBe('ORD-0002');
|
||||||
|
expect(n3).toBe('ORD-0003');
|
||||||
|
|
||||||
|
// Even if items 1-3 were deleted, next item is strictly ORD-0004, NOT ORD-0001 or ORD-records.length
|
||||||
|
const n4 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
||||||
|
expect(n4).toBe('ORD-0004');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3. Independent counters for different prefixes', async () => {
|
||||||
|
const ord1 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
||||||
|
const inv1 = await generateMonotonicNumber('INV', 4, TENANT_A);
|
||||||
|
const lead1 = await generateMonotonicNumber('LEAD', 4, TENANT_A);
|
||||||
|
const ord2 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
||||||
|
|
||||||
|
expect(ord1).toBe('ORD-0001');
|
||||||
|
expect(inv1).toBe('INV-0001');
|
||||||
|
expect(lead1).toBe('LEAD-0001');
|
||||||
|
expect(ord2).toBe('ORD-0002');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('4. Multi-tenant counter isolation', async () => {
|
||||||
|
const a1 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
||||||
|
const b1 = await generateMonotonicNumber('ORD', 4, TENANT_B);
|
||||||
|
const a2 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
||||||
|
|
||||||
|
expect(a1).toBe('ORD-0001');
|
||||||
|
expect(b1).toBe('ORD-0001');
|
||||||
|
expect(a2).toBe('ORD-0002');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Repository Auto-Numbering on Create', () => {
|
||||||
|
it('5. BaseMockRepository automatically assigns monotonic orderNumber when omitted', async () => {
|
||||||
|
const o1 = await commerce.orders.create({
|
||||||
|
orderNumber: '',
|
||||||
|
customerType: 'contact',
|
||||||
|
customerId: 'c1',
|
||||||
|
customerName: 'Customer One',
|
||||||
|
status: 'draft',
|
||||||
|
paymentStatus: 'unpaid',
|
||||||
|
fulfillmentStatus: 'unfulfilled',
|
||||||
|
items: [],
|
||||||
|
subtotal: { minor: 10000, currency: 'RUB' },
|
||||||
|
discountMinor: 0,
|
||||||
|
taxMinor: 0,
|
||||||
|
shippingMinor: 0,
|
||||||
|
total: { minor: 10000, currency: 'RUB' },
|
||||||
|
} as any, createRequestContext({ tenantId: TENANT_A, correlationId: 'corr_1' }));
|
||||||
|
|
||||||
|
const o2 = await commerce.orders.create({
|
||||||
|
orderNumber: '',
|
||||||
|
customerType: 'contact',
|
||||||
|
customerId: 'c2',
|
||||||
|
customerName: 'Customer Two',
|
||||||
|
status: 'draft',
|
||||||
|
paymentStatus: 'unpaid',
|
||||||
|
fulfillmentStatus: 'unfulfilled',
|
||||||
|
items: [],
|
||||||
|
subtotal: { minor: 20000, currency: 'RUB' },
|
||||||
|
discountMinor: 0,
|
||||||
|
taxMinor: 0,
|
||||||
|
shippingMinor: 0,
|
||||||
|
total: { minor: 20000, currency: 'RUB' },
|
||||||
|
} as any, createRequestContext({ tenantId: TENANT_A, correlationId: 'corr_2' }));
|
||||||
|
|
||||||
|
expect(o1.orderNumber).toBe('ORD-0001');
|
||||||
|
expect(o2.orderNumber).toBe('ORD-0002');
|
||||||
|
|
||||||
|
// Delete o1
|
||||||
|
await commerce.orders.delete(o1.id, createRequestContext({ tenantId: TENANT_A, correlationId: 'corr_3' }));
|
||||||
|
|
||||||
|
// Create o3 -> Must be ORD-0003, avoiding collision with o2
|
||||||
|
const o3 = await commerce.orders.create({
|
||||||
|
orderNumber: '',
|
||||||
|
customerType: 'contact',
|
||||||
|
customerId: 'c3',
|
||||||
|
customerName: 'Customer Three',
|
||||||
|
status: 'draft',
|
||||||
|
paymentStatus: 'unpaid',
|
||||||
|
fulfillmentStatus: 'unfulfilled',
|
||||||
|
items: [],
|
||||||
|
subtotal: { minor: 30000, currency: 'RUB' },
|
||||||
|
discountMinor: 0,
|
||||||
|
taxMinor: 0,
|
||||||
|
shippingMinor: 0,
|
||||||
|
total: { minor: 30000, currency: 'RUB' },
|
||||||
|
} as any, createRequestContext({ tenantId: TENANT_A, correlationId: 'corr_4' }));
|
||||||
|
|
||||||
|
expect(o3.orderNumber).toBe('ORD-0003');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Full Dataset Search & Invariant Pagination (F16)', () => {
|
||||||
|
it('6. Searches across whole collection before slicing pages, maintaining total and totalPages', async () => {
|
||||||
|
// Seed 12 items: 8 "Premium Arabica Coffee" and 4 "Green Tea"
|
||||||
|
for (let i = 1; i <= 8; i++) {
|
||||||
|
await putRecord({
|
||||||
|
id: `coffee_${i}`,
|
||||||
|
entityType: 'test_product',
|
||||||
|
name: `Premium Arabica Coffee Batch #${i}`,
|
||||||
|
category: 'coffee',
|
||||||
|
price: 500 + i * 10,
|
||||||
|
tenant_id: TENANT_A,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 1; i <= 4; i++) {
|
||||||
|
await putRecord({
|
||||||
|
id: `tea_${i}`,
|
||||||
|
entityType: 'test_product',
|
||||||
|
name: `Green Tea Leaf #${i}`,
|
||||||
|
category: 'tea',
|
||||||
|
price: 200 + i * 10,
|
||||||
|
tenant_id: TENANT_A,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page 1 of search "Arabica" with pageSize = 3
|
||||||
|
const page1 = await getRecordsByEntity('test_product', {
|
||||||
|
search: 'Arabica',
|
||||||
|
page: 1,
|
||||||
|
pageSize: 3,
|
||||||
|
tenantId: TENANT_A,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(page1.total).toBe(8); // Full count of matching records across collection
|
||||||
|
expect(page1.totalPages).toBe(3); // Math.ceil(8 / 3) = 3
|
||||||
|
expect(page1.page).toBe(1);
|
||||||
|
expect(page1.pageSize).toBe(3);
|
||||||
|
expect(page1.items).toHaveLength(3);
|
||||||
|
expect((page1.items[0] as any).name).toContain('Coffee Batch #1');
|
||||||
|
|
||||||
|
// Page 2 of search "Arabica"
|
||||||
|
const page2 = await getRecordsByEntity('test_product', {
|
||||||
|
search: 'Arabica',
|
||||||
|
page: 2,
|
||||||
|
pageSize: 3,
|
||||||
|
tenantId: TENANT_A,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(page2.total).toBe(8);
|
||||||
|
expect(page2.items).toHaveLength(3);
|
||||||
|
expect((page2.items[0] as any).name).toContain('Coffee Batch #4');
|
||||||
|
|
||||||
|
// Page 3 of search "Arabica" (last page with remaining 2 items)
|
||||||
|
const page3 = await getRecordsByEntity('test_product', {
|
||||||
|
search: 'Arabica',
|
||||||
|
page: 3,
|
||||||
|
pageSize: 3,
|
||||||
|
tenantId: TENANT_A,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(page3.total).toBe(8);
|
||||||
|
expect(page3.items).toHaveLength(2);
|
||||||
|
expect((page3.items[1] as any).name).toContain('Coffee Batch #8');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
getRecordsByEntity,
|
getRecordsByEntity,
|
||||||
putStoreItem,
|
putStoreItem,
|
||||||
RecordEntity,
|
RecordEntity,
|
||||||
|
generateMonotonicNumber,
|
||||||
} from '../storage/indexedDb';
|
} from '../storage/indexedDb';
|
||||||
import { ulid } from '../../shared/lib/ulid';
|
import { ulid } from '../../shared/lib/ulid';
|
||||||
import { useDemoStore } from '../../stores/demo';
|
import { useDemoStore } from '../../stores/demo';
|
||||||
|
|
@ -98,8 +99,18 @@ export class BaseMockRepository<T extends BaseEntity> implements IRepository<T>
|
||||||
const userId = ctx.actorId || useDemoStore.getState().currentRoleId || 'demo_user';
|
const userId = ctx.actorId || useDemoStore.getState().currentRoleId || 'demo_user';
|
||||||
|
|
||||||
const id = ulid();
|
const id = ulid();
|
||||||
|
const entityData = { ...data } as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (this.entityName === 'order' && !entityData.orderNumber) {
|
||||||
|
entityData.orderNumber = await generateMonotonicNumber('ORD', 4, ctx.tenantId);
|
||||||
|
} else if (this.entityName === 'invoice' && !entityData.invoiceNumber) {
|
||||||
|
entityData.invoiceNumber = await generateMonotonicNumber('INV', 4, ctx.tenantId);
|
||||||
|
} else if (this.entityName === 'lead' && !entityData.leadNumber) {
|
||||||
|
entityData.leadNumber = await generateMonotonicNumber('LEAD', 4, ctx.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
const entity = {
|
const entity = {
|
||||||
...data,
|
...entityData,
|
||||||
id,
|
id,
|
||||||
entityType: this.entityName,
|
entityType: this.entityName,
|
||||||
tenant_id: ctx.tenantId,
|
tenant_id: ctx.tenantId,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
import { ulid } from '../../shared/lib/ulid';
|
import { ulid } from '../../shared/lib/ulid';
|
||||||
|
|
||||||
export const DB_NAME = 'krona_business_os_db';
|
export const DB_NAME = 'krona_business_os_db';
|
||||||
export const DB_VERSION = 2;
|
export const DB_VERSION = 3;
|
||||||
|
|
||||||
export const DB_OBJECT_STORES = [
|
export const DB_OBJECT_STORES = [
|
||||||
'entities_meta',
|
'entities_meta',
|
||||||
|
|
@ -18,6 +18,7 @@ export const DB_OBJECT_STORES = [
|
||||||
'records_data',
|
'records_data',
|
||||||
'audit_log',
|
'audit_log',
|
||||||
'settings',
|
'settings',
|
||||||
|
'counters',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type DbStoreName = (typeof DB_OBJECT_STORES)[number];
|
export type DbStoreName = (typeof DB_OBJECT_STORES)[number];
|
||||||
|
|
@ -53,6 +54,7 @@ export interface DatabaseSnapshot {
|
||||||
records_data: unknown[];
|
records_data: unknown[];
|
||||||
audit_log: unknown[];
|
audit_log: unknown[];
|
||||||
settings: unknown[];
|
settings: unknown[];
|
||||||
|
counters?: unknown[];
|
||||||
};
|
};
|
||||||
metadata: {
|
metadata: {
|
||||||
totalRecords: number;
|
totalRecords: number;
|
||||||
|
|
@ -127,12 +129,44 @@ export function getDb(): Promise<IDBPDatabase> {
|
||||||
if (!db.objectStoreNames.contains('settings')) {
|
if (!db.objectStoreNames.contains('settings')) {
|
||||||
db.createObjectStore('settings', { keyPath: 'key' });
|
db.createObjectStore('settings', { keyPath: 'key' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 8. counters
|
||||||
|
if (!db.objectStoreNames.contains('counters')) {
|
||||||
|
db.createObjectStore('counters', { keyPath: 'key' });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return dbInstancePromise;
|
return dbInstancePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CounterItem {
|
||||||
|
key: string;
|
||||||
|
currentValue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNextMonotonicSequence(prefix: string, tenantId?: string): Promise<number> {
|
||||||
|
const db = await getDb();
|
||||||
|
const effectiveTenant = tenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID;
|
||||||
|
const key = `${effectiveTenant}:${prefix}`;
|
||||||
|
const tx = db.transaction('counters', 'readwrite');
|
||||||
|
const store = tx.objectStore('counters');
|
||||||
|
const existing = (await store.get(key)) as CounterItem | undefined;
|
||||||
|
const nextVal = (existing ? existing.currentValue : 0) + 1;
|
||||||
|
await store.put({ key, currentValue: nextVal });
|
||||||
|
await tx.done;
|
||||||
|
return nextVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMonotonicNumber(
|
||||||
|
prefix: string,
|
||||||
|
padLength = 4,
|
||||||
|
tenantId?: string
|
||||||
|
): Promise<string> {
|
||||||
|
const nextSeq = await getNextMonotonicSequence(prefix, tenantId);
|
||||||
|
return `${prefix}-${String(nextSeq).padStart(padLength, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
// CRUD on records_data with Tenant Isolation
|
// CRUD on records_data with Tenant Isolation
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
|
|
@ -371,6 +405,7 @@ export async function exportDatabaseSnapshot(tenantId?: string): Promise<Databas
|
||||||
let records_data = (await db.getAll('records_data')) as RecordEntity[];
|
let records_data = (await db.getAll('records_data')) as RecordEntity[];
|
||||||
let audit_log = (await db.getAll('audit_log')) as Record<string, unknown>[];
|
let audit_log = (await db.getAll('audit_log')) as Record<string, unknown>[];
|
||||||
let settings = (await db.getAll('settings')) as Record<string, unknown>[];
|
let settings = (await db.getAll('settings')) as Record<string, unknown>[];
|
||||||
|
let counters = (await db.getAll('counters')) as CounterItem[];
|
||||||
|
|
||||||
if (tenantId) {
|
if (tenantId) {
|
||||||
records_data = records_data.filter((r) => (r.tenant_id || DEFAULT_TENANT_ID) === tenantId);
|
records_data = records_data.filter((r) => (r.tenant_id || DEFAULT_TENANT_ID) === tenantId);
|
||||||
|
|
@ -385,6 +420,7 @@ export async function exportDatabaseSnapshot(tenantId?: string): Promise<Databas
|
||||||
(v) => !v.tenant_id || v.tenant_id === 'system' || v.tenant_id === tenantId
|
(v) => !v.tenant_id || v.tenant_id === 'system' || v.tenant_id === tenantId
|
||||||
);
|
);
|
||||||
settings = settings.filter((s) => !s.tenant_id || s.tenant_id === tenantId);
|
settings = settings.filter((s) => !s.tenant_id || s.tenant_id === tenantId);
|
||||||
|
counters = counters.filter((c) => c.key.startsWith(`${tenantId}:`));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -401,6 +437,7 @@ export async function exportDatabaseSnapshot(tenantId?: string): Promise<Databas
|
||||||
records_data,
|
records_data,
|
||||||
audit_log,
|
audit_log,
|
||||||
settings,
|
settings,
|
||||||
|
counters,
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
totalRecords: records_data.length,
|
totalRecords: records_data.length,
|
||||||
|
|
@ -517,7 +554,7 @@ export async function resetDatabase(tenantId?: string): Promise<void> {
|
||||||
await tx.done;
|
await tx.done;
|
||||||
} else {
|
} else {
|
||||||
// Only wipe data belonging to the specified tenant
|
// Only wipe data belonging to the specified tenant
|
||||||
const tx = db.transaction(['records_data', 'audit_log'] as const, 'readwrite');
|
const tx = db.transaction(['records_data', 'audit_log', 'counters'] as const, 'readwrite');
|
||||||
const recordsStore = tx.objectStore('records_data');
|
const recordsStore = tx.objectStore('records_data');
|
||||||
const allRecords = (await recordsStore.getAll()) as RecordEntity[];
|
const allRecords = (await recordsStore.getAll()) as RecordEntity[];
|
||||||
for (const r of allRecords) {
|
for (const r of allRecords) {
|
||||||
|
|
@ -532,6 +569,13 @@ export async function resetDatabase(tenantId?: string): Promise<void> {
|
||||||
await auditStore.delete(String(a.id));
|
await auditStore.delete(String(a.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const countersStore = tx.objectStore('counters');
|
||||||
|
const allCounters = (await countersStore.getAll()) as CounterItem[];
|
||||||
|
for (const c of allCounters) {
|
||||||
|
if (c.key.startsWith(`${tenantId}:`)) {
|
||||||
|
await countersStore.delete(c.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
await tx.done;
|
await tx.done;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue