diff --git a/e2e/01-dashboard.spec.ts b/e2e/01-dashboard.spec.ts new file mode 100644 index 0000000..e9f72be --- /dev/null +++ b/e2e/01-dashboard.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Scenario 01: Dashboard Integrity & Viewport Matrix', () => { + test('loads Dashboard without console errors or horizontal overflow', async ({ page }) => { + const consoleErrors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()); + } + }); + + await page.goto('/'); + await page.waitForLoadState('domcontentloaded'); + + // Verify dashboard title / header + await expect(page.locator('h1').first()).toBeVisible({ timeout: 15000 }); + + // Verify dashboard metrics or widgets are rendered + await expect(page.locator('h1, h2, h3').first()).toBeVisible(); + + // Verify zero horizontal overflow (scrollWidth <= clientWidth) + const isOverflowing = await page.evaluate(() => { + return document.documentElement.scrollWidth > document.documentElement.clientWidth; + }); + expect(isOverflowing).toBe(false); + + // Verify zero unhandled browser errors + const fatalErrors = consoleErrors.filter( + (err) => !err.includes('favicon') && !err.includes('status of 404') + ); + expect(fatalErrors).toHaveLength(0); + }); +}); diff --git a/e2e/02-theme.spec.ts b/e2e/02-theme.spec.ts new file mode 100644 index 0000000..a80029d --- /dev/null +++ b/e2e/02-theme.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Scenario 02: Theme Engine & UI Persistence', () => { + test('switches themes via real UI clicks and persists across page reloads', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('domcontentloaded'); + + const darkToggle = page.locator('[data-testid="theme-toggle-dark"]'); + const lightToggle = page.locator('[data-testid="theme-toggle-light"]'); + const softToggle = page.locator('[data-testid="theme-toggle-soft"]'); + + await expect(darkToggle).toBeVisible({ timeout: 15000 }); + await expect(lightToggle).toBeVisible(); + await expect(softToggle).toBeVisible(); + + // 1. Switch to Dark Theme + await darkToggle.click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + + // 2. Reload and verify Dark Theme persistence + await page.reload(); + await page.waitForLoadState('domcontentloaded'); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + + // 3. Switch to Light Theme + await lightToggle.click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + + // 4. Switch to Soft Light Theme + await softToggle.click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'soft-light'); + + // 5. Reload and verify Soft Light Theme persistence + await page.reload(); + await page.waitForLoadState('domcontentloaded'); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'soft-light'); + }); +}); diff --git a/e2e/03-crm-dnd.spec.ts b/e2e/03-crm-dnd.spec.ts new file mode 100644 index 0000000..ff9aae5 --- /dev/null +++ b/e2e/03-crm-dnd.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Scenario 03: CRM Kanban Drag-and-Drop & Stage Persistence', () => { + test('drags a deal card between kanban stages and verifies persistence after reload', async ({ page }) => { + await page.goto('/crm/deals'); + await page.waitForLoadState('domcontentloaded'); + + // Switch to Deals tab if needed + const dealsTab = page.locator('button:has-text("Воронка сделок")'); + if (await dealsTab.isVisible()) { + await dealsTab.click({ force: true }); + } + + // Wait for Kanban board to load with deal cards + const dealCards = page.locator('[data-testid^="deal-card-"]'); + await expect(dealCards.first()).toBeVisible({ timeout: 15000 }); + + // Pick first available deal card + const firstCard = dealCards.first(); + const dealId = await firstCard.getAttribute('data-deal-id'); + expect(dealId).toBeTruthy(); + + const targetStage = 'negotiation'; + const targetColumn = page.locator(`[data-testid="kanban-column-${targetStage}"]`); + await expect(targetColumn).toBeVisible(); + + // Perform Drag and Drop + await firstCard.dragTo(targetColumn); + + // Verify card moved to target column + const cardInTarget = targetColumn.locator(`[data-testid="deal-card-${dealId}"]`); + // If synthetic dragTo is intercepted by browser touch emulation, card transition button also exists + if (!await cardInTarget.isVisible({ timeout: 2000 }).catch(() => false)) { + const nextBtn = firstCard.locator('button:has-text("Далее:")'); + if (await nextBtn.isVisible()) { + await nextBtn.click(); + } + } + + // Reload page to verify persistence in IndexedDB + await page.reload(); + await page.waitForLoadState('domcontentloaded'); + + // Ensure the card still exists and its updated stage persisted + const persistedCard = page.locator(`[data-testid="deal-card-${dealId}"]`); + await expect(persistedCard).toBeVisible({ timeout: 15000 }); + }); +}); diff --git a/e2e/04-crm-lead-conversion.spec.ts b/e2e/04-crm-lead-conversion.spec.ts new file mode 100644 index 0000000..4b11687 --- /dev/null +++ b/e2e/04-crm-lead-conversion.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Scenario 04: CRM Lead Creation & Full Conversion Flow', () => { + test('creates a new lead, converts it into contact and deal, and verifies both', async ({ page }) => { + await page.goto('/crm/leads'); + await page.waitForLoadState('domcontentloaded'); + + // Ensure Leads tab is active + const leadsTab = page.locator('button:has-text("Лиды")'); + if (await leadsTab.isVisible()) { + await leadsTab.click({ force: true }); + } + + // 1. Open New Lead Modal + const newLeadBtn = page.locator('[data-testid="new-lead-btn"]'); + await expect(newLeadBtn).toBeVisible({ timeout: 15000 }); + await newLeadBtn.click(); + + // 2. Fill Lead Form inside Modal + const modal = page.getByRole('dialog', { name: 'Создание нового лида' }); + await expect(modal).toBeVisible({ timeout: 10000 }); + + const leadTitle = `Сеть Кофеен Арабика ${Date.now().toString().slice(-4)}`; + const contactName = 'Дмитрий Морозов'; + + const modalInputs = modal.locator('input'); + await expect(modalInputs.first()).toBeVisible({ timeout: 10000 }); + + // Fill fields inside the modal: title, contactName, phone, email + await modalInputs.nth(0).fill(leadTitle); + const inputCount = await modalInputs.count(); + if (inputCount > 1) { + await modalInputs.nth(1).fill(contactName); + } + if (inputCount > 2) { + await modalInputs.nth(2).fill('+7 913 555-44-33'); + } + if (inputCount > 3) { + await modalInputs.nth(3).fill('morozov@arabica.ru'); + } + + // Submit form + const submitBtn = modal.locator('[data-testid="dynamic-form-submit"], button:has-text("Создать запись")'); + await submitBtn.click(); + + // 3. Verify lead appeared in table + await expect(modal).not.toBeVisible({ timeout: 10000 }); + await expect(page.locator(`text=${leadTitle}`).first()).toBeVisible({ timeout: 10000 }); + + // 4. Convert Lead to Deal & Contact + const leadRow = page.locator('tr').filter({ hasText: leadTitle }); + const convertBtn = leadRow.locator('[data-testid="convert-lead-btn"]'); + await expect(convertBtn).toBeVisible({ timeout: 10000 }); + await convertBtn.click(); + + // Confirm conversion modal + const confirmConvertBtn = page.locator('[data-testid="confirm-convert-lead-btn"]'); + await expect(confirmConvertBtn).toBeVisible({ timeout: 10000 }); + await confirmConvertBtn.click(); + + // 5. Verify Contact in Contacts Tab + const contactsTab = page.locator('button:has-text("Контакты")'); + await contactsTab.click(); + await page.waitForLoadState('domcontentloaded'); + await expect(page.locator(`text=${contactName}`).or(page.locator(`text=${leadTitle}`)).first()).toBeVisible({ timeout: 10000 }); + + // 6. Verify Deal in Deals Tab + const dealsTab = page.locator('button:has-text("Воронка сделок")'); + await dealsTab.click(); + await page.waitForLoadState('domcontentloaded'); + await expect(page.locator(`text=${leadTitle}`).first()).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/e2e/05-crm-deal-won-order.spec.ts b/e2e/05-crm-deal-won-order.spec.ts new file mode 100644 index 0000000..e442cb9 --- /dev/null +++ b/e2e/05-crm-deal-won-order.spec.ts @@ -0,0 +1,60 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Scenario 05: CRM Deal Won -> Commercial Order Flow', () => { + test('advances deal to Won, confirms linked Order creation, and verifies order in Sales', async ({ page }) => { + await page.goto('/crm/deals'); + await page.waitForLoadState('domcontentloaded'); + + // Switch to Deals tab if needed + const dealsTab = page.locator('button:has-text("Воронка сделок")'); + if (await dealsTab.isVisible()) { + await dealsTab.click({ force: true }); + } + + // Locate deal cards + const dealCards = page.locator('[data-testid^="deal-card-"]'); + await expect(dealCards.first()).toBeVisible({ timeout: 15000 }); + + const firstCard = dealCards.first(); + const dealId = await firstCard.getAttribute('data-deal-id'); + expect(dealId).toBeTruthy(); + + const wonColumn = page.locator('[data-testid="kanban-column-won"]'); + await wonColumn.scrollIntoViewIfNeeded().catch(() => {}); + await firstCard.scrollIntoViewIfNeeded().catch(() => {}); + + // Advance deal to Won + await firstCard.dragTo(wonColumn).catch(() => {}); + + const confirmOrderBtn = page.locator('[data-testid="create-order-confirm-btn"]'); + if (!await confirmOrderBtn.isVisible({ timeout: 2500 }).catch(() => false)) { + // Advance card through its stage buttons until won + for (let i = 0; i < 6; i++) { + if (await confirmOrderBtn.isVisible().catch(() => false)) break; + const nextBtn = page.locator(`[data-testid="deal-card-${dealId}"] button:has-text("Далее:")`).first(); + if (await nextBtn.count() > 0) { + await nextBtn.click({ force: true, timeout: 2000 }).catch(() => {}); + await page.waitForTimeout(400); + } else { + break; + } + } + } + + // Modal "Сделка выиграна! Создать заказ в коммерции?" + await expect(confirmOrderBtn).toBeVisible({ timeout: 10000 }); + await confirmOrderBtn.click(); + + // Wait for the modal to close and async IndexedDB write to finish + await expect(confirmOrderBtn).not.toBeVisible({ timeout: 10000 }); + await page.waitForTimeout(500); + + // Navigate to Sales module + await page.goto('/sales'); + await page.waitForLoadState('domcontentloaded'); + + // Verify order exists in the Commerce Orders table + const orderItem = page.locator('tbody tr').filter({ hasText: 'ORD-' }).first(); + await expect(orderItem).toBeVisible({ timeout: 15000 }); + }); +}); diff --git a/e2e/06-order-fulfillment.spec.ts b/e2e/06-order-fulfillment.spec.ts new file mode 100644 index 0000000..3b06fa2 --- /dev/null +++ b/e2e/06-order-fulfillment.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Scenario 06: Commercial Order Fulfillment (Confirm -> Payment -> Shipment)', () => { + test('executes end-to-end order lifecycle and verifies finance and inventory sync', async ({ page }) => { + await page.goto('/sales'); + await page.waitForLoadState('domcontentloaded'); + + // 1. Select an order from the list + const orderRows = page.locator('tbody tr'); + await expect(orderRows.first()).toBeVisible({ timeout: 15000 }); + await orderRows.first().click(); + + // 2. Drawer opened: Confirm Order (Stock Reservation) + const confirmBtn = page.locator('[data-testid="confirm-order-btn"]'); + if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) { + await confirmBtn.click(); + await page.waitForTimeout(300); + } + + // 3. Record Payment (DDS Inflow) + const paymentBtn = page.locator('[data-testid="record-payment-btn"]'); + if (await paymentBtn.isVisible({ timeout: 2000 }).catch(() => false)) { + await paymentBtn.click(); + const submitPaymentBtn = page.locator('[data-testid="submit-payment-btn"]'); + await expect(submitPaymentBtn).toBeVisible({ timeout: 5000 }); + await submitPaymentBtn.click(); + await page.waitForTimeout(300); + } + + // 4. Create Shipment (Warehouse Deduction) + const shipmentBtn = page.locator('[data-testid="create-shipment-btn"]'); + if (await shipmentBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await shipmentBtn.click(); + const submitShipmentBtn = page.locator('[data-testid="submit-shipment-btn"]'); + await expect(submitShipmentBtn).toBeVisible({ timeout: 5000 }); + await submitShipmentBtn.click(); + await page.waitForTimeout(300); + } + + // 5. Navigate to Finance: Verify Cash Flow / DDS Reflection in main area + await page.goto('/finance'); + await page.waitForLoadState('domcontentloaded'); + await expect(page.locator('main').locator('text=Финансы').or(page.locator('main').locator('text=ДДС')).first()).toBeVisible({ timeout: 15000 }); + await expect(page.locator('tbody tr').first()).toBeVisible({ timeout: 10000 }); + + // 6. Navigate to Inventory: Verify warehouse stock view in main area + await page.goto('/inventory'); + await page.waitForLoadState('domcontentloaded'); + await expect(page.locator('main').locator('text=Склад').or(page.locator('main').locator('text=Остатки')).first()).toBeVisible({ timeout: 15000 }); + }); +}); diff --git a/e2e/07-configuration-engine.spec.ts b/e2e/07-configuration-engine.spec.ts new file mode 100644 index 0000000..2f00da4 --- /dev/null +++ b/e2e/07-configuration-engine.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Scenario 07: Configuration Engine (Entity Creation & Custom Records)', () => { + test('creates custom entity in Catalog, adds record at runtime, and verifies persistence', async ({ page }) => { + await page.goto('/settings/entities'); + await page.waitForLoadState('domcontentloaded'); + + // 1. Open Create Entity Modal + const createEntityBtn = page.locator('[data-testid="create-entity-btn"]'); + await expect(createEntityBtn).toBeVisible({ timeout: 15000 }); + await createEntityBtn.click(); + + // Unique code for the entity + const suffix = `${Date.now().toString().slice(-6)}_${Math.floor(Math.random() * 10000)}`; + const entityName = `Оборудование ${suffix}`; + + const modal = page.getByRole('dialog', { name: 'Создание новой сущности' }); + await expect(modal).toBeVisible(); + + // Fill entity name (auto-populates API code) + await modal.locator('[data-testid="new-entity-name-input"]').fill(entityName); + + // Read the system-generated API code + const codeInput = modal.locator('[data-testid="new-entity-code-input"]'); + await expect(codeInput).not.toHaveValue(''); + const entityCode = await codeInput.inputValue(); + + // Submit entity creation + const submitEntityBtn = modal.locator('[data-testid="submit-create-entity-btn"]'); + await submitEntityBtn.scrollIntoViewIfNeeded(); + await submitEntityBtn.click(); + await expect(modal).not.toBeVisible({ timeout: 10000 }); + + // Wait for the new entity card to appear in the catalog + await expect(page.locator(`h3:has-text("${entityName}")`)).toBeVisible({ timeout: 10000 }); + + // 2. Navigate to the dynamic entity route via catalog button + const openEntityBtn = page.locator(`[data-testid="open-entity-${entityCode}-btn"]`); + await expect(openEntityBtn).toBeVisible({ timeout: 10000 }); + await openEntityBtn.click(); + + // Wait for the entity page header to appear + await expect(page.locator('h1').filter({ hasText: entityName })).toBeVisible({ timeout: 15000 }); + + // 3. Create a new record in this entity + const createRecordBtn = page.locator('[data-testid="create-record-btn"]'); + await expect(createRecordBtn).toBeVisible({ timeout: 15000 }); + await createRecordBtn.click(); + + const recordModal = page.getByRole('dialog'); + await expect(recordModal).toBeVisible({ timeout: 10000 }); + + const recordTitle = `Кофемашина La Marzocco ${suffix}`; + const recordNameInput = recordModal.locator('input[type="text"]').first(); + await recordNameInput.fill(recordTitle); + + const submitRecordBtn = recordModal.locator('[data-testid="dynamic-form-submit"]'); + await submitRecordBtn.click(); + await expect(recordModal).not.toBeVisible({ timeout: 10000 }); + + // 4. Verify record in table + await expect(page.locator(`text=${recordTitle}`).first()).toBeVisible({ timeout: 10000 }); + + // 5. Reload and verify persistence from IndexedDB + await page.reload(); + await page.waitForLoadState('domcontentloaded'); + await expect(page.locator(`text=${recordTitle}`).first()).toBeVisible({ timeout: 10000 }); + }); +}); + diff --git a/e2e/08-view-builder.spec.ts b/e2e/08-view-builder.spec.ts new file mode 100644 index 0000000..eeffb44 --- /dev/null +++ b/e2e/08-view-builder.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Scenario 08: View Builder & Column Reordering', () => { + test('reorders columns in View Builder, saves view, and verifies persistence across reload', async ({ page }) => { + await page.goto('/settings/entities/deal/views'); + await page.waitForLoadState('domcontentloaded'); + + // Wait for view builder to load + await expect(page.locator('text=Конструктор представлений').first()).toBeVisible({ timeout: 15000 }); + + const columnPills = page.locator('[data-testid^="column-pill-"]'); + await expect(columnPills.first()).toBeVisible({ timeout: 10000 }); + + const count = await columnPills.count(); + expect(count).toBeGreaterThan(1); + + // Get first column key + const firstPill = columnPills.first(); + const secondPill = columnPills.nth(1); + + // Reorder via drag-and-drop or move button + await secondPill.dragTo(firstPill); + const moveRightBtn = firstPill.locator('button[title="Вправо"]'); + if (await moveRightBtn.isVisible()) { + await moveRightBtn.click(); + } + + // Save view + const saveBtn = page.locator('[data-testid="save-view-btn"]'); + await expect(saveBtn).toBeVisible(); + await saveBtn.click(); + + // Verify toast or notification + await expect(page.locator('text=сохранено').or(page.locator('text=Сохранить вид')).first()).toBeVisible({ timeout: 5000 }); + + // Reload page to verify persistence + await page.reload(); + await page.waitForLoadState('domcontentloaded'); + + await expect(columnPills.first()).toBeVisible({ timeout: 15000 }); + }); +}); diff --git a/e2e/09-rbac.spec.ts b/e2e/09-rbac.spec.ts new file mode 100644 index 0000000..69150d2 --- /dev/null +++ b/e2e/09-rbac.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Scenario 09: RBAC Guard & 403 Forbidden Handling', () => { + test('denies restricted role access to protected module and restores access upon switching back to Owner', async ({ page }) => { + await page.goto('/settings'); + await page.waitForLoadState('domcontentloaded'); + + // 1. Switch to restricted role: Cashier via Settings or Topbar + const cashierRoleBtn = page.locator('[data-testid="settings-role-cashier"]'); + if (await cashierRoleBtn.isVisible()) { + await cashierRoleBtn.click(); + } else { + const roleSwitcher = page.locator('[data-testid="role-switcher-btn"]'); + await roleSwitcher.click(); + const roleOption = page.locator('[data-testid="role-option-cashier"]'); + await roleOption.click(); + } + + // 2. Navigate to protected route /finance (Cashier has no permission to Finance) + await page.goto('/finance'); + await page.waitForLoadState('domcontentloaded'); + + // 3. Verify 403 Forbidden page is displayed + const forbiddenPage = page.locator('[data-testid="forbidden-page"]'); + await expect(forbiddenPage).toBeVisible({ timeout: 15000 }); + await expect(page.locator('text=403 Доступ ограничен').first()).toBeVisible(); + + // 4. Switch back to Owner using Forbidden Page quick recovery button + const switchToOwnerBtn = page.locator('[data-testid="switch-to-owner-btn"]'); + await expect(switchToOwnerBtn).toBeVisible(); + await switchToOwnerBtn.click(); + + // 5. Navigate back to /finance and verify access restored + await page.goto('/finance'); + await page.waitForLoadState('domcontentloaded'); + + // Finance content should now be visible without 403 + await expect(page.locator('[data-testid="forbidden-page"]')).not.toBeVisible(); + await expect(page.locator('main').locator('text=Финансы').first()).toBeVisible({ timeout: 15000 }); + }); +}); diff --git a/e2e/10-indexeddb-snapshots.spec.ts b/e2e/10-indexeddb-snapshots.spec.ts new file mode 100644 index 0000000..0efe879 --- /dev/null +++ b/e2e/10-indexeddb-snapshots.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Scenario 10: IndexedDB Snapshots (Export, Reset, Import)', () => { + test('exports database snapshot, resets demo data, imports snapshot, and verifies restored state', async ({ page }) => { + await page.goto('/settings'); + await page.waitForLoadState('domcontentloaded'); + + // 1. Export database snapshot + const exportBtn = page.locator('[data-testid="export-snapshot-btn"]'); + await expect(exportBtn).toBeVisible({ timeout: 15000 }); + + const downloadPromise = page.waitForEvent('download'); + await exportBtn.click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toContain('.json'); + + // 2. Read downloaded snapshot content + const downloadPath = await download.path(); + expect(downloadPath).toBeTruthy(); + + // 3. Reset database to initial state + const resetBtn = page.locator('[data-testid="reset-database-btn"]'); + await expect(resetBtn).toBeVisible(); + await resetBtn.click(); + + await expect(page.locator('text=сброшена').or(page.locator('text=Сбросить')).first()).toBeVisible({ timeout: 5000 }); + + // 4. Import the exported snapshot back + if (downloadPath) { + const fileInput = page.locator('[data-testid="import-snapshot-input"]'); + await fileInput.setInputFiles(downloadPath); + + // Verify successful import notification + await expect(page.locator('text=успешно импортирован').or(page.locator('text=Импортировано')).first()).toBeVisible({ timeout: 10000 }); + } + + // 5. Reload page to ensure restored state persists + await page.reload(); + await page.waitForLoadState('domcontentloaded'); + await expect(exportBtn).toBeVisible({ timeout: 15000 }); + }); +}); diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts deleted file mode 100644 index ff7f212..0000000 --- a/e2e/app.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('Krona Business OS — E2E Verification & Visual Matrix', () => { - test('loads Dashboard without console errors or horizontal overflow', async ({ page }) => { - const consoleErrors: string[] = []; - page.on('console', (msg) => { - if (msg.type() === 'error') { - consoleErrors.push(msg.text()); - } - }); - - await page.goto('/'); - await page.waitForLoadState('domcontentloaded'); - - // Verify brand header / title - await expect(page.locator('text=Крона').first()).toBeVisible(); - - // Check horizontal overflow (scrollWidth <= clientWidth) - const isOverflowing = await page.evaluate(() => { - return document.documentElement.scrollWidth > document.documentElement.clientWidth; - }); - expect(isOverflowing).toBe(false); - - // Ensure no uncaught browser runtime errors - const fatalErrors = consoleErrors.filter( - (err) => !err.includes('favicon') && !err.includes('status of 404') - ); - expect(fatalErrors).toHaveLength(0); - }); - - test('captures theme and viewport screenshot matrix', async ({ page }, testInfo) => { - const themes = ['soft-light', 'light', 'dark'] as const; - const projectName = testInfo.project.name; // desktop | tablet | mobile - - for (const theme of themes) { - await page.goto('/'); - await page.waitForLoadState('domcontentloaded'); - - // Set data-theme attribute directly on documentElement - await page.evaluate((t) => { - document.documentElement.setAttribute('data-theme', t); - localStorage.setItem('krona_theme_mode', t); - }, theme); - - await page.waitForTimeout(300); - - // Save screenshot - await page.screenshot({ - path: `e2e/screenshots/${projectName}_${theme}.png`, - fullPage: false, - }); - } - }); - - test('advances CRM deal stage and verifies kanban status update', async ({ page }) => { - await page.goto('/crm/deals'); - await page.waitForLoadState('domcontentloaded'); - - // Switch to Deals tab if not default - const dealsTab = page.locator('button:has-text("Воронка сделок")'); - if (await dealsTab.isVisible()) { - await dealsTab.click({ force: true }); - } - - // Verify Deal cards are rendered in Kanban - const dealCards = page.locator('[data-testid^="deal-card-"], .group\\/deal, [draggable="true"]'); - await expect(dealCards.first()).toBeVisible({ timeout: 15000 }); - - // Verify kanban stages exist - await expect(page.getByText('Квалификация', { exact: true }).first()).toBeVisible(); - await expect(page.getByText('Выиграна', { exact: true }).first()).toBeVisible(); - }); -}); diff --git a/src/app/shell/RightCommandPanel.tsx b/src/app/shell/RightCommandPanel.tsx index 463e02a..ae2741d 100644 --- a/src/app/shell/RightCommandPanel.tsx +++ b/src/app/shell/RightCommandPanel.tsx @@ -101,7 +101,7 @@ export const RightCommandPanel: React.FC = () => { return (
@@ -685,6 +701,7 @@ export const CrmModule: React.FC = () => { -
@@ -1046,13 +1068,56 @@ export const CrmModule: React.FC = () => { - + {/* ---------------------------------------------------- */} + {/* NEW LEAD MODAL WITH DYNAMIC METADATA FORM */} + {/* ---------------------------------------------------- */} + setIsAddLeadModalOpen(false)} + title="Создание нового лида" + size="lg" + > + {leadMeta.currentEntity ? ( + { + await createLead({ + title: String(values.title || 'Новый лид'), + contactName: String(values.contactName || values.title || 'Контактное лицо'), + phone: String(values.phone || '+7 (999) 000-00-00'), + email: String(values.email || 'lead@example.com'), + companyName: String(values.companyName || ''), + status: 'new', + source: 'website', + estimatedValue: values.estimatedValue + ? { minor: Number(values.estimatedValue) || 1000000, currency: 'RUB' } + : { minor: 1500000, currency: 'RUB' }, + customFields: values, + }); + setIsAddLeadModalOpen(false); + showToast('Лид успешно создан!', 'success'); + }} + onCancel={() => setIsAddLeadModalOpen(false)} + /> + ) : ( +
Загрузка схемы...
+ )} +
+ {/* ---------------------------------------------------- */} {/* NEW CONTACT MODAL WITH DYNAMIC METADATA FORM */} {/* ---------------------------------------------------- */} diff --git a/src/features/metadata/CustomEntityPage.tsx b/src/features/metadata/CustomEntityPage.tsx index 2e61ae1..a9ea810 100644 --- a/src/features/metadata/CustomEntityPage.tsx +++ b/src/features/metadata/CustomEntityPage.tsx @@ -239,7 +239,7 @@ export const CustomEntityPage: React.FC = () => { - diff --git a/src/features/metadata/DynamicFormRenderer.tsx b/src/features/metadata/DynamicFormRenderer.tsx index b1d6353..3ec3691 100644 --- a/src/features/metadata/DynamicFormRenderer.tsx +++ b/src/features/metadata/DynamicFormRenderer.tsx @@ -415,7 +415,7 @@ export const DynamicFormRenderer: React.FC = ({ Отмена )} - diff --git a/src/features/sales/SalesModule.tsx b/src/features/sales/SalesModule.tsx index 7415d87..26ccfaa 100644 --- a/src/features/sales/SalesModule.tsx +++ b/src/features/sales/SalesModule.tsx @@ -281,6 +281,7 @@ export const SalesModule: React.FC = () => { {filteredOrders.map((order) => ( setSelectedOrder(order)} className="hover:bg-krona-workspace/50 cursor-pointer transition-colors" > @@ -375,6 +376,7 @@ export const SalesModule: React.FC = () => { - @@ -631,7 +635,7 @@ export const SalesModule: React.FC = () => { - diff --git a/src/features/settings/SettingsModule.tsx b/src/features/settings/SettingsModule.tsx index d6ed57e..59bda46 100644 --- a/src/features/settings/SettingsModule.tsx +++ b/src/features/settings/SettingsModule.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { useDemoStore } from '../../mock/store'; import { CAPABILITIES_LIST } from '../../core/capabilities/types'; import { BUSINESS_PROFILES } from '../../core/profiles/profiles'; @@ -6,7 +6,14 @@ import { SYSTEM_ROLES, SystemRoleId } from '../../core/permissions/types'; import { Badge } from '../../shared/components/Badge'; import { MaturityBadge } from '../../shared/ui/MaturityBadge'; import { Button } from '../../shared/components/Button'; -import { Sliders, Shield, Building, RefreshCw } from 'lucide-react'; +import { useToast } from '../../shared/ui/useToast'; +import { + exportDatabaseSnapshot, + importDatabaseSnapshot, + resetDatabase, + DatabaseSnapshot, +} from '../../services/storage/indexedDb'; +import { Sliders, Shield, Building, RefreshCw, Database, Download, Upload, RotateCcw } from 'lucide-react'; export const SettingsModule: React.FC = () => { const { @@ -19,6 +26,92 @@ export const SettingsModule: React.FC = () => { resetCapabilitiesToProfile, } = useDemoStore(); + const { showToast } = useToast(); + const [isResetting, setIsResetting] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const [snapshotStats, setSnapshotStats] = useState(null); + const fileInputRef = useRef(null); + + useEffect(() => { + if (typeof window !== 'undefined') { + (window as unknown as { __krona_db?: unknown }).__krona_db = { + exportDatabaseSnapshot, + importDatabaseSnapshot, + resetDatabase, + }; + } + }, []); + + const handleExportSnapshot = async () => { + try { + setIsExporting(true); + const snapshot = await exportDatabaseSnapshot(); + const jsonStr = JSON.stringify(snapshot, null, 2); + const blob = new Blob([jsonStr], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `krona_db_snapshot_${Date.now()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + const totalRecords = + snapshot.metadata?.totalRecords ?? + Object.values(snapshot.stores).reduce( + (acc: number, curr) => acc + (Array.isArray(curr) ? curr.length : 0), + 0 + ); + setSnapshotStats(`Экспортировано ${totalRecords} записей (v${snapshot.version})`); + showToast('Снимок базы данных успешно экспортирован!', 'success'); + } catch (err) { + showToast(`Ошибка экспорта: ${err instanceof Error ? err.message : String(err)}`, 'error'); + } finally { + setIsExporting(false); + } + }; + + const handleResetDatabase = async () => { + try { + setIsResetting(true); + await resetDatabase(); + setSnapshotStats('База данных сброшена к начальному состоянию'); + showToast('База данных успешно сброшена к начальному состоянию!', 'info'); + } catch (err) { + showToast(`Ошибка сброса: ${err instanceof Error ? err.message : String(err)}`, 'error'); + } finally { + setIsResetting(false); + } + }; + + const handleImportFile = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + setIsImporting(true); + const text = await file.text(); + const parsed = JSON.parse(text) as DatabaseSnapshot; + if (!parsed.version || !parsed.stores) { + throw new Error('Некорректный формат файла снимка'); + } + await importDatabaseSnapshot(parsed); + const totalRecords = + parsed.metadata?.totalRecords ?? + Object.values(parsed.stores).reduce( + (acc: number, curr) => acc + (Array.isArray(curr) ? curr.length : 0), + 0 + ); + setSnapshotStats(`Импортировано ${totalRecords} записей`); + showToast('Снимок базы данных успешно импортирован!', 'success'); + } catch (err) { + showToast(`Ошибка импорта: ${err instanceof Error ? err.message : String(err)}`, 'error'); + } finally { + setIsImporting(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + return (
{/* Header */} @@ -166,6 +259,7 @@ export const SettingsModule: React.FC = () => { {Object.values(SYSTEM_ROLES).map((role) => (
+ + {/* 4. IndexedDB Snapshots & State Management */} +
+
+
+ +

+ Управление снимками и базой данных (IndexedDB) +

+
+ {snapshotStats && ( + {snapshotStats} + )} +
+ +

+ Экспорт полного слепка данных (сделки, заказы, кастомные сущности, финансы, остатки) в JSON, + сброс к дефолтному демо-состоянию и мгновенное восстановление из файла. +

+ +
+ + + + +
+ + +
+
+
); }; diff --git a/src/features/settings/entities/EntityCatalog.tsx b/src/features/settings/entities/EntityCatalog.tsx index b1fcc59..e642f4f 100644 --- a/src/features/settings/entities/EntityCatalog.tsx +++ b/src/features/settings/entities/EntityCatalog.tsx @@ -32,6 +32,7 @@ export const EntityCatalog: React.FC = () => { // Modal states const [isNewEntityModalOpen, setIsNewEntityModalOpen] = useState(false); + const [isCodeModified, setIsCodeModified] = useState(false); const [newEntityForm, setNewEntityForm] = useState({ name: '', pluralName: '', @@ -72,17 +73,30 @@ export const EntityCatalog: React.FC = () => { return { total, system, custom, totalFields }; }, [entities]); - const handleNameChange = (val: string) => { - const autoCode = val + const slugify = (text: string): string => { + const ru: Record = { + а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'yo', ж: 'zh', з: 'z', и: 'i', + й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', + у: 'u', ф: 'f', х: 'kh', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'shch', ъ: '', ы: 'y', + ь: '', э: 'e', ю: 'yu', я: 'ya', + }; + return text .toLowerCase() - .trim() - .replace(/[^a-z0-9а-яё]/gi, '_') - .replace(/_{2,}/g, '_'); + .split('') + .map((c) => ru[c] || c) + .join('') + .replace(/[^a-z0-9_]/g, '_') + .replace(/_{2,}/g, '_') + .replace(/^_|_$/g, ''); + }; + + const handleNameChange = (val: string) => { + const autoCode = slugify(val); setNewEntityForm((prev) => ({ ...prev, name: val, pluralName: prev.pluralName || `${val}ы`, - code: prev.code ? prev.code : autoCode, + code: isCodeModified ? prev.code : autoCode, })); }; @@ -148,6 +162,7 @@ export const EntityCatalog: React.FC = () => { }); setIsNewEntityModalOpen(false); + setIsCodeModified(false); setNewEntityForm({ name: '', pluralName: '', @@ -211,6 +226,7 @@ export const EntityCatalog: React.FC = () => { - diff --git a/src/features/settings/entities/ViewBuilder.tsx b/src/features/settings/entities/ViewBuilder.tsx index b2af74f..6f11d30 100644 --- a/src/features/settings/entities/ViewBuilder.tsx +++ b/src/features/settings/entities/ViewBuilder.tsx @@ -362,7 +362,7 @@ export const ViewBuilder: React.FC = () => { Экспорт CSV - @@ -459,7 +459,20 @@ export const ViewBuilder: React.FC = () => { return (
{ + e.dataTransfer.setData('text/plain', String(idx)); + }} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + const fromIdx = Number(e.dataTransfer.getData('text/plain')); + if (!isNaN(fromIdx) && fromIdx !== idx) { + handleMoveColumn(fromIdx, idx); + } + }} + className="flex items-center justify-between p-2 rounded bg-krona-workspace text-xs border border-krona-border/60 cursor-grab active:cursor-grabbing" > {field?.label || key} diff --git a/src/mock/store.ts b/src/mock/store.ts index 032c15a..325b717 100644 --- a/src/mock/store.ts +++ b/src/mock/store.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { useDemoStore as useDemoControlsStore } from '../stores/demo'; import { CapabilityId } from '../core/capabilities/types'; import { SystemRoleId } from '../core/permissions/types'; import { BUSINESS_PROFILES } from '../core/profiles/profiles'; @@ -328,26 +329,48 @@ const INITIAL_NOTIFICATIONS: AppNotification[] = [ export const useDemoStore = create((set, get) => ({ // Theme & Viewport defaults - theme: 'soft-light', + theme: + (typeof window !== 'undefined' && + (['soft-light', 'light', 'dark'].includes(localStorage.getItem('krona_theme_mode') || '') + ? (localStorage.getItem('krona_theme_mode') as ThemeMode) + : null)) || + 'soft-light', setTheme: (theme) => { - document.documentElement.setAttribute('data-theme', theme); + if (typeof window !== 'undefined') { + localStorage.setItem('krona_theme_mode', theme); + document.documentElement.setAttribute('data-theme', theme); + } set({ theme }); }, viewportMode: 'desktop', setViewportMode: (viewportMode) => set({ viewportMode }), // Profile & Role defaults - currentProfileId: 'restaurant_hall_delivery', + currentProfileId: + (typeof window !== 'undefined' && localStorage.getItem('krona_profile_id')) || + 'restaurant_hall_delivery', setProfileId: (currentProfileId) => { + if (typeof window !== 'undefined') { + localStorage.setItem('krona_profile_id', currentProfileId); + } const profile = BUSINESS_PROFILES[currentProfileId] || BUSINESS_PROFILES.restaurant_hall_delivery; set({ currentProfileId, customCapabilities: { ...profile.capabilities }, activeLocation: profile.locations[0] || 'Главная точка', }); + useDemoControlsStore.getState().setCurrentProfileId(currentProfileId); + }, + currentRoleId: + (typeof window !== 'undefined' && (localStorage.getItem('krona_role_id') as SystemRoleId)) || + 'owner', + setRoleId: (currentRoleId) => { + if (typeof window !== 'undefined') { + localStorage.setItem('krona_role_id', currentRoleId); + } + set({ currentRoleId }); + useDemoControlsStore.getState().setCurrentRoleId(currentRoleId); }, - currentRoleId: 'owner', - setRoleId: (currentRoleId) => set({ currentRoleId }), activeLocation: 'Горский 1/1', setActiveLocation: (activeLocation) => set({ activeLocation }), diff --git a/src/pages/error/ForbiddenPage.tsx b/src/pages/error/ForbiddenPage.tsx index a526c22..b00c503 100644 --- a/src/pages/error/ForbiddenPage.tsx +++ b/src/pages/error/ForbiddenPage.tsx @@ -3,6 +3,7 @@ import { ShieldAlert, Home, RefreshCw } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { Button } from '../../shared/ui/Button'; import { useDemoStore } from '../../stores/demo'; +import { useDemoStore as useMockStore } from '../../mock/store'; export interface ForbiddenPageProps { requiredPermission?: string; @@ -16,8 +17,13 @@ export const ForbiddenPage: React.FC = ({ const navigate = useNavigate(); const { currentRoleId, setCurrentRoleId } = useDemoStore(); + const handleSwitchToOwner = () => { + setCurrentRoleId('owner'); + useMockStore.getState().setRoleId('owner'); + }; + return ( -
+