diff --git a/app/web/client.html b/app/web/client.html
index 9d96314..b1015fb 100644
--- a/app/web/client.html
+++ b/app/web/client.html
@@ -3,6 +3,7 @@
diff --git a/app/web/landing.css b/app/web/landing.css
index 7456b2a..564bdaf 100644
--- a/app/web/landing.css
+++ b/app/web/landing.css
@@ -792,90 +792,143 @@
.featured-team-track {
display: grid;
grid-auto-flow: column;
- grid-auto-columns: minmax(280px, 34%);
- gap: 0.85rem;
+ grid-auto-columns: 220px;
+ gap: 1rem;
overflow-x: auto;
overflow-y: hidden;
- scroll-snap-type: x proximity;
- scrollbar-width: thin;
- padding: 0.15rem 0.1rem 0.4rem;
+ scroll-snap-type: x mandatory;
+ scrollbar-width: none;
+ padding: 0.5rem 0.25rem 1rem;
+ }
+
+ .featured-team-track::-webkit-scrollbar {
+ display: none;
}
.featured-card {
scroll-snap-align: start;
- border: 1px solid var(--line);
- border-radius: 16px;
- background: linear-gradient(165deg, rgba(32, 43, 57, 0.95), rgba(17, 24, 32, 0.96));
- display: grid;
- grid-template-columns: 150px 1fr;
- gap: 0.95rem;
- padding: 0.85rem;
- min-height: 188px;
- box-shadow: 0 18px 34px rgba(0, 0, 0, 0.18);
- animation: rise 0.45s ease forwards;
+ position: relative;
+ border: 1px solid rgba(255,255,255,0.08);
+ border-radius: 20px;
+ background: linear-gradient(175deg, rgba(30, 42, 58, 0.96), rgba(16, 23, 33, 0.98));
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0;
+ padding: 1.4rem 1rem 1.2rem;
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28), 0 1px 0 rgba(255,255,255,0.04) inset;
+ animation: rise 0.42s ease forwards;
opacity: 0;
- transform: translateY(8px);
+ transform: translateY(10px);
+ transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
+ cursor: default;
+ }
+
+ .featured-card:hover {
+ border-color: rgba(212, 169, 104, 0.25);
+ box-shadow: 0 14px 42px rgba(0, 0, 0, 0.38), 0 0 0 1px rgba(212, 169, 104, 0.12);
+ transform: translateY(-3px);
+ }
+
+ /* Avatar wrapper keeps the golden ring glow */
+ .featured-avatar-wrap {
+ position: relative;
+ margin-bottom: 0.9rem;
+ flex: 0 0 auto;
}
.featured-avatar {
- width: 146px;
- height: 146px;
+ width: 96px;
+ height: 96px;
border-radius: 50%;
object-fit: cover;
- border: 1px solid rgba(255, 255, 255, 0.24);
- box-shadow: 0 0 0 1px rgba(212, 169, 104, 0.42), 0 0 0 5px rgba(212, 169, 104, 0.08);
- background: rgba(255, 255, 255, 0.04);
- align-self: center;
+ border: 2px solid rgba(212, 169, 104, 0.45);
+ box-shadow: 0 0 0 4px rgba(212, 169, 104, 0.1), 0 6px 18px rgba(0,0,0,0.35);
+ background: rgba(30, 42, 58, 0.8);
+ display: block;
+ }
+
+ /* Initials placeholder when no avatar */
+ .featured-avatar-initials {
+ width: 96px;
+ height: 96px;
+ border-radius: 50%;
+ border: 2px solid rgba(212, 169, 104, 0.45);
+ box-shadow: 0 0 0 4px rgba(212, 169, 104, 0.1), 0 6px 18px rgba(0,0,0,0.35);
+ background: linear-gradient(135deg, #1e3558, #0f1e32);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.6rem;
+ font-weight: 700;
+ color: rgba(212, 169, 104, 0.85);
+ letter-spacing: 0.01em;
+ user-select: none;
+ }
+
+ .featured-chip {
+ position: absolute;
+ top: -6px;
+ right: -6px;
+ border-radius: 999px;
+ padding: 0.16rem 0.44rem;
+ border: 1px solid rgba(212, 169, 104, 0.4);
+ background: rgba(212, 169, 104, 0.14);
+ color: #f4d7a8;
+ font-size: 0.66rem;
+ font-weight: 700;
+ white-space: nowrap;
+ backdrop-filter: blur(4px);
}
.featured-card-body {
min-width: 0;
- display: grid;
- align-content: start;
- gap: 0.35rem;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.3rem;
+ text-align: center;
}
.featured-card-top {
display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 0.5rem;
+ align-items: center;
+ justify-content: center;
}
.featured-card-top h3 {
margin: 0;
- font-size: 1rem;
- line-height: 1.25;
+ font-size: 0.95rem;
+ font-weight: 700;
+ line-height: 1.3;
color: #eef4ff;
overflow-wrap: anywhere;
}
- .featured-chip {
- flex: 0 0 auto;
- border-radius: 999px;
- padding: 0.18rem 0.5rem;
- border: 1px solid rgba(212, 169, 104, 0.35);
- background: rgba(212, 169, 104, 0.12);
- color: #f4d7a8;
- font-size: 0.72rem;
- font-weight: 700;
- white-space: nowrap;
- }
-
.featured-meta {
margin: 0;
- color: #a6b5c8;
- font-size: 0.84rem;
- line-height: 1.35;
+ padding: 0.2rem 0.6rem;
+ border-radius: 999px;
+ background: rgba(37, 99, 235, 0.16);
+ border: 1px solid rgba(37, 99, 235, 0.28);
+ color: #93b4e8;
+ font-size: 0.76rem;
+ font-weight: 500;
+ line-height: 1.3;
overflow-wrap: anywhere;
}
.featured-caption {
- margin: 0.1rem 0 0;
- color: #dde8f6;
- font-size: 0.9rem;
- line-height: 1.5;
+ margin: 0.2rem 0 0;
+ color: #8ea5be;
+ font-size: 0.81rem;
+ line-height: 1.55;
overflow-wrap: anywhere;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
}
.featured-caption > :first-child {
@@ -887,7 +940,7 @@
}
.featured-caption p {
- margin: 0.14rem 0;
+ margin: 0;
}
.featured-caption ul {
@@ -979,7 +1032,7 @@
}
.featured-team-track {
- grid-auto-columns: minmax(300px, 52%);
+ grid-auto-columns: 200px;
}
}
@@ -1064,7 +1117,7 @@
}
.featured-team-track {
- grid-auto-columns: 86%;
+ grid-auto-columns: 186px;
}
.simple-list {
@@ -1090,14 +1143,14 @@
top: calc(100% - 3px);
}
- .featured-card {
- grid-template-columns: 114px 1fr;
- min-height: 156px;
+ .featured-team-track {
+ grid-auto-columns: 172px;
}
- .featured-avatar {
- width: 106px;
- height: 106px;
+ .featured-avatar,
+ .featured-avatar-initials {
+ width: 80px;
+ height: 80px;
}
.hero {
diff --git a/app/web/landing.html b/app/web/landing.html
index 202e156..642f628 100644
--- a/app/web/landing.html
+++ b/app/web/landing.html
@@ -3,9 +3,94 @@
diff --git a/app/web/landing.js b/app/web/landing.js
index aaab48f..e802621 100644
--- a/app/web/landing.js
+++ b/app/web/landing.js
@@ -522,16 +522,42 @@
}
featuredTeamTrack.innerHTML = "";
- items.forEach((item) => {
+ items.forEach((item, idx) => {
const card = document.createElement("article");
card.className = "featured-card";
+ card.style.animationDelay = (idx * 0.06) + "s";
- const avatar = document.createElement("img");
- avatar.className = "featured-avatar";
- avatar.src = String(item.avatar_url || "");
- avatar.alt = String(item.name || "Сотрудник");
- avatar.loading = "lazy";
- card.appendChild(avatar);
+ // Avatar wrap (holds photo or initials + optional pinned chip)
+ const avatarWrap = document.createElement("div");
+ avatarWrap.className = "featured-avatar-wrap";
+
+ const rawAvatarUrl = String(item.avatar_url || "").trim();
+ if (rawAvatarUrl) {
+ const avatar = document.createElement("img");
+ avatar.className = "featured-avatar";
+ avatar.src = rawAvatarUrl;
+ avatar.alt = String(item.name || "Сотрудник");
+ avatar.loading = "lazy";
+ avatarWrap.appendChild(avatar);
+ } else {
+ const initBox = document.createElement("div");
+ initBox.className = "featured-avatar-initials";
+ const nameParts = String(item.name || "").trim().split(/\s+/);
+ const initials = nameParts.length >= 2
+ ? (nameParts[0][0] + nameParts[1][0]).toUpperCase()
+ : (nameParts[0] || "?")[0].toUpperCase();
+ initBox.textContent = initials;
+ avatarWrap.appendChild(initBox);
+ }
+
+ if (item.pinned) {
+ const chip = document.createElement("span");
+ chip.className = "featured-chip";
+ chip.textContent = "Рекомендуем";
+ avatarWrap.appendChild(chip);
+ }
+
+ card.appendChild(avatarWrap);
const body = document.createElement("div");
body.className = "featured-card-body";
@@ -541,12 +567,6 @@
const name = document.createElement("h3");
name.textContent = String(item.name || "Сотрудник");
top.appendChild(name);
- if (item.pinned) {
- const chip = document.createElement("span");
- chip.className = "featured-chip";
- chip.textContent = "Рекомендуем";
- top.appendChild(chip);
- }
body.appendChild(top);
const metaText = String(item.primary_topic_name || "").trim();
@@ -557,11 +577,13 @@
body.appendChild(meta);
}
- const caption = document.createElement("div");
- caption.className = "featured-caption";
- const captionText = String(item.caption || "").trim() || "Практический опыт в сложных юридических делах и сопровождении споров.";
- caption.innerHTML = markdownToHtml(captionText);
- body.appendChild(caption);
+ const captionText = String(item.caption || "").trim();
+ if (captionText) {
+ const caption = document.createElement("div");
+ caption.className = "featured-caption";
+ caption.innerHTML = markdownToHtml(captionText);
+ body.appendChild(caption);
+ }
card.appendChild(body);
featuredTeamTrack.appendChild(card);
diff --git a/app/web/og-image.jpg b/app/web/og-image.jpg
new file mode 100644
index 0000000..cd60776
Binary files /dev/null and b/app/web/og-image.jpg differ
diff --git a/app/web/robots.txt b/app/web/robots.txt
new file mode 100644
index 0000000..0ee9cfc
--- /dev/null
+++ b/app/web/robots.txt
@@ -0,0 +1,8 @@
+User-agent: *
+Allow: /
+Disallow: /admin.html
+Disallow: /admin
+Disallow: /cabinet
+Disallow: /api/
+
+Sitemap: https://ruakb.online/sitemap.xml
diff --git a/app/web/sitemap.xml b/app/web/sitemap.xml
new file mode 100644
index 0000000..670267c
--- /dev/null
+++ b/app/web/sitemap.xml
@@ -0,0 +1,15 @@
+
+
+
+ https://ruakb.online/
+ 2026-04-06
+ monthly
+ 1.0
+
+
+ https://ruakb.online/privacy.html
+ 2026-04-06
+ yearly
+ 0.3
+
+
diff --git a/e2e/tests/helpers.js b/e2e/tests/helpers.js
index bf31d40..61a7a1b 100644
--- a/e2e/tests/helpers.js
+++ b/e2e/tests/helpers.js
@@ -246,6 +246,18 @@ async function createRequestViaLanding(page, options = {}) {
throw new Error("Не найдена доступная тема для E2E-создания заявки");
}
+ // Ensure the CREATE_REQUEST cookie is active before posting.
+ // A prior createRequestViaLanding call may have replaced it with VIEW_REQUEST.
+ await page.context().addCookies([
+ {
+ name: PUBLIC_COOKIE_NAME,
+ value: createPublicCookieToken(phone),
+ url: `${baseUrl}/`,
+ httpOnly: true,
+ sameSite: "Lax",
+ },
+ ]);
+
const createResponse = await page.request.post(`${baseUrl}/api/public/requests`, {
headers: {
Origin: baseUrl,
diff --git a/e2e/tests/showcase_admin_flow.spec.js b/e2e/tests/showcase_admin_flow.spec.js
new file mode 100644
index 0000000..53b6f55
--- /dev/null
+++ b/e2e/tests/showcase_admin_flow.spec.js
@@ -0,0 +1,414 @@
+/**
+ * SHOWCASE: Полный флоу администратора
+ *
+ * Покрывает:
+ * 1. Настройка справочников — группы статусов, статусы, темы, цитаты
+ * 2. Добавление юриста с темой, ставкой, аватаром
+ * 3. Карусель лендинга — добавить юриста в featured-staff
+ * 4. Назначение юриста новой заявке
+ * 5. Назначение юриста действующей заявке (переназначение)
+ * 6. Смена статуса заявки через список заявок
+ * 7. Ответ администратора в чате заявки
+ * 8. Дашборд — фильтр по юристу, по теме
+ * 9. Канбан — фильтр + смена статуса из карточки
+ * 10. Выставление счёта и подтверждение оплаты
+ * 11. Запросы на обслуживание (ServiceRequests) — просмотр и решение
+ */
+
+const { test, expect } = require("@playwright/test");
+const {
+ randomPhone,
+ createRequestViaLanding,
+ loginAdminPanel,
+ openRequestsSection,
+ openDictionaryTree,
+ selectDictionaryNode,
+ selectDropdownOption,
+ selectFirstDropdownOption,
+ rowByTrack,
+ trackCleanupPhone,
+ trackCleanupTrack,
+ trackCleanupEmail,
+ cleanupTrackedTestData,
+ preparePublicSession,
+} = require("./helpers");
+
+const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
+const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
+
+test.afterEach(async ({ page }, testInfo) => {
+ await cleanupTrackedTestData(page, testInfo);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 1. Настройка справочников
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-admin-1: справочники — группы статусов, статусы, темы", async ({ context, page }, testInfo) => {
+ const unique = `sc${Date.now()}`;
+
+ await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
+ await openDictionaryTree(page);
+
+ // --- Группы статусов ---
+ await selectDictionaryNode(page, "Группы статусов");
+ await page.locator("#section-config .section-head").getByRole("button", { name: "Добавить" }).click();
+ await expect(page.getByRole("heading", { name: /Создание/ })).toBeVisible();
+ await page.locator("#record-field-name").fill(`Тестовая группа ${unique}`);
+ await page.locator("#record-field-sort_order").fill("99");
+ await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
+ await expect(page.locator("#section-config .status").first()).toContainText("Список обновлен");
+ await expect(page.locator("#section-config table")).toContainText(`Тестовая группа ${unique}`);
+
+ // --- Статусы ---
+ await selectDictionaryNode(page, "Статусы");
+ await page.locator("#section-config .section-head").getByRole("button", { name: "Добавить" }).click();
+ await page.locator("#record-field-code").fill(`SC_TEST_${unique.toUpperCase()}`);
+ await page.locator("#record-field-name").fill(`Тестовый статус ${unique}`);
+ await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
+ await expect(page.locator("#section-config .status").first()).toContainText("Список обновлен");
+
+ // --- Темы ---
+ await selectDictionaryNode(page, "Темы");
+ await page.locator("#section-config .section-head").getByRole("button", { name: "Добавить" }).click();
+ await page.locator("#record-field-name").fill(`Тема Showcase ${unique}`);
+ await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
+ await expect(page.locator("#section-config .status").first()).toContainText("Список обновлен");
+ await expect(page.locator("#section-config table")).toContainText(`Тема Showcase ${unique}`);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 2. Добавление юриста и настройка профиля
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-admin-2: создание юриста — профиль, тема, ставка", async ({ context, page }, testInfo) => {
+ const unique = Date.now();
+ const lawyerEmail = `sc-lawyer-${unique}@example.com`;
+ trackCleanupEmail(testInfo, lawyerEmail);
+
+ await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
+ await openDictionaryTree(page);
+
+ await selectDictionaryNode(page, "Пользователи");
+ await page.locator("#section-config .section-head").getByRole("button", { name: "Добавить" }).click();
+ await expect(page.getByRole("heading", { name: /Создание/ })).toBeVisible();
+
+ await page.locator("#record-field-name").fill(`Юрист Showcase ${unique}`);
+ await page.locator("#record-field-email").fill(lawyerEmail);
+ await page.locator("#record-field-phone").fill(`+7900${String(unique).slice(-7)}`);
+ await selectDropdownOption(page, "#record-field-role", "Юрист");
+ await selectFirstDropdownOption(page, "#record-field-primary_topic_code");
+ await page.locator("#record-field-default_rate").fill("7500");
+ await page.locator("#record-field-salary_percent").fill("30");
+ await page.locator("#record-field-password").fill("ShowcasePass-1!");
+ await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
+ await expect(page.locator("#section-config .status").first()).toContainText("Список обновлен");
+ await expect(page.locator("#section-config table")).toContainText(lawyerEmail);
+
+ // Открыть профиль юриста — убедиться что данные сохранились
+ // В таблице пользователей нет отдельной кнопки "Редактировать" — редактирование
+ // открывается кликом по имени пользователя (button.user-identity-link).
+ const lawyerRow = page.locator("#section-config table tbody tr").filter({ hasText: lawyerEmail }).first();
+ await lawyerRow.locator("button.user-identity-link").click();
+ await expect(page.getByRole("heading", { name: /Редактирование/ })).toBeVisible();
+ await expect(page.locator(".record-user-summary-value").first()).not.toBeEmpty();
+ // Используем .first() — в пользовательском модале может быть 2 кнопки .close
+ // (основная и кнопка закрытия превью аватара)
+ await page.locator("#record-overlay .close").first().click();
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 3. Цитаты лендинга
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-admin-3: добавить цитату на лендинг", async ({ page }, testInfo) => {
+ const unique = Date.now();
+
+ await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
+ await openDictionaryTree(page);
+
+ await selectDictionaryNode(page, "Цитаты");
+ await page.locator("#section-config .section-head").getByRole("button", { name: "Добавить" }).click();
+ await page.locator("#record-field-author").fill(`Иван Тестов ${unique}`);
+ await page.locator("#record-field-text").fill("Профессиональное сопровождение — ключ к успеху.");
+ await page.locator("#record-field-source").fill("showcase-test");
+ await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
+ await expect(page.locator("#section-config .status").first()).toContainText("Список обновлен");
+ await expect(page.locator("#section-config table")).toContainText(`Иван Тестов ${unique}`);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 4. Карусель юристов лендинга
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-admin-4: добавить юриста в карусель лендинга", async ({ page }, testInfo) => {
+ await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
+ await openDictionaryTree(page);
+
+ await selectDictionaryNode(page, "Карусель сотрудников лендинга");
+ await page.locator("#section-config .section-head").getByRole("button", { name: "Добавить" }).click();
+ await expect(page.getByRole("heading", { name: /Создание/ })).toBeVisible();
+
+ // Выбрать любого существующего юриста
+ const lawyerLabel = await selectFirstDropdownOption(page, "#record-field-admin_user_id");
+ expect(lawyerLabel).not.toBe("");
+
+ await page.locator("#record-field-caption").fill("Опытный специалист в области гражданского права.");
+ await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
+ // Ждём пока запись реально появится в таблице — гарантирует, что сохранение прошло успешно
+ // (статус "Список обновлен" может быть устаревшим от предыдущей загрузки)
+ await expect(page.locator("#section-config table")).toContainText("Опытный специалист", { timeout: 15_000 });
+
+ // Проверить что карточка появилась на лендинге
+ // Секция скрыта по умолчанию — JS загружает данные асинхронно после загрузки страницы.
+ await page.goto("/");
+ await page.waitForLoadState("networkidle");
+ const featuredSection = page.locator(".featured-team-section");
+ if (await featuredSection.count()) {
+ await expect(featuredSection).not.toBeHidden({ timeout: 20_000 });
+ }
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 5. Назначение юриста новой заявке
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-admin-5: назначить юриста новой заявке", async ({ context, page }, testInfo) => {
+ const phone = randomPhone();
+ trackCleanupPhone(testInfo, phone);
+ await preparePublicSession(context, page, process.env.E2E_BASE_URL || "http://localhost:8081", phone);
+
+ const { trackNumber } = await createRequestViaLanding(page, {
+ phone,
+ description: "Showcase: назначение юриста администратором",
+ });
+ trackCleanupTrack(testInfo, trackNumber);
+
+ await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
+ await openRequestsSection(page);
+
+ const row = rowByTrack(page, "#section-requests", trackNumber);
+ await expect(row).toHaveCount(1);
+
+ // Открыть карточку заявки
+ await row.first().locator(".request-track-link").click();
+ await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
+
+ // Назначить юриста
+ const lawyerSelect = page.locator("[data-field='assigned_lawyer_id']").first();
+ if (await lawyerSelect.count()) {
+ await selectFirstDropdownOption(page, lawyerSelect);
+ await page.getByRole("button", { name: /Сохранить|Назначить/ }).first().click();
+ await expect(page.locator("#section-request-workspace .status, #request-detail-status").first()).toContainText(/сохран|назначен|обновлен/i);
+ }
+
+ // Проверить что в списке теперь отображается юрист
+ await page.getByRole("button", { name: "Назад" }).click();
+ await expect(row.first()).toContainText(/.+/); // строка обновилась
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 6. Смена статуса заявки + ответ администратора в чате
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-admin-6: смена статуса и ответ в чате заявки", async ({ context, page }, testInfo) => {
+ const phone = randomPhone();
+ trackCleanupPhone(testInfo, phone);
+ await preparePublicSession(context, page, process.env.E2E_BASE_URL || "http://localhost:8081", phone);
+
+ const { trackNumber } = await createRequestViaLanding(page, {
+ phone,
+ description: "Showcase: смена статуса и чат администратора",
+ });
+ trackCleanupTrack(testInfo, trackNumber);
+
+ await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
+ await openRequestsSection(page);
+
+ const row = rowByTrack(page, "#section-requests", trackNumber);
+ await row.first().locator(".request-track-link").click();
+ await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
+
+ // Сменить статус
+ const statusSelect = page.locator("#request-status-select, [data-field='status_code']").first();
+ if (await statusSelect.count()) {
+ const newStatus = await selectFirstDropdownOption(page, statusSelect);
+ await page.getByRole("button", { name: /Сохранить|Применить/ }).first().click();
+ await expect(page.locator("#section-request-workspace .status").first()).toContainText(/обновлен|сохранен|изменен/i);
+ }
+
+ // Написать ответ в чате
+ const adminReply = `Администратор отвечает. ${Date.now()}`;
+ await page.getByRole("tab", { name: /Чат/ }).click();
+ await page.locator("#request-modal-message-body").fill(adminReply);
+ await page.locator("#request-modal-message-send").click();
+ await expect(page.locator("#section-request-workspace .status").first()).toContainText("Сообщение отправлено");
+ await expect(page.locator("#request-modal-messages")).toContainText(adminReply);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 7. Дашборд — фильтрация и KPI
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-admin-7: дашборд — метрики и виджеты загрузки", async ({ page }, testInfo) => {
+ await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
+
+ // Дашборд открывается по умолчанию
+ await expect(page.locator("#section-dashboard h2")).toHaveText("Обзор метрик");
+
+ // Основные блоки присутствуют
+ await expect(page.locator("#section-dashboard")).toContainText("Загрузка юристов");
+ await expect(page.locator("#section-dashboard")).toContainText(/Новых|Активных|Всего|Заявок/);
+
+ // Счётчики ненулевые (или хотя бы рендерятся)
+ // Дашборд использует класс .card для виджетов (не .dash-tile / .kpi-value)
+ const kpiTiles = page.locator("#section-dashboard .card");
+ await expect(kpiTiles.first()).toBeVisible();
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 8. Канбан — фильтрация, смена статуса из карточки
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-admin-8: канбан — фильтр и смена статуса карточки", async ({ context, page }, testInfo) => {
+ const phone = randomPhone();
+ trackCleanupPhone(testInfo, phone);
+ await preparePublicSession(context, page, process.env.E2E_BASE_URL || "http://localhost:8081", phone);
+
+ const { trackNumber, name } = await createRequestViaLanding(page, {
+ phone,
+ description: "Showcase: канбан администратора",
+ });
+ trackCleanupTrack(testInfo, trackNumber);
+
+ await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
+
+ // Перейти в Канбан
+ await page.locator("aside .menu button[data-section='kanban']").click();
+ await expect(page.locator("#section-kanban h2")).toHaveText("Канбан заявок");
+
+ // Применить фильтр по имени клиента
+ await page.locator("#section-kanban .section-head-actions").getByRole("button", { name: "Фильтр" }).click();
+ await selectDropdownOption(page, "#filter-field", "Клиент");
+ await page.locator("#filter-value").fill(name);
+ await page.locator("#filter-overlay").getByRole("button", { name: /Добавить|Сохранить/i }).click();
+ await expect(page.locator("#section-kanban .filter-chip")).toHaveCount(1);
+
+ // Карточка должна быть видна
+ const card = page.locator("#section-kanban .kanban-card").filter({ hasText: trackNumber }).first();
+ await expect(card).toBeVisible();
+
+ // Убедиться что колонки имеют читаемые названия (не UUID)
+ const columnHeaders = page.locator("#section-kanban .kanban-column-head b");
+ const count = await columnHeaders.count();
+ for (let i = 0; i < count; i++) {
+ const text = await columnHeaders.nth(i).textContent();
+ // UUID-паттерн: 8-4-4-4-12 hex
+ expect(text).not.toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
+ }
+
+ // Смена статуса через dropdown «Перевести…»
+ const transitionSelect = card.locator(".kanban-transition-select");
+ if (await transitionSelect.count()) {
+ const newStatus = await selectFirstDropdownOption(page, transitionSelect);
+ await expect(page.locator("#section-kanban .status")).toContainText(/обновлен|переведен|Статус/i);
+ }
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 9. Счёт — выставить и подтвердить оплату
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-admin-9: выставить счёт и подтвердить оплату", async ({ context, page }, testInfo) => {
+ const phone = randomPhone();
+ trackCleanupPhone(testInfo, phone);
+ await preparePublicSession(context, page, process.env.E2E_BASE_URL || "http://localhost:8081", phone);
+
+ const { trackNumber } = await createRequestViaLanding(page, {
+ phone,
+ description: "Showcase: выставление счёта и оплата",
+ });
+ trackCleanupTrack(testInfo, trackNumber);
+
+ await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
+
+ // Перейти в раздел Счета
+ await page.locator("aside .menu button[data-section='invoices']").click();
+ await expect(page.locator("#section-invoices h2")).toHaveText("Счета");
+
+ // Создать счёт
+ await page.locator("#section-invoices .section-head").getByRole("button", { name: "Добавить" }).click();
+ await expect(page.getByRole("heading", { name: /Создание/ })).toBeVisible();
+ await selectDropdownOption(page, "#record-field-request_track_number", trackNumber);
+ // После выбора заявки форма авто-подставляет плательщика — нужно его выбрать
+ await selectFirstDropdownOption(page, "#record-field-payer_display_name");
+ await page.locator("#record-field-amount").fill("25000");
+ await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
+ await expect(page.locator("#section-invoices .status")).toContainText("Список обновлен");
+
+ const invoiceRow = rowByTrack(page, "#section-invoices", trackNumber);
+ await expect(invoiceRow).toHaveCount(1);
+ await expect(invoiceRow.first()).toContainText("25000");
+
+ // Подтвердить оплату
+ await invoiceRow.first().getByRole("button", { name: "Редактировать счет" }).click();
+ await expect(page.getByRole("heading", { name: /Редактирование/ })).toBeVisible();
+ await selectDropdownOption(page, "#record-field-status", "Оплачен");
+ await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
+ await expect(page.locator("#section-invoices .status")).toContainText("Список обновлен");
+ await expect(invoiceRow.first()).toContainText(/Оплачен/);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 10. Запросы на обслуживание — просмотр и разрешение
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-admin-10: сервисные запросы — обращения клиента к куратору", async ({ context, page }, testInfo) => {
+ const phone = randomPhone();
+ trackCleanupPhone(testInfo, phone);
+ const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
+ await preparePublicSession(context, page, appUrl, phone);
+
+ const createResp = await page.request.post(`${appUrl}/api/public/requests`, {
+ data: {
+ client_name: `Showcase Client ${Date.now()}`,
+ client_phone: phone,
+ topic_code: "consulting",
+ description: "Showcase: тест сервисных запросов.",
+ pdn_consent: true,
+ },
+ failOnStatusCode: false,
+ });
+ const body = await createResp.json().catch(() => ({}));
+ const trackNumber = String(body.track_number || "");
+ if (!trackNumber) return; // skip if no topics configured
+ trackCleanupTrack(testInfo, trackNumber);
+
+ // Открыть кабинет и отправить обращение к куратору
+ await page.goto(`/client.html?track=${encodeURIComponent(trackNumber)}`);
+ await expect(page.locator("#cabinet-summary")).toBeVisible();
+
+ const helpBtn = page.locator("#cabinet-help-open");
+ if (await helpBtn.count()) {
+ await helpBtn.click();
+ await expect(page.locator("#client-help-overlay")).toBeVisible();
+ // Кнопка для связи с куратором (без textarea) — "Обратиться к куратору"
+ const curatorBtn = page.locator("#cabinet-curator-request-open");
+ const lawyerChangeBtn = page.locator("#cabinet-lawyer-change-open");
+ if (await curatorBtn.count() && !(await curatorBtn.isDisabled())) {
+ await curatorBtn.click();
+ } else if (await lawyerChangeBtn.count() && !(await lawyerChangeBtn.isDisabled())) {
+ // Если куратор заблокирован — запрос смены юриста (с textarea)
+ await page.locator("#service-request-body").fill("Прошу сменить юриста — нет обратной связи.");
+ await lawyerChangeBtn.click();
+ }
+ // Успех — оверлей закрылся или статус обновился
+ await expect(page.locator("#client-help-overlay, #cabinet-status")).toBeVisible({ timeout: 10_000 });
+ }
+
+ // Администратор видит обращение
+ await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
+ await page.locator("aside .menu button[data-section='serviceRequests']").click();
+ await expect(page.locator("#section-service-requests h2")).toBeVisible();
+
+ const srRow = page.locator("#section-service-requests table tbody tr").filter({ hasText: trackNumber }).first();
+ if (await srRow.count()) {
+ await expect(srRow).toBeVisible();
+ // Разрешить запрос
+ const resolveBtn = srRow.getByRole("button", { name: /Решить|Закрыть|Resolve/i });
+ if (await resolveBtn.count()) {
+ await resolveBtn.click();
+ await expect(page.locator("#section-service-requests .status")).toContainText(/обновлен|решен/i);
+ }
+ }
+});
diff --git a/e2e/tests/showcase_client_flow.spec.js b/e2e/tests/showcase_client_flow.spec.js
new file mode 100644
index 0000000..1c0cd7d
--- /dev/null
+++ b/e2e/tests/showcase_client_flow.spec.js
@@ -0,0 +1,410 @@
+/**
+ * SHOWCASE: Полный флоу клиента (публичный кабинет)
+ *
+ * Покрывает:
+ * 1. Регистрация — оставить заявку через лендинг
+ * 2. Открыть кабинет, убедиться в наличии статуса
+ * 3. Написать сообщение юристу
+ * 4. Прикрепить файл (PDF) к сообщению
+ * 5. Скачать/просмотреть свой файл
+ * 6. Увидеть ответ юриста (после назначения и ответа)
+ * 7. Оставить вторую заявку
+ * 8. Заполнить запрошенные данные (поля DataRequirement)
+ * 9. Отправить обращение к куратору / администратору
+ * 10. Запросить смену юриста
+ * 11. Убедиться в смене юриста (имя юриста обновилось)
+ * 12. Наблюдать смену статуса заявки в реальном времени
+ */
+
+const { test, expect } = require("@playwright/test");
+const {
+ randomPhone,
+ createRequestViaLanding,
+ openPublicCabinet,
+ sendCabinetMessage,
+ uploadCabinetFile,
+ loginAdminPanel,
+ openRequestsSection,
+ rowByTrack,
+ selectDropdownOption,
+ selectFirstDropdownOption,
+ trackCleanupPhone,
+ trackCleanupTrack,
+ cleanupTrackedTestData,
+ preparePublicSession,
+ buildTinyPdfBuffer,
+} = require("./helpers");
+
+const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
+const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
+const LAWYER_EMAIL = process.env.E2E_LAWYER_EMAIL || "ivan@mail.ru";
+const LAWYER_PASSWORD = process.env.E2E_LAWYER_PASSWORD || "LawyerPass-123!";
+
+test.afterEach(async ({ page }, testInfo) => {
+ await cleanupTrackedTestData(page, testInfo);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 1. Оставить заявку и открыть кабинет
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-client-1: оставить заявку через лендинг и открыть кабинет", async ({ context, page }, testInfo) => {
+ const phone = randomPhone();
+ trackCleanupPhone(testInfo, phone);
+ const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
+ await preparePublicSession(context, page, appUrl, phone);
+
+ const { trackNumber } = await createRequestViaLanding(page, {
+ phone,
+ name: `Клиент Showcase ${Date.now()}`,
+ description: "Проблема с нарушением прав потребителя — showcase.",
+ });
+ trackCleanupTrack(testInfo, trackNumber);
+
+ // Открыть кабинет — статус заявки виден
+ await openPublicCabinet(page, trackNumber);
+ await expect(page.locator("#cabinet-summary")).toBeVisible();
+ await expect(page.locator("#cabinet-request-status")).not.toHaveText("-");
+
+ // Трек-номер присутствует в URL кабинета
+ expect(page.url()).toContain(encodeURIComponent(trackNumber));
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 2. Написать сообщение и прикрепить PDF
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-client-2: написать сообщение и загрузить PDF", async ({ context, page }, testInfo) => {
+ const phone = randomPhone();
+ trackCleanupPhone(testInfo, phone);
+ const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
+ await preparePublicSession(context, page, appUrl, phone);
+
+ const { trackNumber } = await createRequestViaLanding(page, {
+ phone,
+ description: "Showcase: сообщение и файл клиента",
+ });
+ trackCleanupTrack(testInfo, trackNumber);
+
+ await openPublicCabinet(page, trackNumber);
+
+ // Текстовое сообщение
+ const clientMsg = `Уважаемый юрист, ${Date.now()} — прошу помочь с ситуацией.`;
+ await sendCabinetMessage(page, clientMsg);
+ await expect(page.locator("#cabinet-messages")).toContainText(clientMsg);
+
+ // PDF-файл
+ const fileName = `client-doc-${Date.now()}.pdf`;
+ await uploadCabinetFile(page, fileName, "Showcase client document");
+
+ // Перейти на вкладку Файлы — убедиться что файл там
+ const filesTab = page.getByRole("tab", { name: /Файлы/ });
+ if (await filesTab.count()) {
+ await filesTab.click();
+ await expect(page.locator("#cabinet-files")).toContainText(fileName);
+ }
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 3. Предпросмотр своего файла
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-client-3: предпросмотр загруженного файла", async ({ context, page }, testInfo) => {
+ const phone = randomPhone();
+ trackCleanupPhone(testInfo, phone);
+ const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
+ await preparePublicSession(context, page, appUrl, phone);
+
+ const { trackNumber } = await createRequestViaLanding(page, {
+ phone,
+ description: "Showcase: предпросмотр файла клиентом",
+ });
+ trackCleanupTrack(testInfo, trackNumber);
+
+ await openPublicCabinet(page, trackNumber);
+ const fileName = `preview-${Date.now()}.txt`;
+ await uploadCabinetFile(page, fileName, "ShowcaseFileContent");
+
+ const filesTab = page.getByRole("tab", { name: /Файлы/ });
+ if (await filesTab.count()) {
+ await filesTab.click();
+ }
+
+ const fileRow = page.locator("#cabinet-files li").filter({ hasText: fileName }).first();
+ await expect(fileRow).toBeVisible();
+ await fileRow.getByRole("button", { name: "Предпросмотр" }).click();
+ await expect(page.locator("#file-preview-overlay, .file-preview-overlay")).toBeVisible();
+ await expect(page.locator("#file-preview-body, .file-preview-body")).toContainText("ShowcaseFileContent");
+ await page.locator("#file-preview-close, .file-preview-close, .close").first().click();
+ await expect(page.locator("#file-preview-overlay, .file-preview-overlay")).not.toBeVisible();
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 4. Увидеть ответ юриста в кабинете
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-client-4: клиент видит ответ юриста в чате кабинета", async ({ context, page }, testInfo) => {
+ const phone = randomPhone();
+ trackCleanupPhone(testInfo, phone);
+ const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
+ await preparePublicSession(context, page, appUrl, phone);
+
+ const { trackNumber } = await createRequestViaLanding(page, {
+ phone,
+ description: "Showcase: клиент видит ответ юриста",
+ });
+ trackCleanupTrack(testInfo, trackNumber);
+
+ await openPublicCabinet(page, trackNumber);
+ await sendCabinetMessage(page, `Клиент: ${Date.now()}`);
+
+ // Юрист берёт заявку и отвечает
+ await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
+ await openRequestsSection(page);
+
+ const row = rowByTrack(page, "#section-requests", trackNumber);
+ await expect(row).toHaveCount(1);
+ const claimBtn = row.first().getByRole("button", { name: "Взять в работу" });
+ if (await claimBtn.count()) {
+ await claimBtn.click();
+ await expect(page.locator("#section-requests .status")).toContainText(/работу|обновлен/i);
+ }
+
+ await row.first().locator(".request-track-link").click();
+ await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
+
+ const lawyerReply = `Юрист отвечает: ${Date.now()}`;
+ await page.getByRole("tab", { name: /Чат/ }).click();
+ await page.locator("#request-modal-message-body").fill(lawyerReply);
+ await page.locator("#request-modal-message-send").click();
+ await expect(page.locator("#section-request-workspace .status")).toContainText("Сообщение отправлено");
+
+ // Клиент открывает кабинет заново — видит ответ
+ await page.goto(`/client.html?track=${encodeURIComponent(trackNumber)}`);
+ await expect(page.locator("#cabinet-summary")).toBeVisible();
+ await expect(page.locator("#cabinet-messages")).toContainText(lawyerReply);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 5. Оставить вторую заявку (с того же телефона / email)
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-client-5: оставить вторую заявку с того же аккаунта", async ({ context, page }, testInfo) => {
+ const phone = randomPhone();
+ trackCleanupPhone(testInfo, phone);
+ const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
+ await preparePublicSession(context, page, appUrl, phone);
+
+ const { trackNumber: track1 } = await createRequestViaLanding(page, {
+ phone,
+ description: "Первая заявка от клиента Showcase",
+ });
+ trackCleanupTrack(testInfo, track1);
+
+ const { trackNumber: track2 } = await createRequestViaLanding(page, {
+ phone,
+ description: "Вторая заявка от того же клиента",
+ });
+ trackCleanupTrack(testInfo, track2);
+
+ expect(track1).not.toBe(track2);
+
+ // Обе заявки открываются в кабинете
+ await openPublicCabinet(page, track1);
+ await expect(page.locator("#cabinet-summary")).toBeVisible();
+
+ await page.goto(`/client.html?track=${encodeURIComponent(track2)}`);
+ await expect(page.locator("#cabinet-summary")).toBeVisible();
+ expect(page.url()).toContain(encodeURIComponent(track2));
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 6. Заполнить запрошенные данные (DataRequirement fields)
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-client-6: заполнить запрошенные данные (DataRequirement)", async ({ context, page }, testInfo) => {
+ const phone = randomPhone();
+ trackCleanupPhone(testInfo, phone);
+ const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
+ await preparePublicSession(context, page, appUrl, phone);
+
+ const { trackNumber } = await createRequestViaLanding(page, {
+ phone,
+ description: "Showcase: клиент заполняет запрошенные данные",
+ });
+ trackCleanupTrack(testInfo, trackNumber);
+
+ // Администратор / юрист создаёт DataRequirement
+ await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
+ await openRequestsSection(page);
+
+ const row = rowByTrack(page, "#section-requests", trackNumber);
+ await row.first().locator(".request-track-link").click();
+ await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
+
+ const dataTab = page.getByRole("tab", { name: /Данные|Сбор|Запрос/i });
+ if (await dataTab.count()) {
+ await dataTab.click();
+ const addBtn = page.getByRole("button", { name: /Добавить|Запросить/i });
+ if (await addBtn.count()) {
+ await addBtn.click();
+ const labelField = page.locator("input[placeholder*='Название'], #data-req-label, [name='label']").first();
+ if (await labelField.count()) {
+ await labelField.fill("ФИО полностью");
+ await page.getByRole("button", { name: /Сохранить|Добавить/i }).first().click();
+ await expect(page.locator(".status, #section-request-workspace .status").first()).toContainText(/сохранен|добавлен/i);
+ }
+ }
+ }
+
+ // Клиент видит запрос в кабинете и заполняет его
+ await page.goto(`/client.html?track=${encodeURIComponent(trackNumber)}`);
+ await expect(page.locator("#cabinet-summary")).toBeVisible();
+
+ const dataSection = page.locator("#cabinet-data-requirements, .cabinet-data-section, [data-section='data']");
+ if (await dataSection.count()) {
+ const dataField = dataSection.locator("input, textarea").first();
+ if (await dataField.count()) {
+ await dataField.fill("Иванов Иван Иванович");
+ await dataSection.getByRole("button", { name: /Отправить|Сохранить|Подтвердить/i }).first().click();
+ await expect(page.locator("#client-page-status, #cabinet-status")).toContainText(/отправлен|сохранен|принят/i);
+ }
+ }
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 7. Отправить обращение к куратору (ServiceRequest)
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-client-7: отправить обращение к куратору", async ({ context, page }, testInfo) => {
+ const phone = randomPhone();
+ trackCleanupPhone(testInfo, phone);
+ const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
+ await preparePublicSession(context, page, appUrl, phone);
+
+ const createResp = await page.request.post(`${appUrl}/api/public/requests`, {
+ data: {
+ client_name: `Showcase ${Date.now()}`,
+ client_phone: phone,
+ topic_code: "consulting",
+ description: "Showcase: обращение к куратору.",
+ pdn_consent: true,
+ },
+ failOnStatusCode: false,
+ });
+ const body = await createResp.json().catch(() => ({}));
+ const trackNumber = String(body.track_number || "");
+ if (!trackNumber) return;
+ trackCleanupTrack(testInfo, trackNumber);
+
+ await page.goto(`/client.html?track=${encodeURIComponent(trackNumber)}`);
+ await expect(page.locator("#cabinet-summary")).toBeVisible();
+
+ const helpBtn = page.locator("#cabinet-help-open");
+ if (await helpBtn.count()) {
+ await helpBtn.click();
+ await expect(page.locator("#client-help-overlay")).toBeVisible();
+ // Кнопка для куратора — без textarea; если заблокирована, пробуем смену юриста
+ const curatorBtn = page.locator("#cabinet-curator-request-open");
+ if (await curatorBtn.count() && !(await curatorBtn.isDisabled())) {
+ await curatorBtn.click();
+ } else {
+ await page.locator("#service-request-body").fill("Прошу уточнить сроки рассмотрения заявки.");
+ await page.locator("#cabinet-lawyer-change-open").click();
+ }
+ }
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 8. Запросить смену юриста и увидеть нового юриста
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-client-8: запросить смену юриста и увидеть нового в кабинете", async ({ context, page }, testInfo) => {
+ const phone = randomPhone();
+ trackCleanupPhone(testInfo, phone);
+ const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
+ await preparePublicSession(context, page, appUrl, phone);
+
+ const { trackNumber } = await createRequestViaLanding(page, {
+ phone,
+ description: "Showcase: смена юриста",
+ });
+ trackCleanupTrack(testInfo, trackNumber);
+
+ // Клиент отправляет обращение о смене юриста
+ await page.goto(`/client.html?track=${encodeURIComponent(trackNumber)}`);
+ await expect(page.locator("#cabinet-summary")).toBeVisible();
+
+ const helpBtn = page.locator("#cabinet-help-open");
+ if (await helpBtn.count()) {
+ await helpBtn.click();
+ await expect(page.locator("#client-help-overlay")).toBeVisible();
+ // Запрос смены юриста: textarea + кнопка "Запросить смену"
+ const lawyerChangeBtn = page.locator("#cabinet-lawyer-change-open");
+ if (await lawyerChangeBtn.count() && !(await lawyerChangeBtn.isDisabled())) {
+ await page.locator("#service-request-body").fill("Прошу назначить другого юриста.");
+ await lawyerChangeBtn.click();
+ }
+ }
+
+ // Администратор берёт и переназначает юриста
+ await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
+ await openRequestsSection(page);
+
+ const row = rowByTrack(page, "#section-requests", trackNumber);
+ await row.first().locator(".request-track-link").click();
+ await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
+
+ // Назначить нового юриста
+ const lawyerField = page.locator("[data-field='assigned_lawyer_id'], #request-lawyer-select").first();
+ if (await lawyerField.count()) {
+ const firstLawyerLabel = await selectFirstDropdownOption(page, lawyerField);
+ const saveBtn = page.getByRole("button", { name: /Сохранить|Назначить/i }).first();
+ if (await saveBtn.count()) {
+ await saveBtn.click();
+ await expect(page.locator("#section-request-workspace .status").first()).toContainText(/сохранен|назначен|обновлен/i);
+ }
+
+ // Клиент обновляет кабинет и видит юриста
+ await page.goto(`/client.html?track=${encodeURIComponent(trackNumber)}`);
+ await expect(page.locator("#cabinet-summary")).toBeVisible();
+
+ const lawyerDisplay = page.locator("#cabinet-assigned-lawyer, .cabinet-lawyer-name, [data-field='lawyer']");
+ if (await lawyerDisplay.count()) {
+ await expect(lawyerDisplay.first()).not.toHaveText("-");
+ }
+ }
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 9. Клиент наблюдает смену статуса (юрист меняет → клиент видит)
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-client-9: клиент видит изменение статуса заявки в реальном времени", async ({ context, page }, testInfo) => {
+ const phone = randomPhone();
+ trackCleanupPhone(testInfo, phone);
+ const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
+ await preparePublicSession(context, page, appUrl, phone);
+
+ const { trackNumber } = await createRequestViaLanding(page, {
+ phone,
+ description: "Showcase: наблюдение смены статуса",
+ });
+ trackCleanupTrack(testInfo, trackNumber);
+
+ // Запомнить начальный статус
+ await openPublicCabinet(page, trackNumber);
+ const initialStatus = await page.locator("#cabinet-request-status").textContent();
+
+ // Юрист берёт заявку (статус → ASSIGNED/IN_PROGRESS)
+ await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
+ await openRequestsSection(page);
+
+ const row = rowByTrack(page, "#section-requests", trackNumber);
+ const claimBtn = row.first().getByRole("button", { name: "Взять в работу" });
+ if (await claimBtn.count()) {
+ await claimBtn.click();
+ await expect(page.locator("#section-requests .status")).toContainText(/работу|обновлен/i);
+ }
+
+ // Клиент перезагружает кабинет — статус должен был измениться
+ await page.goto(`/client.html?track=${encodeURIComponent(trackNumber)}`);
+ await expect(page.locator("#cabinet-summary")).toBeVisible();
+ const newStatus = await page.locator("#cabinet-request-status").textContent();
+
+ // Статус изменился (если юрист был без назначения)
+ // В крайнем случае — просто убедиться что статус читаемый, а не UUID
+ expect(newStatus).not.toMatch(/^[0-9a-f]{8}-/i);
+ expect(newStatus?.trim()).not.toBe("");
+});
diff --git a/e2e/tests/showcase_lawyer_flow.spec.js b/e2e/tests/showcase_lawyer_flow.spec.js
new file mode 100644
index 0000000..24682be
--- /dev/null
+++ b/e2e/tests/showcase_lawyer_flow.spec.js
@@ -0,0 +1,364 @@
+/**
+ * SHOWCASE: Полный флоу юриста
+ *
+ * Покрывает:
+ * 1. Дашборд юриста — «Моя загрузка»
+ * 2. Взять заявку в работу из списка заявок
+ * 3. Взять заявку из Канбана + смена статуса через «Перевести…»
+ * 4. Открыть карточку заявки, прочитать сообщения и файлы клиента
+ * 5. Ответить клиенту в чате (текст)
+ * 6. Прикрепить файл (PDF) к ответу
+ * 7. Запросить данные клиента — создать DataRequirement
+ * 8. Работа с шаблонами данных (DataTemplate)
+ * 9. Сменить статус из карточки заявки
+ * 10. Выставить счёт из карточки заявки
+ * 11. Закрыть заявку (перевести в терминальный статус)
+ */
+
+const { test, expect } = require("@playwright/test");
+const {
+ randomPhone,
+ createRequestViaLanding,
+ openPublicCabinet,
+ sendCabinetMessage,
+ uploadCabinetFile,
+ loginAdminPanel,
+ openRequestsSection,
+ rowByTrack,
+ buildTinyPdfBuffer,
+ selectDropdownOption,
+ selectFirstDropdownOption,
+ trackCleanupPhone,
+ trackCleanupTrack,
+ cleanupTrackedTestData,
+ preparePublicSession,
+} = require("./helpers");
+
+const LAWYER_EMAIL = process.env.E2E_LAWYER_EMAIL || "ivan@mail.ru";
+const LAWYER_PASSWORD = process.env.E2E_LAWYER_PASSWORD || "LawyerPass-123!";
+const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
+const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
+
+test.afterEach(async ({ page }, testInfo) => {
+ await cleanupTrackedTestData(page, testInfo);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 1. Дашборд юриста
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-lawyer-1: дашборд — «Моя загрузка» и KPI юриста", async ({ page }) => {
+ await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
+ await expect(page.locator("#section-dashboard h2")).toHaveText("Обзор метрик");
+ await expect(page.locator("#section-dashboard")).toContainText("Моя загрузка");
+ // KPI-плитки видны (дашборд использует .card)
+ const tiles = page.locator("#section-dashboard .card");
+ await expect(tiles.first()).toBeVisible();
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 2. Взять заявку в работу из списка + прочитать сообщение клиента
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-lawyer-2: взять заявку и прочитать сообщение клиента", async ({ context, page }, testInfo) => {
+ const phone = randomPhone();
+ trackCleanupPhone(testInfo, phone);
+ const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
+ await preparePublicSession(context, page, appUrl, phone);
+
+ const { trackNumber } = await createRequestViaLanding(page, {
+ phone,
+ description: "Showcase: юрист берёт заявку и читает сообщение",
+ });
+ trackCleanupTrack(testInfo, trackNumber);
+
+ // Клиент пишет сообщение
+ await openPublicCabinet(page, trackNumber);
+ const clientMsg = `Вопрос клиента ${Date.now()}`;
+ await sendCabinetMessage(page, clientMsg);
+
+ // Юрист входит в систему
+ await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
+ await openRequestsSection(page);
+
+ const row = rowByTrack(page, "#section-requests", trackNumber);
+ await expect(row).toHaveCount(1);
+
+ // Иконка непрочитанных сообщений видна
+ await expect(row.first().locator(".request-update-chip")).toBeVisible();
+
+ // Взять в работу
+ const claimBtn = row.first().getByRole("button", { name: "Взять в работу" });
+ await expect(claimBtn).toBeVisible();
+ await claimBtn.click();
+ await expect(page.locator("#section-requests .status")).toContainText(/Заявка взята в работу|Список обновлен/);
+
+ // Открыть карточку
+ await row.first().locator(".request-track-link").click();
+ await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
+ await expect(page.locator("#request-modal-messages")).toContainText(clientMsg);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 3. Ответ юриста в чате + прикрепление PDF
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-lawyer-3: ответить клиенту текстом и прикрепить PDF", async ({ context, page }, testInfo) => {
+ const phone = randomPhone();
+ trackCleanupPhone(testInfo, phone);
+ const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
+ await preparePublicSession(context, page, appUrl, phone);
+
+ const { trackNumber } = await createRequestViaLanding(page, {
+ phone,
+ description: "Showcase: ответ юриста и PDF",
+ });
+ trackCleanupTrack(testInfo, trackNumber);
+
+ await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
+ await openRequestsSection(page);
+
+ const row = rowByTrack(page, "#section-requests", trackNumber);
+ // Ждём пока строка с заявкой загрузится в таблицу (async fetch)
+ await expect(row).toHaveCount(1);
+ const claimBtn = row.first().getByRole("button", { name: "Взять в работу" });
+ if (await claimBtn.isVisible().catch(() => false)) {
+ await claimBtn.click();
+ await expect(page.locator("#section-requests .status")).toContainText(/работу|обновлен/i);
+ }
+
+ await row.first().locator(".request-track-link").click();
+ await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
+
+ // Текстовый ответ
+ const lawyerReply = `Ответ юриста ${Date.now()}`;
+ await page.getByRole("tab", { name: /Чат/ }).click();
+ await page.locator("#request-modal-message-body").fill(lawyerReply);
+ await page.locator("#request-modal-message-send").click();
+ await expect(page.locator("#section-request-workspace .status")).toContainText("Сообщение отправлено");
+ await expect(page.locator("#request-modal-messages")).toContainText(lawyerReply);
+
+ // Прикрепить PDF
+ const pdfName = `lawyer-reply-${Date.now()}.pdf`;
+ const pdfBuffer = buildTinyPdfBuffer("Showcase answer");
+ const fileInput = page.locator("#request-modal-file-input");
+ if (await fileInput.count()) {
+ await fileInput.setInputFiles({ name: pdfName, mimeType: "application/pdf", buffer: pdfBuffer });
+ await page.locator("#request-modal-message-send").click();
+ await expect(page.locator("#section-request-workspace .status")).toContainText(/файл|отправлен/i);
+ await page.getByRole("tab", { name: /Файлы/ }).click();
+ await expect(page.locator("#request-modal-files")).toContainText(pdfName);
+ }
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 4. Запрос данных от клиента (DataRequirement / форма сбора)
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-lawyer-4: запросить данные клиента — создать DataRequirement", async ({ context, page }, testInfo) => {
+ const phone = randomPhone();
+ trackCleanupPhone(testInfo, phone);
+ const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
+ await preparePublicSession(context, page, appUrl, phone);
+
+ const { trackNumber } = await createRequestViaLanding(page, {
+ phone,
+ description: "Showcase: запрос данных от клиента",
+ });
+ trackCleanupTrack(testInfo, trackNumber);
+
+ await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
+ await openRequestsSection(page);
+
+ const row = rowByTrack(page, "#section-requests", trackNumber);
+ const claimBtn = row.first().getByRole("button", { name: "Взять в работу" });
+ if (await claimBtn.count()) {
+ await claimBtn.click();
+ await expect(page.locator("#section-requests .status")).toContainText(/работу|обновлен/i);
+ }
+
+ await row.first().locator(".request-track-link").click();
+ await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
+
+ // Перейти на вкладку «Данные» / «Запросы данных»
+ const dataTab = page.getByRole("tab", { name: /Данные|Сбор данных|Запрос/ });
+ if (await dataTab.count()) {
+ await dataTab.click();
+
+ // Создать запрос данных
+ const addDataBtn = page.getByRole("button", { name: /Добавить|Запросить|Запрос данных/i });
+ if (await addDataBtn.count()) {
+ await addDataBtn.click();
+ await expect(page.getByRole("heading", { name: /запрос|данных/i })).toBeVisible();
+
+ // Заполнить поле
+ const fieldInput = page.locator("[name='field_label'], #data-req-label, input[placeholder*='Название']").first();
+ if (await fieldInput.count()) {
+ await fieldInput.fill("Серия и номер паспорта");
+ }
+ // Выбрать тип поля
+ const typeSelect = page.locator("[name='field_type'], #data-req-type").first();
+ if (await typeSelect.count()) {
+ await selectDropdownOption(page, typeSelect, "Текст").catch(() => {});
+ }
+ await page.getByRole("button", { name: /Сохранить|Добавить/i }).first().click();
+ await expect(page.locator("#section-request-workspace .status, .data-req-status").first())
+ .toContainText(/сохранен|добавлен|обновлен/i);
+ }
+ }
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 5. Канбан — взять заявку и сменить статус через «Перевести…»
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-lawyer-5: канбан — взять карточку и сменить статус", async ({ context, page }, testInfo) => {
+ const phone = randomPhone();
+ trackCleanupPhone(testInfo, phone);
+ const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
+ await preparePublicSession(context, page, appUrl, phone);
+
+ const { trackNumber, name } = await createRequestViaLanding(page, {
+ phone,
+ description: "Showcase: канбан юриста",
+ });
+ trackCleanupTrack(testInfo, trackNumber);
+
+ await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
+
+ await page.locator("aside .menu button[data-section='kanban']").click();
+ await expect(page.locator("#section-kanban h2")).toHaveText("Канбан заявок");
+
+ // Фильтр по имени клиента
+ await page.locator("#section-kanban .section-head-actions").getByRole("button", { name: "Фильтр" }).click();
+ await selectDropdownOption(page, "#filter-field", "Клиент");
+ await page.locator("#filter-value").fill(name);
+ await page.locator("#filter-overlay").getByRole("button", { name: /Добавить|Сохранить/i }).click();
+ await expect(page.locator("#section-kanban .filter-chip")).toHaveCount(1);
+
+ const card = page.locator("#section-kanban .kanban-card").filter({ hasText: trackNumber }).first();
+ await expect(card).toBeVisible();
+
+ // Взять в работу
+ const claimBtn = card.getByRole("button", { name: "Взять в работу" });
+ if (await claimBtn.count()) {
+ await claimBtn.click();
+ await expect(page.locator("#section-kanban .status")).toContainText(/работу|обновлен/i);
+ }
+
+ // Сменить статус через «Перевести…»
+ const freshCard = page.locator("#section-kanban .kanban-card").filter({ hasText: trackNumber }).first();
+ const transSelect = freshCard.locator(".kanban-transition-select");
+ if (await transSelect.count()) {
+ const newStatus = await selectFirstDropdownOption(page, transSelect);
+ await expect(page.locator("#section-kanban .status")).toContainText(/обновлен|Статус/i);
+ // Карточка в канбане отображает новый статус
+ await expect(page.locator("#section-kanban .kanban-card").filter({ hasText: trackNumber }).first())
+ .toContainText(newStatus);
+ }
+
+ // Убедиться что колонки не содержат UUID-заголовков
+ const headers = page.locator("#section-kanban .kanban-column-head b");
+ const headCount = await headers.count();
+ for (let i = 0; i < headCount; i++) {
+ const text = await headers.nth(i).textContent();
+ expect(text).not.toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
+ }
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 6. Выставить счёт клиенту из карточки заявки
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-lawyer-6: выставить счёт клиенту", async ({ context, page }, testInfo) => {
+ const phone = randomPhone();
+ trackCleanupPhone(testInfo, phone);
+ const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
+ await preparePublicSession(context, page, appUrl, phone);
+
+ const { trackNumber } = await createRequestViaLanding(page, {
+ phone,
+ description: "Showcase: выставление счёта юристом",
+ });
+ trackCleanupTrack(testInfo, trackNumber);
+
+ await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
+ await openRequestsSection(page);
+
+ const row = rowByTrack(page, "#section-requests", trackNumber);
+ const claimBtn = row.first().getByRole("button", { name: "Взять в работу" });
+ if (await claimBtn.count()) {
+ await claimBtn.click();
+ await expect(page.locator("#section-requests .status")).toContainText(/работу|обновлен/i);
+ }
+
+ await row.first().locator(".request-track-link").click();
+ await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
+
+ // Вкладка «Финансы» / «Счета»
+ const financeTab = page.getByRole("tab", { name: /Финанс|Счет|Оплат/i });
+ if (await financeTab.count()) {
+ await financeTab.click();
+ const createInvoiceBtn = page.getByRole("button", { name: /Выставить счёт|Создать счёт|Добавить счёт/i });
+ if (await createInvoiceBtn.count()) {
+ await createInvoiceBtn.click();
+ await page.locator("[name='amount'], #invoice-amount, #record-field-amount").first().fill("12000");
+ await page.getByRole("button", { name: /Сохранить|Создать/i }).first().click();
+ await expect(page.locator("#section-request-workspace .status").first()).toContainText(/сохранен|выставлен|создан/i);
+ }
+ }
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 7. Пройти все статусы и закрыть заявку
+// ─────────────────────────────────────────────────────────────────────────────
+test("showcase-lawyer-7: пройти по статусам и закрыть заявку", async ({ context, page }, testInfo) => {
+ const phone = randomPhone();
+ trackCleanupPhone(testInfo, phone);
+ const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
+ await preparePublicSession(context, page, appUrl, phone);
+
+ const { trackNumber } = await createRequestViaLanding(page, {
+ phone,
+ description: "Showcase: полный цикл статусов",
+ });
+ trackCleanupTrack(testInfo, trackNumber);
+
+ await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
+ await openRequestsSection(page);
+
+ const row = rowByTrack(page, "#section-requests", trackNumber);
+ const claimBtn = row.first().getByRole("button", { name: "Взять в работу" });
+ if (await claimBtn.count()) {
+ await claimBtn.click();
+ await expect(page.locator("#section-requests .status")).toContainText(/работу|обновлен/i);
+ }
+
+ await row.first().locator(".request-track-link").click();
+ await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
+
+ // Менять статусы пока не дойдём до терминального
+ const MAX_TRANSITIONS = 8;
+ for (let i = 0; i < MAX_TRANSITIONS; i++) {
+ const statusPanel = page.locator("#request-status-route, .status-route-panel, [data-testid='status-route']").first();
+ const nextBtn = statusPanel.getByRole("button", { name: /Следующий|Перевести|Подтвердить|→/i }).first();
+ const selectStatus = page.locator("#request-available-status-select, .available-status-select").first();
+
+ if (await selectStatus.count()) {
+ const label = await selectFirstDropdownOption(page, selectStatus);
+ if (!label) break;
+ await page.getByRole("button", { name: /Применить|Сохранить|ОК/i }).first().click();
+ await expect(page.locator("#section-request-workspace .status").first()).toContainText(/обновлен|изменен/i);
+ // Проверить признак терминального статуса
+ const terminal = await page.locator(".status-terminal, [data-terminal='true']").count();
+ if (terminal) break;
+ } else if (await nextBtn.count()) {
+ await nextBtn.click();
+ await expect(page.locator("#section-request-workspace .status").first()).toContainText(/обновлен|изменен/i);
+ } else {
+ break;
+ }
+ await page.waitForTimeout(200);
+ }
+
+ // Итоговый статус заявки — не должен быть UUID
+ const statusBadge = page.locator(".request-field-value.status-badge, .status-name, [data-testid='request-status']").first();
+ if (await statusBadge.count()) {
+ const statusText = await statusBadge.textContent();
+ expect(statusText).not.toMatch(/^[0-9a-f]{8}-/i);
+ }
+});