diff --git a/src/app/router/__tests__/routeTabsDeepLinks.test.tsx b/src/app/router/__tests__/routeTabsDeepLinks.test.tsx new file mode 100644 index 0000000..67dcef1 --- /dev/null +++ b/src/app/router/__tests__/routeTabsDeepLinks.test.tsx @@ -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( + + + } /> + + + ); + + // 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( + + + } /> + + + ); + + // 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( + + + } /> + + + ); + + // 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( + + + } /> + + + ); + + // 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( + + + } /> + + + ); + + expect(screen.getByText('Текущий чек')).toBeInTheDocument(); + expect(screen.getByText('В зале')).toBeInTheDocument(); + }); + + it('6. /operations?tab=orders renders Sales & Orders module', () => { + render( + + + } /> + + + ); + + expect(screen.getByText('Коммерция & Продажи: Управление Заказами')).toBeInTheDocument(); + }); + + it('7. /operations?tab=kds renders Kitchen Display System (KDS)', () => { + render( + + + } /> + + + ); + + expect(screen.getByText(/Кухонный монитор заказов & Экран цехов/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/features/employees/EmployeesModule.tsx b/src/features/employees/EmployeesModule.tsx index 913782f..6bb93bb 100644 --- a/src/features/employees/EmployeesModule.tsx +++ b/src/features/employees/EmployeesModule.tsx @@ -1,5 +1,6 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { INITIAL_EMPLOYEES, EmployeeEntity } from '../../mock/mockData'; +import { getStoreItem, putStoreItem } from '../../services/storage/indexedDb'; import { Badge, Button, MaturityBadge, Modal, useToast } from '../../shared/ui'; import { Clock, @@ -51,6 +52,35 @@ export const EmployeesModule: React.FC = () => { const [shifts, setShifts] = useState(INITIAL_SHIFTS); 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 const [isNewEmpModalOpen, setIsNewEmpModalOpen] = useState(false); const [newEmpName, setNewEmpName] = useState(''); @@ -83,7 +113,8 @@ export const EmployeesModule: React.FC = () => { avatarInitials: initials, }; - setEmployees((prev) => [newEmp, ...prev]); + const updatedEmployees = [newEmp, ...employees]; + persistEmployees(updatedEmployees); setIsNewEmpModalOpen(false); setNewEmpName(''); showToast({ @@ -99,26 +130,24 @@ export const EmployeesModule: React.FC = () => { const hasConflict = editingShift.hours > 12; const conflictReason = hasConflict ? 'Смена превышает 12 ч (ТК РФ)' : undefined; - setShifts((prev) => { - const filtered = prev.filter( - (s) => !(s.employeeId === editingShift.empId && s.day === editingShift.day) - ); - if (editingShift.shiftText === 'Выходной' || editingShift.hours === 0) { - return filtered; - } - return [ - ...filtered, - { - employeeId: editingShift.empId, - day: editingShift.day, - shiftText: editingShift.shiftText, - hours: editingShift.hours, - hasConflict, - conflictReason, - }, - ]; - }); + const filtered = shifts.filter( + (s) => !(s.employeeId === editingShift.empId && s.day === editingShift.day) + ); + const updatedShifts = (editingShift.shiftText === 'Выходной' || editingShift.hours === 0) + ? filtered + : [ + ...filtered, + { + employeeId: editingShift.empId, + day: editingShift.day, + shiftText: editingShift.shiftText, + hours: editingShift.hours, + hasConflict, + conflictReason, + }, + ]; + persistShifts(updatedShifts); setEditingShift(null); showToast({ title: 'Смена обновлена', diff --git a/src/features/finance/FinanceModule.tsx b/src/features/finance/FinanceModule.tsx index e8bb8a6..2b6b056 100644 --- a/src/features/finance/FinanceModule.tsx +++ b/src/features/finance/FinanceModule.tsx @@ -1,4 +1,5 @@ import React, { useState, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { useFinance } from '../../services/hooks/useFinance'; import { TransactionCategory } from '../../services/contracts/finance'; import { Badge } from '../../shared/ui/Badge'; @@ -24,6 +25,7 @@ import { export const FinanceModule: React.FC = () => { const { showToast } = useToast(); + const [searchParams, setSearchParams] = useSearchParams(); const { accounts, transactions, @@ -31,7 +33,19 @@ export const FinanceModule: React.FC = () => { createManualTransaction, } = 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 [filterType, setFilterType] = useState<'all' | 'inflow' | 'outflow'>('all'); diff --git a/src/features/helpdesk/HelpDeskModule.tsx b/src/features/helpdesk/HelpDeskModule.tsx index c4c18a0..5dcce9a 100644 --- a/src/features/helpdesk/HelpDeskModule.tsx +++ b/src/features/helpdesk/HelpDeskModule.tsx @@ -1,5 +1,6 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { INITIAL_TICKETS, TicketEntity } from '../../mock/mockData'; +import { getStoreItem, putStoreItem } from '../../services/storage/indexedDb'; import { Badge, Button, MaturityBadge, Modal, useToast } from '../../shared/ui'; import { AlertCircle, @@ -33,6 +34,27 @@ const ASSIGNEES = ['Алина Соколова', 'Артем Васильев', export const HelpDeskModule: React.FC = () => { const { showToast } = useToast(); const [tickets, setTickets] = useState(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(null); const [search, setSearch] = useState(''); const [filterStatus, setFilterStatus] = useState('all'); @@ -109,9 +131,8 @@ export const HelpDeskModule: React.FC = () => { }; const handleUpdateTicketStatus = (ticketId: string, nextStatus: ExtendedTicket['status']) => { - setTickets((prev) => - prev.map((t) => (t.id === ticketId ? { ...t, status: nextStatus } : t)) - ); + const updated = tickets.map((t) => (t.id === ticketId ? { ...t, status: nextStatus } : t)); + persistTickets(updated); if (selectedTicket && selectedTicket.id === ticketId) { setSelectedTicket({ ...selectedTicket, status: nextStatus }); } @@ -123,9 +144,8 @@ export const HelpDeskModule: React.FC = () => { }; const handleUpdateAssignee = (ticketId: string, assignee: string) => { - setTickets((prev) => - prev.map((t) => (t.id === ticketId ? { ...t, assignee } : t)) - ); + const updated = tickets.map((t) => (t.id === ticketId ? { ...t, assignee } : t)); + persistTickets(updated); if (selectedTicket && selectedTicket.id === ticketId) { setSelectedTicket({ ...selectedTicket, assignee }); } @@ -153,7 +173,8 @@ export const HelpDeskModule: React.FC = () => { : { 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); setReplyText(''); diff --git a/src/features/marketing/MarketingModule.tsx b/src/features/marketing/MarketingModule.tsx index 3d9b82b..5410054 100644 --- a/src/features/marketing/MarketingModule.tsx +++ b/src/features/marketing/MarketingModule.tsx @@ -130,9 +130,9 @@ export const MarketingModule: React.FC = () => { revenue: 74800, }); showToast({ - title: 'Рассылка успешно завершена!', - message: '340 сообщений отправлено в Telegram-бот. Оформлено 34 новых заказа.', - type: 'success', + title: 'Имитация рассылки завершена (Демо)', + message: '340 сообщений смоделировано. Реальная отправка ожидает интеграции внешнего провайдера.', + type: 'info', }); }, 1200); }; @@ -241,12 +241,15 @@ export const MarketingModule: React.FC = () => {
-

+

Интерактивная симуляция запуска триггерной рассылки + + Демо-модель +

- Протестируйте отправку рассылки с промокодом по сегменту «Спящие клиенты» (340 получателей в базе) + Протестируйте отправку рассылки с промокодом по сегменту «Спящие клиенты» (340 получателей в базе). Подключение внешнего провайдера ожидает интеграции бэкенда.

diff --git a/src/features/operations/OperationsModule.tsx b/src/features/operations/OperationsModule.tsx index b93eb8c..27400be 100644 --- a/src/features/operations/OperationsModule.tsx +++ b/src/features/operations/OperationsModule.tsx @@ -1,5 +1,5 @@ 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 { PosView } from './PosView'; import { KdsView } from './KdsView'; @@ -20,47 +20,49 @@ import { formatCurrency } from '../../shared/utils/formatters'; export const OperationsModule: React.FC = () => { const location = useLocation(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const { getActiveProfile, getActiveCapabilities, setIsQuickActionsOpen, activeLocation } = useDemoStore(); const profile = getActiveProfile(); const capabilities = getActiveCapabilities(); const currentPath = location.pathname; + const tabParam = (searchParams.get('tab') || '').toLowerCase(); - // Sub-routes dispatcher - if (currentPath === '/operations/pos') { + // Sub-routes dispatcher (supports both direct path and deep link query params) + if (currentPath === '/operations/pos' || tabParam === 'pos') { return ; } - if (currentPath === '/operations/kds') { + if (currentPath === '/operations/kds' || tabParam === 'kds') { return ; } - if (currentPath === '/operations/dining') { + if (currentPath === '/operations/dining' || tabParam === 'dining') { return ; } - if (currentPath === '/operations/work-orders') { + if (currentPath === '/operations/work-orders' || tabParam === 'work-orders') { return ; } - if (currentPath === '/operations/booking') { + if (currentPath === '/operations/booking' || tabParam === 'booking') { return ; } - if (currentPath === '/operations/delivery') { + if (currentPath === '/operations/delivery' || tabParam === 'delivery') { return ; } - if (currentPath === '/operations/courier') { + if (currentPath === '/operations/courier' || tabParam === 'courier') { return ; } - if (currentPath === '/operations/recipes') { + if (currentPath === '/operations/recipes' || tabParam === 'recipes') { return ; } - if (currentPath === '/operations/inventory') { + if (currentPath === '/operations/inventory' || tabParam === 'inventory') { return ; } - if (currentPath === '/operations/orders') { + if (currentPath === '/operations/orders' || tabParam === 'orders') { return ; } - if (currentPath === '/operations/purchasing') { + if (currentPath === '/operations/purchasing' || tabParam === 'purchasing') { return ; } - if (currentPath === '/operations/bays') { + if (currentPath === '/operations/bays' || tabParam === 'bays') { return ; } diff --git a/src/features/tasks/TasksModule.tsx b/src/features/tasks/TasksModule.tsx index 471f62f..3f3d043 100644 --- a/src/features/tasks/TasksModule.tsx +++ b/src/features/tasks/TasksModule.tsx @@ -1,5 +1,6 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { INITIAL_TASKS, TaskEntity } from '../../mock/mockData'; +import { getStoreItem, putStoreItem } from '../../services/storage/indexedDb'; import { Badge, MaturityBadge, Button, Modal } from '../../shared/ui'; import { Clock, @@ -27,6 +28,26 @@ export const TasksModule: React.FC = () => { const [tasks, setTasks] = useState(INITIAL_TASKS); 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 const [selectedTask, setSelectedTask] = useState(null); const [subtasks, setSubtasks] = useState([ @@ -41,7 +62,8 @@ export const TasksModule: React.FC = () => { const [newComment, setNewComment] = useState(''); 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) { setSelectedTask({ ...selectedTask, status: nextStatus }); } diff --git a/src/services/__tests__/paginationAndNumbering.test.ts b/src/services/__tests__/paginationAndNumbering.test.ts new file mode 100644 index 0000000..06efebf --- /dev/null +++ b/src/services/__tests__/paginationAndNumbering.test.ts @@ -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'); + }); + }); +}); diff --git a/src/services/mock/baseRepository.ts b/src/services/mock/baseRepository.ts index 872a144..c5d563f 100644 --- a/src/services/mock/baseRepository.ts +++ b/src/services/mock/baseRepository.ts @@ -12,6 +12,7 @@ import { getRecordsByEntity, putStoreItem, RecordEntity, + generateMonotonicNumber, } from '../storage/indexedDb'; import { ulid } from '../../shared/lib/ulid'; import { useDemoStore } from '../../stores/demo'; @@ -98,8 +99,18 @@ export class BaseMockRepository implements IRepository const userId = ctx.actorId || useDemoStore.getState().currentRoleId || 'demo_user'; const id = ulid(); + const entityData = { ...data } as Record; + + 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 = { - ...data, + ...entityData, id, entityType: this.entityName, tenant_id: ctx.tenantId, diff --git a/src/services/storage/indexedDb.ts b/src/services/storage/indexedDb.ts index 47a3811..1f27f75 100644 --- a/src/services/storage/indexedDb.ts +++ b/src/services/storage/indexedDb.ts @@ -8,7 +8,7 @@ import { import { ulid } from '../../shared/lib/ulid'; export const DB_NAME = 'krona_business_os_db'; -export const DB_VERSION = 2; +export const DB_VERSION = 3; export const DB_OBJECT_STORES = [ 'entities_meta', @@ -18,6 +18,7 @@ export const DB_OBJECT_STORES = [ 'records_data', 'audit_log', 'settings', + 'counters', ] as const; export type DbStoreName = (typeof DB_OBJECT_STORES)[number]; @@ -53,6 +54,7 @@ export interface DatabaseSnapshot { records_data: unknown[]; audit_log: unknown[]; settings: unknown[]; + counters?: unknown[]; }; metadata: { totalRecords: number; @@ -127,12 +129,44 @@ export function getDb(): Promise { if (!db.objectStoreNames.contains('settings')) { db.createObjectStore('settings', { keyPath: 'key' }); } + + // 8. counters + if (!db.objectStoreNames.contains('counters')) { + db.createObjectStore('counters', { keyPath: 'key' }); + } }, }); } return dbInstancePromise; } +export interface CounterItem { + key: string; + currentValue: number; +} + +export async function getNextMonotonicSequence(prefix: string, tenantId?: string): Promise { + 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 { + const nextSeq = await getNextMonotonicSequence(prefix, tenantId); + return `${prefix}-${String(nextSeq).padStart(padLength, '0')}`; +} + // ---------------------------------------------------- // CRUD on records_data with Tenant Isolation // ---------------------------------------------------- @@ -371,6 +405,7 @@ export async function exportDatabaseSnapshot(tenantId?: string): Promise[]; let settings = (await db.getAll('settings')) as Record[]; + let counters = (await db.getAll('counters')) as CounterItem[]; if (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 !v.tenant_id || v.tenant_id === 'system' || v.tenant_id === tenantId ); settings = settings.filter((s) => !s.tenant_id || s.tenant_id === tenantId); + counters = counters.filter((c) => c.key.startsWith(`${tenantId}:`)); } return { @@ -401,6 +437,7 @@ export async function exportDatabaseSnapshot(tenantId?: string): Promise { await tx.done; } else { // 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 allRecords = (await recordsStore.getAll()) as RecordEntity[]; for (const r of allRecords) { @@ -532,6 +569,13 @@ export async function resetDatabase(tenantId?: string): Promise { 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; }