mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
✨ feat(portals): complete custom domains and unified navigation
This commit is contained in:
1 parent
2b246e193b
commit
3f07d76173
38 files changed
+1592
-596
No files matched your search
@@ -60,6 +60,18 @@ the local migration DB role, is excluded from the backend image, and the
|
||||
importer refuses any environment other than `CUS_ENV=local`. Production compose
|
||||
has no seed service or seed command.
|
||||
|
||||
Демо-контент портала FoxRay и отдельный анонимный Web-виджет поддержки можно
|
||||
установить независимо от общего seed:
|
||||
|
||||
```powershell
|
||||
.\scripts\seed-support-portal.ps1
|
||||
```
|
||||
|
||||
Этот portal-only импорт идемпотентен и не создаёт пользователей, не меняет
|
||||
пароли, роли, сессии или права доступа. Он публикует локальный портал
|
||||
`http://foxray.localhost/`, категории и статьи, а также подключает существующий
|
||||
Web Chat через отдельный канал `foxray-help`.
|
||||
|
||||
## Tests
|
||||
|
||||
All suites run in Docker, so no manual environment is required — the test
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import sys
|
||||
from ipaddress import IPv4Address
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
@@ -215,6 +216,27 @@ CUS_HELP_BASE_DOMAIN = os.environ.get(
|
||||
).strip().lower().rstrip(".")
|
||||
CUS_HELP_PUBLIC_SCHEME = os.environ.get("CUS_HELP_PUBLIC_SCHEME", "https").strip().lower()
|
||||
CUS_HELP_PUBLIC_PORT = os.environ.get("CUS_HELP_PUBLIC_PORT", "").strip()
|
||||
_default_help_public_ipv4 = os.environ.get(
|
||||
"CUSTOCRM_WEB_LISTENING_IP",
|
||||
"",
|
||||
).strip()
|
||||
if _default_help_public_ipv4 in {"", "0.0.0.0", "::"}:
|
||||
_default_help_public_ipv4 = "127.0.0.1" if CUS_HELP_BASE_DOMAIN == "localhost" else ""
|
||||
CUS_HELP_PUBLIC_IPV4 = os.environ.get(
|
||||
"CUS_HELP_PUBLIC_IPV4",
|
||||
_default_help_public_ipv4,
|
||||
).strip()
|
||||
if not DEBUG and not TESTING and not CUS_HELP_PUBLIC_IPV4:
|
||||
raise ImproperlyConfigured(
|
||||
"CUS_HELP_PUBLIC_IPV4 or a non-wildcard CUSTOCRM_WEB_LISTENING_IP is required"
|
||||
)
|
||||
if CUS_HELP_PUBLIC_IPV4:
|
||||
try:
|
||||
IPv4Address(CUS_HELP_PUBLIC_IPV4)
|
||||
except ValueError as error:
|
||||
raise ImproperlyConfigured(
|
||||
"CUS_HELP_PUBLIC_IPV4 must be a valid IPv4 address"
|
||||
) from error
|
||||
|
||||
# P2P calls: opaque invitation lifetime and short-lived signaling/media access.
|
||||
CUS_CALL_INVITE_TTL_SECONDS = int(os.environ.get("CUS_CALL_INVITE_TTL_SECONDS", str(5 * 60)))
|
||||
|
||||
@@ -4,6 +4,7 @@ import uuid
|
||||
|
||||
import dns.exception
|
||||
import dns.resolver
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -48,6 +49,27 @@ def verify_custom_domain(portal: SupportPortal) -> SupportPortal:
|
||||
)
|
||||
if not portal.custom_domain:
|
||||
raise ValidationError({"customDomain": "Сначала укажите домен"})
|
||||
if settings.CUS_HELP_PUBLIC_IPV4:
|
||||
try:
|
||||
address_answers = dns.resolver.resolve(portal.custom_domain, "A")
|
||||
addresses = {
|
||||
getattr(answer, "address", str(answer).rstrip("."))
|
||||
for answer in address_answers
|
||||
}
|
||||
except (
|
||||
dns.resolver.NoAnswer,
|
||||
dns.resolver.NXDOMAIN,
|
||||
dns.resolver.NoNameservers,
|
||||
dns.exception.Timeout,
|
||||
) as error:
|
||||
raise ValidationError(
|
||||
{"customDomain": "A-запись домена пока не найдена"}
|
||||
) from error
|
||||
if settings.CUS_HELP_PUBLIC_IPV4 not in addresses:
|
||||
raise ValidationError(
|
||||
{"customDomain": "A-запись домена указывает не на сервер CustoCRM"}
|
||||
)
|
||||
|
||||
expected = domain_verification_value(portal)
|
||||
try:
|
||||
answers = dns.resolver.resolve(domain_verification_name(portal), "TXT")
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("support_portals", "0005_custom_domain_verification_token")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="supportportal",
|
||||
name="widget_channel",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="support_portal_widgets",
|
||||
to="channels.channel",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -11,7 +11,7 @@ from hub_platform.support_portals.statuses import ArticleStatus, PortalStatus
|
||||
class SupportPortal(TenantRelationModel):
|
||||
"""Публичный Help Center, управляемый внутри отдела поддержки."""
|
||||
|
||||
tenant_relation_fields = ("department",)
|
||||
tenant_relation_fields = ("department", "widget_channel")
|
||||
department = models.ForeignKey(
|
||||
"identity.Department",
|
||||
on_delete=models.PROTECT,
|
||||
@@ -34,6 +34,13 @@ class SupportPortal(TenantRelationModel):
|
||||
)
|
||||
transition_version = models.PositiveIntegerField(default=0)
|
||||
published_at = models.DateTimeField(null=True, blank=True)
|
||||
widget_channel = models.ForeignKey(
|
||||
"channels.Channel",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="support_portal_widgets",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
products = models.ManyToManyField(
|
||||
@@ -69,6 +76,19 @@ class SupportPortal(TenantRelationModel):
|
||||
raise ValidationError(
|
||||
{"department": "Support portal must belong to the support department"}
|
||||
)
|
||||
if self.widget_channel_id is not None:
|
||||
channel = self.widget_channel
|
||||
if channel.department_id is None or channel.department.code != "support":
|
||||
raise ValidationError(
|
||||
{"widget_channel": "Portal widget must belong to the support department"}
|
||||
)
|
||||
if (
|
||||
channel.requires_authenticated_product_identity
|
||||
or not channel.allow_anonymous_sessions
|
||||
):
|
||||
raise ValidationError(
|
||||
{"widget_channel": "Portal widget must allow anonymous web sessions"}
|
||||
)
|
||||
|
||||
|
||||
class SupportPortalProduct(TenantRelationModel):
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.utils import timezone
|
||||
|
||||
from hub_platform.channels.models import Channel
|
||||
from hub_platform.identity.models import Department
|
||||
from hub_platform.integrations.models import IntegrationProvider, IntegrationStatus
|
||||
from hub_platform.products.models import Product
|
||||
from hub_platform.subscriptions.keys import EntitlementKey, QuotaKey
|
||||
from hub_platform.subscriptions.policy import require_entitlement
|
||||
@@ -24,6 +25,7 @@ class PortalInput:
|
||||
slug: str
|
||||
name: str
|
||||
default_locale: str = "ru"
|
||||
widget_channel_id: int | None = None
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@@ -40,6 +42,7 @@ def create_portal(*, context: TenantContext, data: PortalInput) -> SupportPortal
|
||||
hosted_domain=hosted_domain(data.slug.strip().lower()),
|
||||
name=data.name.strip(),
|
||||
default_locale=data.default_locale.strip().lower() or "ru",
|
||||
widget_channel=_widget_channel(context, data.widget_channel_id),
|
||||
)
|
||||
portal.full_clean()
|
||||
portal.save()
|
||||
@@ -65,11 +68,41 @@ def update_portal(
|
||||
portal.hosted_domain = hosted_domain(portal.slug)
|
||||
portal.name = data.name.strip()
|
||||
portal.default_locale = data.default_locale.strip().lower() or "ru"
|
||||
portal.widget_channel = _widget_channel(context, data.widget_channel_id)
|
||||
portal.full_clean()
|
||||
portal.save()
|
||||
return portal
|
||||
|
||||
|
||||
def _widget_channel(
|
||||
context: TenantContext,
|
||||
channel_id: int | None,
|
||||
) -> Channel | None:
|
||||
if channel_id is None:
|
||||
return None
|
||||
try:
|
||||
return (
|
||||
Channel.objects.select_related("department")
|
||||
.filter(
|
||||
id=channel_id,
|
||||
organization=context.organization,
|
||||
department__code="support",
|
||||
is_active=True,
|
||||
requires_authenticated_product_identity=False,
|
||||
allow_anonymous_sessions=True,
|
||||
connections__provider=IntegrationProvider.WEB,
|
||||
connections__status=IntegrationStatus.OK,
|
||||
connections__is_active=True,
|
||||
)
|
||||
.distinct()
|
||||
.get()
|
||||
)
|
||||
except Channel.DoesNotExist as error:
|
||||
raise ValidationError(
|
||||
{"widgetChannelId": "Активный Web-виджет поддержки не найден"}
|
||||
) from error
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def set_portal_status(
|
||||
*, context: TenantContext, portal: SupportPortal, status: str
|
||||
|
||||
@@ -8,6 +8,7 @@ from hub_platform.api.permissions import HasCapability, HasEntitlement
|
||||
from hub_platform.channels.models import Channel
|
||||
from hub_platform.channels.serializers import channel_payload
|
||||
from hub_platform.identity.audit import record_audit_event
|
||||
from hub_platform.integrations.models import IntegrationProvider, IntegrationStatus
|
||||
from hub_platform.support_portals.api import validation_response
|
||||
from hub_platform.support_portals.models import SupportPortal
|
||||
from hub_platform.support_portals.domain_services import (
|
||||
@@ -28,6 +29,17 @@ from hub_platform.subscriptions.keys import QuotaKey
|
||||
from hub_platform.subscriptions.policy import get_effective_policy
|
||||
|
||||
|
||||
def _optional_id(value) -> int | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValidationError(
|
||||
{"widgetChannelId": "Канал веб-виджета не найден"}
|
||||
) from error
|
||||
|
||||
|
||||
class PortalBaseView(APIView):
|
||||
permission_classes = [HasEntitlement, HasCapability]
|
||||
required_entitlement = "support_department"
|
||||
@@ -58,6 +70,15 @@ def _input(request: Request, current: SupportPortal | None = None) -> PortalInpu
|
||||
current.default_locale if current else "ru",
|
||||
)
|
||||
),
|
||||
widget_channel_id=(
|
||||
_optional_id(request.data["widgetChannelId"])
|
||||
if request.data.get("widgetChannelId") not in (None, "")
|
||||
else (
|
||||
current.widget_channel_id
|
||||
if current is not None and "widgetChannelId" not in request.data
|
||||
else None
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -198,7 +219,29 @@ class PortalSupportChannelsView(PortalBaseView):
|
||||
)
|
||||
.order_by("product__name", "name", "id")
|
||||
)
|
||||
return Response({"items": [channel_payload(channel) for channel in channels]})
|
||||
widget_channels = (
|
||||
Channel.objects.select_related("department", "product")
|
||||
.filter(
|
||||
organization=request.tenant_context.organization,
|
||||
department__code="support",
|
||||
is_active=True,
|
||||
requires_authenticated_product_identity=False,
|
||||
allow_anonymous_sessions=True,
|
||||
connections__provider=IntegrationProvider.WEB,
|
||||
connections__status=IntegrationStatus.OK,
|
||||
connections__is_active=True,
|
||||
)
|
||||
.distinct()
|
||||
.order_by("name", "id")
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"items": [channel_payload(channel) for channel in channels],
|
||||
"widgetItems": [
|
||||
channel_payload(channel) for channel in widget_channels
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PortalDomainView(PortalBaseView):
|
||||
|
||||
@@ -10,7 +10,7 @@ def portals_for_context(context: TenantContext) -> QuerySet[SupportPortal]:
|
||||
organization=context.organization,
|
||||
department__code="support",
|
||||
)
|
||||
.select_related("department")
|
||||
.select_related("department", "widget_channel")
|
||||
.prefetch_related("product_links__product", "product_links__support_channel")
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from django.conf import settings
|
||||
|
||||
from hub_platform.support_portals.models import (
|
||||
PortalArticle,
|
||||
PortalArticleRevision,
|
||||
@@ -5,6 +7,7 @@ from hub_platform.support_portals.models import (
|
||||
SupportPortal,
|
||||
SupportPortalProduct,
|
||||
)
|
||||
from hub_platform.integrations.models import IntegrationProvider, IntegrationStatus
|
||||
from hub_platform.support_portals.addressing import portal_public_url
|
||||
from hub_platform.support_portals.domain_services import (
|
||||
domain_verification_name,
|
||||
@@ -38,6 +41,15 @@ def portal_payload(portal: SupportPortal) -> dict:
|
||||
"slug": portal.slug,
|
||||
"hostedDomain": portal.hosted_domain,
|
||||
"customDomain": portal.custom_domain or None,
|
||||
"customDomainAddress": (
|
||||
{
|
||||
"name": portal.custom_domain,
|
||||
"type": "A",
|
||||
"value": settings.CUS_HELP_PUBLIC_IPV4,
|
||||
}
|
||||
if portal.custom_domain and settings.CUS_HELP_PUBLIC_IPV4
|
||||
else None
|
||||
),
|
||||
"customDomainVerifiedAt": portal.custom_domain_verified_at,
|
||||
"customDomainVerification": (
|
||||
{
|
||||
@@ -53,6 +65,8 @@ def portal_payload(portal: SupportPortal) -> dict:
|
||||
"defaultLocale": portal.default_locale,
|
||||
"status": portal.status,
|
||||
"publishedAt": portal.published_at,
|
||||
"widgetChannelId": portal.widget_channel_id,
|
||||
"widgetChannelCode": _public_widget_channel_code(portal),
|
||||
"products": [product_link_payload(link) for link in portal.product_links.all()],
|
||||
"createdAt": portal.created_at,
|
||||
"updatedAt": portal.updated_at,
|
||||
@@ -122,6 +136,7 @@ def public_portal_payload(portal: SupportPortal) -> dict:
|
||||
"slug": portal.slug,
|
||||
"name": portal.name,
|
||||
"defaultLocale": portal.default_locale,
|
||||
"webWidgetChannelCode": _public_widget_channel_code(portal),
|
||||
"products": [
|
||||
{
|
||||
"code": link.product.code,
|
||||
@@ -137,6 +152,23 @@ def public_portal_payload(portal: SupportPortal) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _public_widget_channel_code(portal: SupportPortal) -> str | None:
|
||||
channel = portal.widget_channel
|
||||
if (
|
||||
channel is None
|
||||
or not channel.is_active
|
||||
or channel.requires_authenticated_product_identity
|
||||
or not channel.allow_anonymous_sessions
|
||||
):
|
||||
return None
|
||||
available = channel.connections.filter(
|
||||
provider=IntegrationProvider.WEB,
|
||||
status=IntegrationStatus.OK,
|
||||
is_active=True,
|
||||
).exists()
|
||||
return channel.code if available else None
|
||||
|
||||
|
||||
def public_article_payload(article: PortalArticle, *, content: bool = True) -> dict:
|
||||
return {
|
||||
"slug": article.slug,
|
||||
|
||||
@@ -14,12 +14,88 @@ from hub_platform.identity.models import (
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from hub_platform.integrations.models import (
|
||||
Integration,
|
||||
IntegrationKind,
|
||||
IntegrationProvider,
|
||||
IntegrationStatus,
|
||||
)
|
||||
from hub_platform.subscriptions.testing import create_test_subscription
|
||||
from hub_platform.support_portals.models import SupportPortal, SupportPortalProduct
|
||||
from hub_platform.support_portals.tests.base import SupportPortalTestCase
|
||||
|
||||
|
||||
class SupportPortalManagementTests(SupportPortalTestCase):
|
||||
def test_anonymous_web_channel_can_be_attached_as_portal_widget(self) -> None:
|
||||
portal_id = self.create_portal().json()["portal"]["id"]
|
||||
widget_channel = Channel.objects.create(
|
||||
organization=self.organization,
|
||||
code="foxray-portal-chat",
|
||||
name="FoxRay — чат портала",
|
||||
department=self.channel.department,
|
||||
product=self.product,
|
||||
requires_authenticated_product_identity=False,
|
||||
allow_anonymous_sessions=True,
|
||||
allow_self_reported_contact=True,
|
||||
)
|
||||
Integration.objects.create(
|
||||
organization=self.organization,
|
||||
kind=IntegrationKind.MESSENGER,
|
||||
provider=IntegrationProvider.WEB,
|
||||
name="FoxRay portal widget",
|
||||
channel=widget_channel,
|
||||
status=IntegrationStatus.OK,
|
||||
)
|
||||
|
||||
options = self.client.get(
|
||||
f"/api/v1/support/portals/{portal_id}/support-channels/"
|
||||
)
|
||||
self.assertEqual(options.status_code, 200, options.content)
|
||||
self.assertEqual(
|
||||
[item["id"] for item in options.json()["widgetItems"]],
|
||||
[widget_channel.id],
|
||||
)
|
||||
|
||||
updated = self.client.patch(
|
||||
f"/api/v1/support/portals/{portal_id}/",
|
||||
{"widgetChannelId": widget_channel.id},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(updated.status_code, 200, updated.content)
|
||||
self.assertEqual(
|
||||
updated.json()["portal"]["widgetChannelCode"],
|
||||
widget_channel.code,
|
||||
)
|
||||
|
||||
config = self.client.get(
|
||||
f"/api/v1/webchat/config/?channel={widget_channel.code}",
|
||||
HTTP_ORIGIN="http://foxray-help.localhost",
|
||||
)
|
||||
self.assertEqual(config.status_code, 200, config.content)
|
||||
self.assertTrue(config.json()["available"])
|
||||
|
||||
def test_authenticated_support_channel_is_rejected_by_public_webchat(self) -> None:
|
||||
Integration.objects.create(
|
||||
organization=self.organization,
|
||||
kind=IntegrationKind.MESSENGER,
|
||||
provider=IntegrationProvider.WEB,
|
||||
name="Authenticated support widget",
|
||||
channel=self.channel,
|
||||
status=IntegrationStatus.OK,
|
||||
)
|
||||
|
||||
config = self.client.get(
|
||||
f"/api/v1/webchat/config/?channel={self.channel.code}",
|
||||
)
|
||||
session = self.client.post(
|
||||
"/api/v1/webchat/session/",
|
||||
{"channel": self.channel.code},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertFalse(config.json()["available"])
|
||||
self.assertEqual(session.status_code, 404, session.content)
|
||||
|
||||
def test_subscription_allows_multiple_active_portals(self) -> None:
|
||||
first = self.create_portal()
|
||||
self.assertEqual(first.status_code, 201, first.content)
|
||||
@@ -85,7 +161,8 @@ class SupportPortalManagementTests(SupportPortalTestCase):
|
||||
link.refresh_from_db()
|
||||
self.assertEqual(link.support_channel, self.channel)
|
||||
|
||||
def test_custom_domain_requires_matching_dns_record(self) -> None:
|
||||
@override_settings(CUS_HELP_PUBLIC_IPV4="203.0.113.42")
|
||||
def test_custom_domain_requires_matching_dns_records(self) -> None:
|
||||
portal_id = self.create_portal().json()["portal"]["id"]
|
||||
configured = self.client.put(
|
||||
f"/api/v1/support/portals/{portal_id}/domain/",
|
||||
@@ -94,9 +171,23 @@ class SupportPortalManagementTests(SupportPortalTestCase):
|
||||
)
|
||||
self.assertEqual(configured.status_code, 200, configured.content)
|
||||
verification = configured.json()["portal"]["customDomainVerification"]
|
||||
answer = mock.Mock(strings=[verification["value"].encode("utf-8")])
|
||||
self.assertEqual(
|
||||
configured.json()["portal"]["customDomainAddress"],
|
||||
{
|
||||
"name": "help.customer.example",
|
||||
"type": "A",
|
||||
"value": "203.0.113.42",
|
||||
},
|
||||
)
|
||||
address_answer = mock.Mock(address="203.0.113.42")
|
||||
verification_answer = mock.Mock(
|
||||
strings=[verification["value"].encode("utf-8")]
|
||||
)
|
||||
|
||||
with mock.patch("dns.resolver.resolve", return_value=[answer]):
|
||||
with mock.patch(
|
||||
"dns.resolver.resolve",
|
||||
side_effect=[[address_answer], [verification_answer]],
|
||||
) as resolve:
|
||||
verified = self.client.post(
|
||||
f"/api/v1/support/portals/{portal_id}/domain/verify/",
|
||||
{},
|
||||
@@ -106,6 +197,35 @@ class SupportPortalManagementTests(SupportPortalTestCase):
|
||||
self.assertEqual(verified.status_code, 200, verified.content)
|
||||
self.assertIsNotNone(verified.json()["portal"]["customDomainVerifiedAt"])
|
||||
self.assertIsNone(verified.json()["portal"]["customDomainVerification"])
|
||||
self.assertEqual(
|
||||
resolve.call_args_list,
|
||||
[
|
||||
mock.call("help.customer.example", "A"),
|
||||
mock.call("_custocrm.help.customer.example", "TXT"),
|
||||
],
|
||||
)
|
||||
|
||||
@override_settings(CUS_HELP_PUBLIC_IPV4="203.0.113.42")
|
||||
def test_custom_domain_rejects_wrong_address_record(self) -> None:
|
||||
portal_id = self.create_portal().json()["portal"]["id"]
|
||||
self.client.put(
|
||||
f"/api/v1/support/portals/{portal_id}/domain/",
|
||||
{"customDomain": "help.customer.example"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
"dns.resolver.resolve",
|
||||
return_value=[mock.Mock(address="203.0.113.99")],
|
||||
):
|
||||
response = self.client.post(
|
||||
f"/api/v1/support/portals/{portal_id}/domain/verify/",
|
||||
{},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400, response.content)
|
||||
self.assertIn("указывает не на сервер CustoCRM", str(response.json()))
|
||||
|
||||
@override_settings(CUS_APP_PRIMARY_HOSTS=["app.customer.example"])
|
||||
def test_custom_domain_cannot_shadow_application_host(self) -> None:
|
||||
|
||||
@@ -2,6 +2,13 @@ from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
|
||||
from hub_platform.channels.models import Channel
|
||||
from hub_platform.integrations.models import (
|
||||
Integration,
|
||||
IntegrationKind,
|
||||
IntegrationProvider,
|
||||
IntegrationStatus,
|
||||
)
|
||||
from hub_platform.support_portals.models import PortalArticleFeedback, SupportPortal
|
||||
from hub_platform.testing import TenantAPIClient
|
||||
|
||||
@@ -21,6 +28,27 @@ class PublicSupportPortalTests(TestCase):
|
||||
).json()["portal"]
|
||||
self.portal_id = portal["id"]
|
||||
self.portal_host = portal["hostedDomain"]
|
||||
widget_channel = Channel.objects.create(
|
||||
organization=result.organization,
|
||||
code="edevs-help-chat",
|
||||
name="Edevs Help — чат",
|
||||
department=result.support_department,
|
||||
requires_authenticated_product_identity=False,
|
||||
allow_anonymous_sessions=True,
|
||||
)
|
||||
Integration.objects.create(
|
||||
organization=result.organization,
|
||||
kind=IntegrationKind.MESSENGER,
|
||||
provider=IntegrationProvider.WEB,
|
||||
name="Edevs Help widget",
|
||||
channel=widget_channel,
|
||||
status=IntegrationStatus.OK,
|
||||
)
|
||||
self.client.patch(
|
||||
f"/api/v1/support/portals/{self.portal_id}/",
|
||||
{"widgetChannelId": widget_channel.id},
|
||||
format="json",
|
||||
)
|
||||
category = self.client.post(
|
||||
f"/api/v1/support/portals/{self.portal_id}/categories/",
|
||||
{
|
||||
@@ -71,6 +99,10 @@ class PublicSupportPortalTests(TestCase):
|
||||
portal = self.client.get("/api/v1/help/", HTTP_HOST=self.portal_host)
|
||||
self.assertEqual(portal.status_code, 200, portal.content)
|
||||
self.assertEqual(portal.json()["portal"]["name"], "Edevs Help")
|
||||
self.assertEqual(
|
||||
portal.json()["portal"]["webWidgetChannelCode"],
|
||||
"edevs-help-chat",
|
||||
)
|
||||
self.assertEqual(
|
||||
portal.json()["categories"][0]["description"],
|
||||
"Быстрый старт и основные возможности",
|
||||
@@ -128,3 +160,33 @@ class PublicSupportPortalTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
self.assertEqual(response.json()["portal"]["name"], "Edevs Help")
|
||||
|
||||
@override_settings(ROOT_URLCONF="hub_backend.urls_platform")
|
||||
def test_gateway_authorizes_only_published_portal_domains(self) -> None:
|
||||
custom_domain = "help.foxray.example"
|
||||
SupportPortal.objects.filter(id=self.portal_id).update(
|
||||
status="PUBLISHED",
|
||||
custom_domain=custom_domain,
|
||||
)
|
||||
|
||||
hosted = self.client.get(
|
||||
"/api/v1/gateway/help-domain/",
|
||||
{"domain": self.portal_host},
|
||||
)
|
||||
unverified = self.client.get(
|
||||
"/api/v1/gateway/help-domain/",
|
||||
{"domain": custom_domain},
|
||||
)
|
||||
|
||||
self.assertEqual(hosted.status_code, 204, hosted.content)
|
||||
self.assertEqual(unverified.status_code, 404, unverified.content)
|
||||
|
||||
SupportPortal.objects.filter(id=self.portal_id).update(
|
||||
custom_domain_verified_at=timezone.now(),
|
||||
)
|
||||
verified = self.client.get(
|
||||
"/api/v1/gateway/help-domain/",
|
||||
{"domain": custom_domain},
|
||||
)
|
||||
|
||||
self.assertEqual(verified.status_code, 204, verified.content)
|
||||
@@ -51,6 +51,7 @@ def _resolved_web_connection(channel_code: str):
|
||||
id=route.resource_id,
|
||||
organization=organization,
|
||||
provider=IntegrationProvider.WEB,
|
||||
is_active=True,
|
||||
channel__organization=organization,
|
||||
channel__code=channel_code,
|
||||
channel__is_active=True,
|
||||
@@ -86,6 +87,11 @@ class WebchatConfigView(_Public):
|
||||
with _resolved_web_connection(channel_code) as (context, integration):
|
||||
if context is None or integration is None:
|
||||
return Response({"available": False})
|
||||
if (
|
||||
integration.channel.requires_authenticated_product_identity
|
||||
or not integration.channel.allow_anonymous_sessions
|
||||
):
|
||||
return Response({"available": False})
|
||||
return Response(
|
||||
services.public_config(
|
||||
context=context,
|
||||
@@ -101,6 +107,11 @@ class WebchatSessionView(_Public):
|
||||
with _resolved_web_connection(channel_code) as (context, integration):
|
||||
if context is None or integration is None:
|
||||
return Response({"detail": "Канал недоступен"}, status=404)
|
||||
if (
|
||||
integration.channel.requires_authenticated_product_identity
|
||||
or not integration.channel.allow_anonymous_sessions
|
||||
):
|
||||
return Response({"detail": "Канал недоступен"}, status=404)
|
||||
result = services.issue_session(context=context, integration=integration)
|
||||
if result is None:
|
||||
return Response({"detail": "Канал недоступен"}, status=404)
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
|
||||
|
||||
import { LogoIcon } from "../../shared/icons";
|
||||
import { HelpSearch } from "./HelpSearch";
|
||||
import { PortalWebWidget } from "./PortalWebWidget";
|
||||
import { SupportLauncher } from "./SupportLauncher";
|
||||
import type { HelpPortal } from "./types";
|
||||
|
||||
@@ -52,7 +53,9 @@ export function HelpLayout({
|
||||
</a>
|
||||
<span>База знаний и поддержка</span>
|
||||
</footer>
|
||||
<SupportLauncher products={portal.products} />
|
||||
{portal.webWidgetChannelCode
|
||||
? <PortalWebWidget channelCode={portal.webWidgetChannelCode} />
|
||||
: <SupportLauncher products={portal.products} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
const SCRIPT_ID = "custocrm-help-widget-loader";
|
||||
|
||||
export function PortalWebWidget({ channelCode }: { channelCode: string }) {
|
||||
useEffect(() => {
|
||||
if (document.getElementById(SCRIPT_ID)) return;
|
||||
const script = document.createElement("script");
|
||||
script.id = SCRIPT_ID;
|
||||
script.src = "/chat-widget.js";
|
||||
script.async = true;
|
||||
script.dataset.channel = channelCode;
|
||||
document.body.appendChild(script);
|
||||
}, [channelCode]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export type HelpPortal = {
|
||||
slug: string;
|
||||
name: string;
|
||||
defaultLocale: string;
|
||||
webWidgetChannelCode: string | null;
|
||||
products: HelpProduct[];
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Dropdown } from "antd";
|
||||
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { StatusPill } from "../../shared/ui";
|
||||
import { formatDate } from "../../shared/utils";
|
||||
import type { PortalArticle } from "./model";
|
||||
|
||||
export function PortalArticleTable({
|
||||
articles,
|
||||
canManage,
|
||||
onArchive,
|
||||
onEdit,
|
||||
}: {
|
||||
articles: PortalArticle[];
|
||||
canManage: boolean;
|
||||
onArchive: (article: PortalArticle) => void;
|
||||
onEdit: (article: PortalArticle) => void;
|
||||
}) {
|
||||
return (
|
||||
<table className="baseline-table knowledge-table portal-article-table">
|
||||
<colgroup>
|
||||
<col className="portal-article-col-title" />
|
||||
<col className="portal-article-col-category" />
|
||||
<col className="portal-article-col-language" />
|
||||
<col className="portal-article-col-version" />
|
||||
<col className="portal-article-col-status" />
|
||||
<col className="portal-article-col-updated" />
|
||||
<col className="portal-article-col-actions" />
|
||||
</colgroup>
|
||||
<thead><tr><th>СТАТЬЯ</th><th>РАЗДЕЛ</th><th>ЯЗЫК</th><th>ВЕРСИЯ</th><th>СТАТУС</th><th>ОБНОВЛЕНО</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{articles.map((article) => {
|
||||
const title = article.latestRevision?.title || article.slug;
|
||||
const menuItems = [
|
||||
{
|
||||
key: "edit",
|
||||
label: <button type="button" onClick={() => onEdit(article)}><Icon name="edit" size={15} />Изменить</button>,
|
||||
},
|
||||
...(article.status !== "ARCHIVED" ? [{
|
||||
key: "archive",
|
||||
label: <button className="danger" type="button" onClick={() => onArchive(article)}><Icon name="trash" size={15} />В архив</button>,
|
||||
}] : []),
|
||||
];
|
||||
return (
|
||||
<tr className="knowledge-row" key={article.id} onClick={() => onEdit(article)}>
|
||||
<td>
|
||||
<button
|
||||
className="link is-strong is-neutral"
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onEdit(article);
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
<small className="knowledge-description">{article.latestRevision?.summary || article.slug}</small>
|
||||
</td>
|
||||
<td className="knowledge-category-path">{article.category.name}</td>
|
||||
<td>{article.locale.toLocaleUpperCase()}</td>
|
||||
<td>{article.publishedRevision ? article.publishedRevision.revision : "—"}</td>
|
||||
<td><StatusPill status={article.status === "PUBLISHED" ? "published" : article.status === "ARCHIVED" ? "archived" : "draft"} /></td>
|
||||
<td>{formatDate(article.updatedAt)}</td>
|
||||
<td className="row-actions" onClick={(event) => event.stopPropagation()}>
|
||||
{canManage && (
|
||||
<Dropdown menu={{ items: menuItems }} overlayClassName="app-dropdown" placement="bottomRight" trigger={["click"]}>
|
||||
<button aria-label={`Действия: ${title}`} className="row-menu-button" type="button"><Icon name="more" size={18} /></button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
import { Dropdown } from "antd";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { CategoryTree } from "../../shared/content-library/CategoryTree";
|
||||
import { ContentLibraryTable } from "../../shared/content-library/ContentLibraryTable";
|
||||
import { ContentLibraryToolbar } from "../../shared/content-library/ContentLibraryToolbar";
|
||||
import { DecisionDialog } from "../../shared/DecisionDialog";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { StatusPill } from "../../shared/ui";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import { formatDate } from "../../shared/utils";
|
||||
import { PortalArticleEditor } from "./PortalArticleEditor";
|
||||
import { PortalArticleTable } from "./PortalArticleTable";
|
||||
import { PortalCategoryManagement } from "./PortalCategoryManagement";
|
||||
import {
|
||||
archivePortalArticle,
|
||||
@@ -150,35 +147,12 @@ export function PortalContent({
|
||||
onRetry={() => void reload()}
|
||||
footer={<div className="ai-table-footer"><span>{filtered.length} материалов</span></div>}
|
||||
>
|
||||
<table className="baseline-table knowledge-table">
|
||||
<thead><tr><th>СТАТЬЯ</th><th>РАЗДЕЛ</th><th>ЯЗЫК</th><th>ВЕРСИЯ</th><th>СТАТУС</th><th>ОБНОВЛЕНО</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{filtered.map((article) => (
|
||||
<tr className="knowledge-row" key={article.id} onClick={() => setEditing(article)}>
|
||||
<td><button className="link is-strong is-neutral" type="button" onClick={() => setEditing(article)}>{article.latestRevision?.title || article.slug}</button><small className="knowledge-description">{article.latestRevision?.summary || article.slug}</small></td>
|
||||
<td className="knowledge-category-path">{article.category.name}</td>
|
||||
<td>{article.locale.toLocaleUpperCase()}</td>
|
||||
<td>{article.publishedRevision ? article.publishedRevision.revision : "—"}</td>
|
||||
<td><StatusPill status={article.status === "PUBLISHED" ? "published" : article.status === "ARCHIVED" ? "archived" : "draft"} /></td>
|
||||
<td>{formatDate(article.updatedAt)}</td>
|
||||
<td onClick={(event) => event.stopPropagation()}>
|
||||
{canManage && (
|
||||
<Dropdown
|
||||
overlayClassName="app-dropdown"
|
||||
trigger={["click"]}
|
||||
menu={{ items: [
|
||||
{ key: "edit", label: "Изменить", onClick: () => setEditing(article) },
|
||||
...(article.status !== "ARCHIVED" ? [{ key: "archive", danger: true, label: "В архив", onClick: () => setDecision({ article, type: "archive" as const }) }] : []),
|
||||
] }}
|
||||
>
|
||||
<button aria-label={`Действия: ${article.latestRevision?.title ?? article.slug}`} className="row-menu-button" type="button"><Icon name="more" size={18} /></button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<PortalArticleTable
|
||||
articles={filtered}
|
||||
canManage={canManage}
|
||||
onArchive={(article) => setDecision({ article, type: "archive" })}
|
||||
onEdit={setEditing}
|
||||
/>
|
||||
</ContentLibraryTable>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ export function PortalDomainSettings({
|
||||
try {
|
||||
const payload = await setPortalCustomDomain(portal.id, domain);
|
||||
onChanged(payload.portal);
|
||||
setFeedback(domain.trim() ? "Домен сохранён. Добавьте TXT-запись для подтверждения." : "Свой домен отключён");
|
||||
setFeedback(domain.trim() ? "Домен сохранён. Добавьте показанные DNS-записи и проверьте домен." : "Свой домен отключён");
|
||||
} catch (error) {
|
||||
setFeedback(portalErrorMessage(error, "Не удалось сохранить домен"));
|
||||
} finally {
|
||||
@@ -68,14 +68,29 @@ export function PortalDomainSettings({
|
||||
wide
|
||||
/>
|
||||
</div>
|
||||
{portal.customDomainVerification && (
|
||||
{(portal.customDomainAddress || portal.customDomainVerification) && (
|
||||
<div className="portal-domain-verification">
|
||||
<strong>Добавьте DNS-запись</strong>
|
||||
<dl>
|
||||
<div><dt>Тип</dt><dd>{portal.customDomainVerification.type}</dd></div>
|
||||
<div><dt>Имя</dt><dd><code>{portal.customDomainVerification.name}</code></dd></div>
|
||||
<div><dt>Значение</dt><dd><code>{portal.customDomainVerification.value}</code></dd></div>
|
||||
</dl>
|
||||
<strong>DNS-записи для {portal.customDomain}</strong>
|
||||
{portal.customDomainAddress && (
|
||||
<div className="portal-domain-record">
|
||||
<b>A-запись</b>
|
||||
<dl>
|
||||
<div><dt>Тип</dt><dd>{portal.customDomainAddress.type}</dd></div>
|
||||
<div><dt>Имя</dt><dd><code>{portal.customDomainAddress.name}</code></dd></div>
|
||||
<div><dt>Значение</dt><dd><code>{portal.customDomainAddress.value}</code></dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
{portal.customDomainVerification && (
|
||||
<div className="portal-domain-record">
|
||||
<b>TXT-запись</b>
|
||||
<dl>
|
||||
<div><dt>Тип</dt><dd>{portal.customDomainVerification.type}</dd></div>
|
||||
<div><dt>Имя</dt><dd><code>{portal.customDomainVerification.name}</code></dd></div>
|
||||
<div><dt>Значение</dt><dd><code>{portal.customDomainVerification.value}</code></dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="portal-domain-actions">
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { Channel } from "../channels/types";
|
||||
import { SelectField, SwitchButton } from "../../shared/form-controls";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import type { Product } from "../../types";
|
||||
import {
|
||||
portalErrorMessage,
|
||||
replacePortalProducts,
|
||||
type SupportPortal,
|
||||
} from "./model";
|
||||
|
||||
export function PortalProductSettings({
|
||||
portal,
|
||||
products,
|
||||
channels,
|
||||
canManage,
|
||||
onChanged,
|
||||
}: {
|
||||
portal: SupportPortal;
|
||||
products: Product[];
|
||||
channels: Channel[];
|
||||
canManage: boolean;
|
||||
onChanged: (portal: SupportPortal) => void;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Record<number, number | null>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(Object.fromEntries(
|
||||
portal.products.map((item) => [item.productId, item.supportChannelId]),
|
||||
));
|
||||
}, [portal.products]);
|
||||
|
||||
const supportChannels = useMemo(() => channels.filter((channel) => (
|
||||
channel.isActive
|
||||
&& channel.department === "support"
|
||||
&& channel.product
|
||||
&& channel.policy.requiresAuthenticatedProductIdentity
|
||||
&& !channel.policy.allowAnonymousSessions
|
||||
)), [channels]);
|
||||
|
||||
function toggleProduct(productId: number) {
|
||||
setSelected((current) => {
|
||||
const next = { ...current };
|
||||
if (productId in next) delete next[productId];
|
||||
else next[productId] = null;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setFeedback("");
|
||||
try {
|
||||
const items = Object.entries(selected).map(([productId, supportChannelId], index) => ({
|
||||
productId: Number(productId),
|
||||
supportChannelId,
|
||||
sortOrder: index,
|
||||
}));
|
||||
const payload = await replacePortalProducts(portal.id, items);
|
||||
onChanged(payload.portal);
|
||||
setFeedback("Продукты портала обновлены");
|
||||
} catch (caught) {
|
||||
setFeedback(portalErrorMessage(caught, "Не удалось сохранить продукты"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="portal-section">
|
||||
<div className="portal-section-heading">
|
||||
<div><h2>Продукты и поддержка</h2><p>Выберите продукты и каналы, в которых клиенты смогут обратиться в поддержку.</p></div>
|
||||
</div>
|
||||
<div className="portal-product-list">
|
||||
{products.map((product) => {
|
||||
const enabled = product.id in selected;
|
||||
const options: Array<[string, string]> = [
|
||||
["", "Без перехода в поддержку"],
|
||||
...supportChannels
|
||||
.filter((channel) => channel.product?.id === product.id)
|
||||
.map((channel): [string, string] => [String(channel.id), channel.name]),
|
||||
];
|
||||
return (
|
||||
<div className={enabled ? "is-enabled" : ""} key={product.id}>
|
||||
<SwitchButton
|
||||
checked={enabled}
|
||||
className="ui-switch"
|
||||
disabled={!canManage}
|
||||
label={`Добавить ${product.name}`}
|
||||
onClick={() => toggleProduct(product.id)}
|
||||
/>
|
||||
<span><strong>{product.name}</strong><small>{product.code}</small></span>
|
||||
{enabled && (
|
||||
<SelectField
|
||||
disabled={!canManage}
|
||||
label="Канал поддержки"
|
||||
value={String(selected[product.id] ?? "")}
|
||||
onChange={(value) => setSelected((current) => ({
|
||||
...current,
|
||||
[product.id]: value ? Number(value) : null,
|
||||
}))}
|
||||
options={options}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{canManage && <Button variant="secondary" disabled={busy} onClick={() => void save()}>Сохранить продукты</Button>}
|
||||
{feedback && <div className="portal-save-feedback">{feedback}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { Channel } from "../channels/types";
|
||||
import { FormField, SelectField, SwitchButton } from "../../shared/form-controls";
|
||||
import { FormField, SelectField } from "../../shared/form-controls";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import type { Product } from "../../types";
|
||||
import {
|
||||
listPortalSupportChannels,
|
||||
portalErrorMessage,
|
||||
replacePortalProducts,
|
||||
updateSupportPortal,
|
||||
type SupportPortal,
|
||||
} from "./model";
|
||||
import { PortalDomainSettings } from "./PortalDomainSettings";
|
||||
import { PortalProductSettings } from "./PortalProductSettings";
|
||||
import { PortalWidgetSettings } from "./PortalWidgetSettings";
|
||||
|
||||
export function PortalSettings({
|
||||
portal,
|
||||
@@ -28,7 +29,7 @@ export function PortalSettings({
|
||||
const [slug, setSlug] = useState(portal.slug);
|
||||
const [locale, setLocale] = useState(portal.defaultLocale);
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
const [selected, setSelected] = useState<Record<number, number | null>>({});
|
||||
const [widgetChannels, setWidgetChannels] = useState<Channel[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
|
||||
@@ -36,25 +37,20 @@ export function PortalSettings({
|
||||
setName(portal.name);
|
||||
setSlug(portal.slug);
|
||||
setLocale(portal.defaultLocale);
|
||||
setSelected(Object.fromEntries(
|
||||
portal.products.map((item) => [item.productId, item.supportChannelId]),
|
||||
));
|
||||
}, [portal]);
|
||||
|
||||
useEffect(() => {
|
||||
listPortalSupportChannels(portal.id)
|
||||
.then((payload) => setChannels(payload.items))
|
||||
.catch(() => setChannels([]));
|
||||
.then((payload) => {
|
||||
setChannels(payload.items);
|
||||
setWidgetChannels(payload.widgetItems);
|
||||
})
|
||||
.catch(() => {
|
||||
setChannels([]);
|
||||
setWidgetChannels([]);
|
||||
});
|
||||
}, [portal.id]);
|
||||
|
||||
const supportChannels = useMemo(() => channels.filter((channel) => (
|
||||
channel.isActive
|
||||
&& channel.department === "support"
|
||||
&& channel.product
|
||||
&& channel.policy.requiresAuthenticatedProductIdentity
|
||||
&& !channel.policy.allowAnonymousSessions
|
||||
)), [channels]);
|
||||
|
||||
async function saveBasics() {
|
||||
setBusy(true);
|
||||
setFeedback("");
|
||||
@@ -73,34 +69,6 @@ export function PortalSettings({
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProducts() {
|
||||
setBusy(true);
|
||||
setFeedback("");
|
||||
try {
|
||||
const items = Object.entries(selected).map(([productId, supportChannelId], index) => ({
|
||||
productId: Number(productId),
|
||||
supportChannelId,
|
||||
sortOrder: index,
|
||||
}));
|
||||
const payload = await replacePortalProducts(portal.id, items);
|
||||
onChanged(payload.portal);
|
||||
setFeedback("Продукты портала обновлены");
|
||||
} catch (caught) {
|
||||
setFeedback(portalErrorMessage(caught, "Не удалось сохранить продукты"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleProduct(productId: number) {
|
||||
setSelected((current) => {
|
||||
const next = { ...current };
|
||||
if (productId in next) delete next[productId];
|
||||
else next[productId] = null;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portal-settings-grid">
|
||||
<section className="portal-section">
|
||||
@@ -122,47 +90,19 @@ export function PortalSettings({
|
||||
{canManage && <Button variant="primary" disabled={busy} onClick={() => void saveBasics()}>Сохранить настройки</Button>}
|
||||
</section>
|
||||
|
||||
<section className="portal-section">
|
||||
<div className="portal-section-heading">
|
||||
<div><h2>Продукты и поддержка</h2><p>Выберите продукты и каналы, в которых клиенты смогут обратиться в поддержку.</p></div>
|
||||
</div>
|
||||
<div className="portal-product-list">
|
||||
{products.map((product) => {
|
||||
const enabled = product.id in selected;
|
||||
const options: Array<[string, string]> = [
|
||||
["", "Без перехода в поддержку"],
|
||||
...supportChannels
|
||||
.filter((channel) => channel.product?.id === product.id)
|
||||
.map((channel): [string, string] => [String(channel.id), channel.name]),
|
||||
];
|
||||
return (
|
||||
<div className={enabled ? "is-enabled" : ""} key={product.id}>
|
||||
<SwitchButton
|
||||
checked={enabled}
|
||||
className="ui-switch"
|
||||
disabled={!canManage}
|
||||
label={`Добавить ${product.name}`}
|
||||
onClick={() => toggleProduct(product.id)}
|
||||
/>
|
||||
<span><strong>{product.name}</strong><small>{product.code}</small></span>
|
||||
{enabled && (
|
||||
<SelectField
|
||||
disabled={!canManage}
|
||||
label="Канал поддержки"
|
||||
value={String(selected[product.id] ?? "")}
|
||||
onChange={(value) => setSelected((current) => ({
|
||||
...current,
|
||||
[product.id]: value ? Number(value) : null,
|
||||
}))}
|
||||
options={options}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{canManage && <Button variant="secondary" disabled={busy} onClick={() => void saveProducts()}>Сохранить продукты</Button>}
|
||||
</section>
|
||||
<PortalProductSettings
|
||||
canManage={canManage}
|
||||
channels={channels}
|
||||
portal={portal}
|
||||
products={products}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
<PortalWidgetSettings
|
||||
canManage={canManage}
|
||||
channels={widgetChannels}
|
||||
portal={portal}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
<PortalDomainSettings portal={portal} canManage={canManage} onChanged={onChanged} />
|
||||
{feedback && <div className="portal-save-feedback">{feedback}</div>}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { Channel } from "../channels/types";
|
||||
import { SelectField } from "../../shared/form-controls";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import {
|
||||
portalErrorMessage,
|
||||
updateSupportPortal,
|
||||
type SupportPortal,
|
||||
} from "./model";
|
||||
|
||||
export function PortalWidgetSettings({
|
||||
canManage,
|
||||
channels,
|
||||
portal,
|
||||
onChanged,
|
||||
}: {
|
||||
canManage: boolean;
|
||||
channels: Channel[];
|
||||
portal: SupportPortal;
|
||||
onChanged: (portal: SupportPortal) => void;
|
||||
}) {
|
||||
const [channelId, setChannelId] = useState(portal.widgetChannelId);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
|
||||
useEffect(() => setChannelId(portal.widgetChannelId), [portal.widgetChannelId]);
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setFeedback("");
|
||||
try {
|
||||
const payload = await updateSupportPortal(portal.id, {
|
||||
widgetChannelId: channelId,
|
||||
});
|
||||
onChanged(payload.portal);
|
||||
setFeedback("Веб-виджет обновлён");
|
||||
} catch (caught) {
|
||||
setFeedback(portalErrorMessage(caught, "Не удалось сохранить веб-виджет"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="portal-section">
|
||||
<div className="portal-section-heading">
|
||||
<div>
|
||||
<h2>Веб-виджет</h2>
|
||||
<p>Публичный чат отображается на всех страницах портала.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="portal-settings-fields">
|
||||
<SelectField
|
||||
disabled={!canManage}
|
||||
label="Канал виджета"
|
||||
value={String(channelId ?? "")}
|
||||
onChange={(value) => setChannelId(value ? Number(value) : null)}
|
||||
options={[
|
||||
["", "Не показывать"],
|
||||
...channels.map((channel): [string, string] => [
|
||||
String(channel.id),
|
||||
channel.name,
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button variant="secondary" disabled={busy} onClick={() => void save()}>
|
||||
Сохранить виджет
|
||||
</Button>
|
||||
)}
|
||||
{feedback && <div className="portal-save-feedback">{feedback}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { api } from "../../api/client";
|
||||
import type { Channel } from "../channels/types";
|
||||
import type {
|
||||
ArticleRevision,
|
||||
PortalArticle,
|
||||
PortalCategory,
|
||||
PortalInput,
|
||||
PortalStatus,
|
||||
SupportPortal,
|
||||
SupportPortalList,
|
||||
} from "./model";
|
||||
|
||||
export function listSupportPortals(): Promise<SupportPortalList> {
|
||||
return api("/api/v1/support/portals/");
|
||||
}
|
||||
|
||||
export function createSupportPortal(input: PortalInput): Promise<{ portal: SupportPortal }> {
|
||||
return api("/api/v1/support/portals/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadSupportPortal(id: number): Promise<{ portal: SupportPortal }> {
|
||||
return api(`/api/v1/support/portals/${id}/`);
|
||||
}
|
||||
|
||||
export function updateSupportPortal(
|
||||
id: number,
|
||||
input: Partial<PortalInput>,
|
||||
): Promise<{ portal: SupportPortal }> {
|
||||
return api(`/api/v1/support/portals/${id}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function changePortalStatus(
|
||||
id: number,
|
||||
status: PortalStatus,
|
||||
): Promise<{ portal: SupportPortal }> {
|
||||
return api(`/api/v1/support/portals/${id}/status/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
}
|
||||
|
||||
export function replacePortalProducts(
|
||||
id: number,
|
||||
items: Array<{ productId: number; supportChannelId: number | null; sortOrder: number }>,
|
||||
): Promise<{ portal: SupportPortal }> {
|
||||
return api(`/api/v1/support/portals/${id}/products/`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ items }),
|
||||
});
|
||||
}
|
||||
|
||||
export function listPortalCategories(id: number): Promise<{ items: PortalCategory[] }> {
|
||||
return api(`/api/v1/support/portals/${id}/categories/`);
|
||||
}
|
||||
|
||||
export function createPortalCategory(
|
||||
id: number,
|
||||
input: Pick<PortalCategory, "name"> & Partial<Omit<PortalCategory, "id" | "articleCount" | "name">>,
|
||||
): Promise<{ category: PortalCategory }> {
|
||||
return api(`/api/v1/support/portals/${id}/categories/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePortalCategory(
|
||||
portalId: number,
|
||||
categoryId: number,
|
||||
input: Partial<Omit<PortalCategory, "id" | "articleCount">>,
|
||||
): Promise<{ category: PortalCategory }> {
|
||||
return api(`/api/v1/support/portals/${portalId}/categories/${categoryId}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function deletePortalCategory(
|
||||
portalId: number,
|
||||
categoryId: number,
|
||||
): Promise<void> {
|
||||
return api(`/api/v1/support/portals/${portalId}/categories/${categoryId}/`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function listPortalSupportChannels(
|
||||
portalId: number,
|
||||
): Promise<{ items: Channel[]; widgetItems: Channel[] }> {
|
||||
return api(`/api/v1/support/portals/${portalId}/support-channels/`);
|
||||
}
|
||||
|
||||
export function setPortalCustomDomain(
|
||||
portalId: number,
|
||||
customDomain: string,
|
||||
): Promise<{ portal: SupportPortal }> {
|
||||
return api(`/api/v1/support/portals/${portalId}/domain/`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ customDomain }),
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyPortalCustomDomain(
|
||||
portalId: number,
|
||||
): Promise<{ portal: SupportPortal }> {
|
||||
return api(`/api/v1/support/portals/${portalId}/domain/verify/`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function listPortalArticles(id: number): Promise<{ items: PortalArticle[] }> {
|
||||
return api(`/api/v1/support/portals/${id}/articles/`);
|
||||
}
|
||||
|
||||
export function createPortalArticle(
|
||||
portalId: number,
|
||||
input: {
|
||||
categoryId: number;
|
||||
slug: string;
|
||||
locale: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
},
|
||||
): Promise<{ article: PortalArticle }> {
|
||||
return api(`/api/v1/support/portals/${portalId}/articles/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadPortalArticle(
|
||||
portalId: number,
|
||||
articleId: number,
|
||||
): Promise<{ article: PortalArticle }> {
|
||||
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/`);
|
||||
}
|
||||
|
||||
export function updatePortalArticle(
|
||||
portalId: number,
|
||||
articleId: number,
|
||||
input: { categoryId: number; locale: string; slug: string },
|
||||
): Promise<{ article: PortalArticle }> {
|
||||
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function addArticleRevision(
|
||||
portalId: number,
|
||||
articleId: number,
|
||||
input: Pick<ArticleRevision, "title" | "summary" | "content">,
|
||||
): Promise<{ revision: ArticleRevision }> {
|
||||
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/revisions/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function publishArticleRevision(
|
||||
portalId: number,
|
||||
articleId: number,
|
||||
revisionId: number,
|
||||
): Promise<{ article: PortalArticle }> {
|
||||
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/publish/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ revisionId }),
|
||||
});
|
||||
}
|
||||
|
||||
export function archivePortalArticle(
|
||||
portalId: number,
|
||||
articleId: number,
|
||||
): Promise<{ article: PortalArticle }> {
|
||||
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/archive/`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { api, ApiError } from "../../api/client";
|
||||
import type { Channel } from "../channels/types";
|
||||
import { ApiError } from "../../api/client";
|
||||
|
||||
export type PortalStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
|
||||
export type ArticleStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
|
||||
@@ -20,6 +19,11 @@ export type SupportPortal = {
|
||||
slug: string;
|
||||
hostedDomain: string;
|
||||
customDomain: string | null;
|
||||
customDomainAddress: {
|
||||
name: string;
|
||||
type: "A";
|
||||
value: string;
|
||||
} | null;
|
||||
customDomainVerifiedAt: string | null;
|
||||
customDomainVerification: {
|
||||
name: string;
|
||||
@@ -31,6 +35,8 @@ export type SupportPortal = {
|
||||
defaultLocale: string;
|
||||
status: PortalStatus;
|
||||
publishedAt: string | null;
|
||||
widgetChannelId: number | null;
|
||||
widgetChannelCode: string | null;
|
||||
products: PortalProductLink[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -73,6 +79,7 @@ export type PortalInput = {
|
||||
slug: string;
|
||||
name: string;
|
||||
defaultLocale: string;
|
||||
widgetChannelId?: number | null;
|
||||
};
|
||||
|
||||
export type PortalCreationPolicy = {
|
||||
@@ -94,178 +101,28 @@ export type SupportPortalList = {
|
||||
address: PortalAddressConfig;
|
||||
};
|
||||
|
||||
export function listSupportPortals(): Promise<SupportPortalList> {
|
||||
return api("/api/v1/support/portals/");
|
||||
}
|
||||
|
||||
export function createSupportPortal(input: PortalInput): Promise<{ portal: SupportPortal }> {
|
||||
return api("/api/v1/support/portals/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadSupportPortal(id: number): Promise<{ portal: SupportPortal }> {
|
||||
return api(`/api/v1/support/portals/${id}/`);
|
||||
}
|
||||
|
||||
export function updateSupportPortal(
|
||||
id: number,
|
||||
input: PortalInput,
|
||||
): Promise<{ portal: SupportPortal }> {
|
||||
return api(`/api/v1/support/portals/${id}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function changePortalStatus(
|
||||
id: number,
|
||||
status: PortalStatus,
|
||||
): Promise<{ portal: SupportPortal }> {
|
||||
return api(`/api/v1/support/portals/${id}/status/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
}
|
||||
|
||||
export function replacePortalProducts(
|
||||
id: number,
|
||||
items: Array<{ productId: number; supportChannelId: number | null; sortOrder: number }>,
|
||||
): Promise<{ portal: SupportPortal }> {
|
||||
return api(`/api/v1/support/portals/${id}/products/`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ items }),
|
||||
});
|
||||
}
|
||||
|
||||
export function listPortalCategories(id: number): Promise<{ items: PortalCategory[] }> {
|
||||
return api(`/api/v1/support/portals/${id}/categories/`);
|
||||
}
|
||||
|
||||
export function createPortalCategory(
|
||||
id: number,
|
||||
input: Pick<PortalCategory, "name"> & Partial<Omit<PortalCategory, "id" | "articleCount" | "name">>,
|
||||
): Promise<{ category: PortalCategory }> {
|
||||
return api(`/api/v1/support/portals/${id}/categories/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePortalCategory(
|
||||
portalId: number,
|
||||
categoryId: number,
|
||||
input: Partial<Omit<PortalCategory, "id" | "articleCount">>,
|
||||
): Promise<{ category: PortalCategory }> {
|
||||
return api(`/api/v1/support/portals/${portalId}/categories/${categoryId}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function deletePortalCategory(
|
||||
portalId: number,
|
||||
categoryId: number,
|
||||
): Promise<void> {
|
||||
return api(`/api/v1/support/portals/${portalId}/categories/${categoryId}/`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function listPortalSupportChannels(
|
||||
portalId: number,
|
||||
): Promise<{ items: Channel[] }> {
|
||||
return api(`/api/v1/support/portals/${portalId}/support-channels/`);
|
||||
}
|
||||
|
||||
export function setPortalCustomDomain(
|
||||
portalId: number,
|
||||
customDomain: string,
|
||||
): Promise<{ portal: SupportPortal }> {
|
||||
return api(`/api/v1/support/portals/${portalId}/domain/`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ customDomain }),
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyPortalCustomDomain(
|
||||
portalId: number,
|
||||
): Promise<{ portal: SupportPortal }> {
|
||||
return api(`/api/v1/support/portals/${portalId}/domain/verify/`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function listPortalArticles(id: number): Promise<{ items: PortalArticle[] }> {
|
||||
return api(`/api/v1/support/portals/${id}/articles/`);
|
||||
}
|
||||
|
||||
export function createPortalArticle(
|
||||
portalId: number,
|
||||
input: {
|
||||
categoryId: number;
|
||||
slug: string;
|
||||
locale: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
},
|
||||
): Promise<{ article: PortalArticle }> {
|
||||
return api(`/api/v1/support/portals/${portalId}/articles/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadPortalArticle(
|
||||
portalId: number,
|
||||
articleId: number,
|
||||
): Promise<{ article: PortalArticle }> {
|
||||
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/`);
|
||||
}
|
||||
|
||||
export function updatePortalArticle(
|
||||
portalId: number,
|
||||
articleId: number,
|
||||
input: { categoryId: number; locale: string; slug: string },
|
||||
): Promise<{ article: PortalArticle }> {
|
||||
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function addArticleRevision(
|
||||
portalId: number,
|
||||
articleId: number,
|
||||
input: Pick<ArticleRevision, "title" | "summary" | "content">,
|
||||
): Promise<{ revision: ArticleRevision }> {
|
||||
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/revisions/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function publishArticleRevision(
|
||||
portalId: number,
|
||||
articleId: number,
|
||||
revisionId: number,
|
||||
): Promise<{ article: PortalArticle }> {
|
||||
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/publish/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ revisionId }),
|
||||
});
|
||||
}
|
||||
|
||||
export function archivePortalArticle(
|
||||
portalId: number,
|
||||
articleId: number,
|
||||
): Promise<{ article: PortalArticle }> {
|
||||
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/archive/`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
export {
|
||||
addArticleRevision,
|
||||
archivePortalArticle,
|
||||
changePortalStatus,
|
||||
createPortalArticle,
|
||||
createPortalCategory,
|
||||
createSupportPortal,
|
||||
deletePortalCategory,
|
||||
listPortalArticles,
|
||||
listPortalCategories,
|
||||
listPortalSupportChannels,
|
||||
listSupportPortals,
|
||||
loadPortalArticle,
|
||||
loadSupportPortal,
|
||||
publishArticleRevision,
|
||||
replacePortalProducts,
|
||||
setPortalCustomDomain,
|
||||
updatePortalArticle,
|
||||
updatePortalCategory,
|
||||
updateSupportPortal,
|
||||
verifyPortalCustomDomain,
|
||||
} from "./api";
|
||||
|
||||
export const PORTAL_STATUS_LABEL: Record<PortalStatus, string> = {
|
||||
DRAFT: "Черновик",
|
||||
|
||||
@@ -75,6 +75,20 @@
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.portal-article-col-title { width: 26%; }
|
||||
.portal-article-col-category { width: 18%; }
|
||||
.portal-article-col-language { width: 7%; }
|
||||
.portal-article-col-version { width: 7%; }
|
||||
.portal-article-col-status { width: 18%; }
|
||||
.portal-article-col-updated { width: 17%; }
|
||||
.portal-article-col-actions { width: 48px; }
|
||||
|
||||
.portal-article-table th:last-child,
|
||||
.portal-article-table td:last-child {
|
||||
padding-right: 10px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.portal-product-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -130,10 +144,19 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.portal-domain-record {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.portal-domain-record > b {
|
||||
color: var(--text-heading);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.portal-domain-verification dl {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 12px 0 0;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.portal-domain-verification dl > div {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { RouteKey } from "../types";
|
||||
|
||||
const ITEMS: Array<{
|
||||
activeRoutes: RouteKey[];
|
||||
disabled?: boolean;
|
||||
key: RouteKey;
|
||||
label: string;
|
||||
}> = [
|
||||
{
|
||||
activeRoutes: ["aiAgents", "aiAgentCreate", "aiAgentDetail"],
|
||||
key: "aiAgents",
|
||||
label: "AI-агенты",
|
||||
},
|
||||
{
|
||||
activeRoutes: ["aiKnowledge", "aiKnowledgeCreate", "aiKnowledgeDetail"],
|
||||
key: "aiKnowledge",
|
||||
label: "Знания",
|
||||
},
|
||||
{
|
||||
activeRoutes: ["aiUsage"],
|
||||
disabled: true,
|
||||
key: "aiUsage",
|
||||
label: "Использование AI",
|
||||
},
|
||||
];
|
||||
|
||||
export function AiSidebarNav({
|
||||
route,
|
||||
setRoute,
|
||||
}: {
|
||||
route: RouteKey;
|
||||
setRoute: (route: RouteKey) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="hub-ai-subnav">
|
||||
{ITEMS.map((item) => (
|
||||
<button
|
||||
className={`hub-ai-nav-item${item.activeRoutes.includes(route) ? " is-active" : ""}`}
|
||||
disabled={item.disabled}
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => setRoute(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { RouteKey, SessionUser } from "../types";
|
||||
import { Icon } from "../shared/icons";
|
||||
import { canAccess } from "../auth/access";
|
||||
import { SidebarUserMenu } from "./SidebarUserMenu";
|
||||
|
||||
export function SalesSidebar({ route, user, setRoute, onLogout, waitingCount = 0 }: { route: RouteKey; user: SessionUser; setRoute: (route: RouteKey) => void; onLogout: () => void; waitingCount?: number }) {
|
||||
const nav = [
|
||||
{ key: "salesOverview" as const, label: "Обзор", icon: "grid" as const },
|
||||
{ key: "salesDialogs" as const, label: "Диалоги", icon: "message" as const, badge: waitingCount > 0 ? String(waitingCount) : undefined },
|
||||
{ key: "salesClients" as const, label: "Контакты", icon: "team" as const },
|
||||
{ key: "salesOrders" as const, label: "Продажи", icon: "cart" as const },
|
||||
];
|
||||
return (
|
||||
<aside className="hub-sidebar sales-workspace-sidebar">
|
||||
{canAccess(user, "command") && (
|
||||
<div className="sales-sidebar-back-wrap">
|
||||
<button className="sales-sidebar-back" type="button" onClick={() => setRoute("command")}><Icon name="arrow" size={16} />Назад</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="sales-sidebar-title">
|
||||
<div className="sales-sidebar-icon"><Icon name="shop" size={19} /></div>
|
||||
<div><strong>Продажи</strong><span>Рабочее пространство</span></div>
|
||||
</div>
|
||||
<nav className="hub-nav sales-workspace-nav">
|
||||
{nav.map((item) => {
|
||||
const nextRoute = "key" in item ? item.key : null;
|
||||
if (nextRoute && !canAccess(user, nextRoute)) return null;
|
||||
const active = nextRoute === route || (nextRoute === "salesClients" && route === "salesClientDetail") || (nextRoute === "salesOrders" && route === "salesOrderDetail");
|
||||
return (
|
||||
<button className={`hub-nav-item ${active ? "is-active" : ""}`} type="button" onClick={() => nextRoute && setRoute(nextRoute)} key={item.label}>
|
||||
{active && <span className="active-bar" />}
|
||||
<Icon name={item.icon} />
|
||||
{item.label}
|
||||
{item.badge && <b>{item.badge}</b>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<SidebarUserMenu user={user} route={route} setRoute={setRoute} onLogout={onLogout} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,8 @@ import { NotificationDrawer } from "../features/notifications/NotificationDrawer
|
||||
import { fetchNotifications, markAllRead, markRead, type AppNotification } from "../features/notifications/model";
|
||||
import { fetchWaitingCount } from "../features/conversations/model";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { SalesSidebar } from "./SalesSidebar";
|
||||
import { SupportSidebar } from "./SupportSidebar";
|
||||
import { ShellRouteContent } from "./ShellRouteContent";
|
||||
import { TopBar } from "./TopBar";
|
||||
import { canAccess } from "../auth/access";
|
||||
|
||||
export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId, selectedProductCode, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedOrderId, selectedChannelId, selectedSupportPortalId, openChannelRoute, openSupportPortalRoute, openEmployeeRoute, openProductRoute, openAgentCreateRoute, openAgentRoute, openKnowledgeRoute, openConversationRoute, openClientRoute, openOrderRoute, user, data, reload, onUserUpdated, onLogout }: { route: RouteKey; setRoute: (route: RouteKey) => void; selectedEmployeeId: number | null; selectedProductId: number | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedOrderId: number | null; selectedChannelId: number | null; selectedSupportPortalId: number | null; openEmployeeRoute: (employeeId: number) => void; openProductRoute: (productId: number) => void; openAgentCreateRoute: (productCode: string | null) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openOrderRoute: (orderId: number) => void; openChannelRoute: (channelId: number) => void; openSupportPortalRoute: (portalId: number) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) {
|
||||
const [agentName, setAgentName] = useState<string | null>(null);
|
||||
@@ -95,15 +92,9 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId,
|
||||
const isAiFullWidth = route === "aiAgentCreate";
|
||||
const isKnowledgeLibrary = route === "aiKnowledge";
|
||||
const isKnowledgeEditor = route === "aiKnowledgeCreate" || route === "aiKnowledgeDetail";
|
||||
const showSalesSidebar = isSalesWorkspace || (
|
||||
canAccess(user, "salesDialogs") && !canAccess(user, "command") && !isSupportWorkspace
|
||||
);
|
||||
const showSupportSidebar = isSupportWorkspace || (
|
||||
canAccess(user, "supportDialogs") && !canAccess(user, "command") && !isSalesWorkspace
|
||||
);
|
||||
return (
|
||||
<div className="hub-shell">
|
||||
{showSupportSidebar ? <SupportSidebar route={route} user={user} setRoute={setRoute} onLogout={onLogout} waitingCount={waitingCount} /> : showSalesSidebar ? <SalesSidebar route={route} user={user} setRoute={setRoute} onLogout={onLogout} waitingCount={waitingCount} /> : <Sidebar route={route} user={user} setRoute={setRoute} onLogout={onLogout} />}
|
||||
<Sidebar route={route} user={user} setRoute={setRoute} onLogout={onLogout} waitingCount={waitingCount} />
|
||||
<div className="hub-main">
|
||||
<TopBar route={route} user={user} currentEmployee={currentEmployee} currentProduct={currentProduct} currentAgentName={agentName} currentChannelName={channelName} setRoute={setRoute} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} />
|
||||
<main className={`hub-scroll ${isDialogsWorkspace ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""}`}>
|
||||
|
||||
@@ -1,47 +1,89 @@
|
||||
import type { RouteKey, SessionUser } from "../types";
|
||||
import { Icon, LogoIcon } from "../shared/icons";
|
||||
import { canAccess } from "../auth/access";
|
||||
import { AiSidebarNav } from "./AiSidebarNav";
|
||||
import { canAccess, defaultRoute } from "../auth/access";
|
||||
import { SidebarNavSection, type SidebarNavSectionItem } from "./SidebarNavSection";
|
||||
import { SidebarUserMenu } from "./SidebarUserMenu";
|
||||
|
||||
export function Sidebar({ route, user, setRoute, onLogout }: { route: RouteKey; user: SessionUser; setRoute: (route: RouteKey) => void; onLogout: () => void }) {
|
||||
const nav = [
|
||||
{ key: "command" as const, label: "Обзор", icon: "grid" as const },
|
||||
{ label: "КОМПАНИЯ", group: true },
|
||||
{ key: "departments" as const, label: "Отделы", icon: "building" as const },
|
||||
{ key: "employees" as const, label: "Сотрудники", icon: "team" as const },
|
||||
{ key: "products" as const, label: "Продукты", icon: "box" as const },
|
||||
{ key: "channels" as const, label: "Каналы", icon: "route" as const },
|
||||
{ label: "ПЛАТФОРМА", group: true },
|
||||
{ key: "aiAgents" as const, label: "AI", icon: "robot" as const },
|
||||
{ key: "integrations" as const, label: "Интеграции", icon: "plug" as const },
|
||||
{ divider: true },
|
||||
{ label: "Администрирование", icon: "settings" as const, disabled: true },
|
||||
];
|
||||
type SidebarLinkProps = {
|
||||
activeRoutes?: RouteKey[];
|
||||
disabled?: boolean;
|
||||
icon: Parameters<typeof Icon>[0]["name"];
|
||||
label: string;
|
||||
route: RouteKey;
|
||||
routeKey?: RouteKey;
|
||||
setRoute: (route: RouteKey) => void;
|
||||
};
|
||||
|
||||
function SidebarLink({ activeRoutes, disabled, icon, label, route, routeKey, setRoute }: SidebarLinkProps) {
|
||||
const active = activeRoutes?.includes(route) ?? routeKey === route;
|
||||
return (
|
||||
<button
|
||||
className={`hub-nav-item ${active ? "is-active" : ""}`}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
onClick={() => routeKey && setRoute(routeKey)}
|
||||
>
|
||||
{active && <span className="active-bar" />}
|
||||
<Icon name={icon} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const SALES_ITEMS: SidebarNavSectionItem[] = [
|
||||
{ activeRoutes: ["salesOverview"], key: "salesOverview", label: "Обзор" },
|
||||
{ activeRoutes: ["salesDialogs"], key: "salesDialogs", label: "Диалоги" },
|
||||
{ activeRoutes: ["salesClients", "salesClientDetail"], key: "salesClients", label: "Контакты" },
|
||||
{ activeRoutes: ["salesOrders", "salesOrderDetail"], key: "salesOrders", label: "Продажи" },
|
||||
];
|
||||
|
||||
const SUPPORT_ITEMS: SidebarNavSectionItem[] = [
|
||||
{ activeRoutes: ["supportOverview"], key: "supportOverview", label: "Обзор" },
|
||||
{ activeRoutes: ["supportDialogs"], key: "supportDialogs", label: "Диалоги" },
|
||||
{ activeRoutes: ["supportPortals", "supportPortalDetail"], key: "supportPortals", label: "Порталы" },
|
||||
];
|
||||
|
||||
const AI_ITEMS: SidebarNavSectionItem[] = [
|
||||
{ activeRoutes: ["aiAgents", "aiAgentCreate", "aiAgentDetail"], key: "aiAgents", label: "AI-агенты" },
|
||||
{ activeRoutes: ["aiKnowledge", "aiKnowledgeCreate", "aiKnowledgeDetail"], key: "aiKnowledge", label: "Знания" },
|
||||
{ activeRoutes: ["aiUsage"], disabled: true, key: "aiUsage", label: "Использование AI" },
|
||||
];
|
||||
|
||||
function visibleItems(user: SessionUser, items: SidebarNavSectionItem[]) {
|
||||
return items.filter((item) => canAccess(user, item.key));
|
||||
}
|
||||
|
||||
export function Sidebar({ route, user, setRoute, onLogout, waitingCount = 0 }: { route: RouteKey; user: SessionUser; setRoute: (route: RouteKey) => void; onLogout: () => void; waitingCount?: number }) {
|
||||
const sectionStorageKey = (section: string) => (
|
||||
`custocrm.sidebar.${user.organizationPublicId}.${section}.expanded`
|
||||
);
|
||||
const salesItems = visibleItems(user, SALES_ITEMS).map((item) => (
|
||||
item.key === "salesDialogs" && waitingCount > 0
|
||||
? { ...item, badge: String(waitingCount) }
|
||||
: item
|
||||
));
|
||||
const supportItems = visibleItems(user, SUPPORT_ITEMS);
|
||||
const aiItems = visibleItems(user, AI_ITEMS);
|
||||
|
||||
return (
|
||||
<aside className="hub-sidebar">
|
||||
<button className="hub-brand" type="button" onClick={() => setRoute("command")}>
|
||||
<button className="hub-brand" type="button" onClick={() => setRoute(defaultRoute(user))}>
|
||||
<div className="hub-brand-mark"><LogoIcon /></div>
|
||||
<div><strong>CustoCRM</strong><span>Управление компанией</span></div>
|
||||
</button>
|
||||
<nav className="hub-nav">
|
||||
{nav.map((item, index) => {
|
||||
if ("group" in item) return <div className="hub-nav-group" key={item.label}>{item.label}</div>;
|
||||
if ("divider" in item) return <div className="hub-nav-divider" key={index} />;
|
||||
const nextRoute = "key" in item ? item.key : null;
|
||||
if (nextRoute && !canAccess(user, nextRoute)) return null;
|
||||
const active = nextRoute === route || (nextRoute === "employees" && (route === "employeeDetail" || route === "accessProfiles")) || (nextRoute === "products" && route === "productDetail") || (nextRoute === "aiAgents" && route.startsWith("ai")) || (nextRoute === "channels" && route.startsWith("channel"));
|
||||
return (
|
||||
<div key={item.label}>
|
||||
<button className={`hub-nav-item ${active ? "is-active" : ""}`} disabled={item.disabled} onClick={() => nextRoute && setRoute(nextRoute)}>
|
||||
{active && <span className="active-bar" />}
|
||||
<Icon name={item.icon} />
|
||||
{item.label}
|
||||
</button>
|
||||
{nextRoute === "aiAgents" && active && <AiSidebarNav route={route} setRoute={setRoute} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{canAccess(user, "command") && <SidebarLink icon="grid" label="Обзор" route={route} routeKey="command" setRoute={setRoute} />}
|
||||
<div className="hub-nav-group">КОМПАНИЯ</div>
|
||||
<SidebarNavSection icon="shop" items={salesItems} label="Продажи" route={route} setRoute={setRoute} storageKey={sectionStorageKey("sales")} />
|
||||
<SidebarNavSection icon="wrench" items={supportItems} label="Поддержка" route={route} setRoute={setRoute} storageKey={sectionStorageKey("support")} />
|
||||
{canAccess(user, "employees") && <SidebarLink activeRoutes={["employees", "employeeDetail", "accessProfiles"]} icon="team" label="Сотрудники" route={route} routeKey="employees" setRoute={setRoute} />}
|
||||
{canAccess(user, "products") && <SidebarLink activeRoutes={["products", "productDetail"]} icon="box" label="Продукты" route={route} routeKey="products" setRoute={setRoute} />}
|
||||
{canAccess(user, "channels") && <SidebarLink activeRoutes={["channels", "channelCreate", "channelDetail"]} icon="route" label="Каналы" route={route} routeKey="channels" setRoute={setRoute} />}
|
||||
<div className="hub-nav-group">ПЛАТФОРМА</div>
|
||||
<SidebarNavSection icon="robot" items={aiItems} label="AI" route={route} setRoute={setRoute} storageKey={sectionStorageKey("ai")} />
|
||||
{canAccess(user, "integrations") && <SidebarLink icon="plug" label="Интеграции" route={route} routeKey="integrations" setRoute={setRoute} />}
|
||||
<div className="hub-nav-divider" />
|
||||
<SidebarLink disabled icon="settings" label="Администрирование" route={route} setRoute={setRoute} />
|
||||
</nav>
|
||||
<SidebarUserMenu user={user} route={route} setRoute={setRoute} onLogout={onLogout} />
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { Icon } from "../shared/icons";
|
||||
import type { RouteKey } from "../types";
|
||||
|
||||
export type SidebarNavSectionItem = {
|
||||
activeRoutes: RouteKey[];
|
||||
badge?: string;
|
||||
disabled?: boolean;
|
||||
key: RouteKey;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function SidebarNavSection({
|
||||
icon,
|
||||
items,
|
||||
label,
|
||||
route,
|
||||
setRoute,
|
||||
storageKey,
|
||||
}: {
|
||||
icon: Parameters<typeof Icon>[0]["name"];
|
||||
items: SidebarNavSectionItem[];
|
||||
label: string;
|
||||
route: RouteKey;
|
||||
setRoute: (route: RouteKey) => void;
|
||||
storageKey: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const active = items.some((item) => item.activeRoutes.includes(route));
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
function toggleExpanded() {
|
||||
setExpanded((current) => {
|
||||
const next = !current;
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, String(next));
|
||||
} catch {
|
||||
// Навигация остаётся рабочей, даже если хранилище браузера недоступно.
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hub-nav-section">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
className={`hub-nav-item hub-nav-section-toggle ${active ? "is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={toggleExpanded}
|
||||
>
|
||||
{active && <span className="active-bar" />}
|
||||
<Icon name={icon} />
|
||||
{label}
|
||||
<span className="hub-nav-chevron"><Icon name="chevron" size={14} /></span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="hub-subnav">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
className={`hub-subnav-item ${item.activeRoutes.includes(route) ? "is-active" : ""}`}
|
||||
disabled={item.disabled}
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => setRoute(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
{item.badge && <b>{item.badge}</b>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { RouteKey, SessionUser } from "../types";
|
||||
import { Icon } from "../shared/icons";
|
||||
import { canAccess } from "../auth/access";
|
||||
import { SidebarUserMenu } from "./SidebarUserMenu";
|
||||
|
||||
// SPEC-HUB-0010 §8.1: вся внутренняя support-навигация живёт в workspace отдела.
|
||||
// Зеркало SalesSidebar (переиспользование layout/структуры), переиспользует
|
||||
// существующие CSS-классы sales-workspace-sidebar/nav — дизайн baseline.
|
||||
export function SupportSidebar({ route, user, setRoute, onLogout, waitingCount = 0 }: { route: RouteKey; user: SessionUser; setRoute: (route: RouteKey) => void; onLogout: () => void; waitingCount?: number }) {
|
||||
const nav = [
|
||||
{ key: "supportOverview" as const, label: "Обзор", icon: "grid" as const },
|
||||
{ key: "supportDialogs" as const, label: "Диалоги", icon: "message" as const, badge: waitingCount > 0 ? String(waitingCount) : undefined },
|
||||
{ key: "supportPortals" as const, label: "Порталы", icon: "folder" as const },
|
||||
];
|
||||
return (
|
||||
<aside className="hub-sidebar sales-workspace-sidebar">
|
||||
{canAccess(user, "command") && (
|
||||
<div className="sales-sidebar-back-wrap">
|
||||
<button className="sales-sidebar-back" type="button" onClick={() => setRoute("command")}><Icon name="arrow" size={16} />Назад</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="sales-sidebar-title">
|
||||
<div className="sales-sidebar-icon"><Icon name="wrench" size={19} /></div>
|
||||
<div><strong>Поддержка</strong><span>Рабочее пространство</span></div>
|
||||
</div>
|
||||
<nav className="hub-nav sales-workspace-nav">
|
||||
{nav.map((item) => {
|
||||
if (!canAccess(user, item.key)) return null;
|
||||
const active = item.key === route || (
|
||||
item.key === "supportPortals" && route === "supportPortalDetail"
|
||||
);
|
||||
return (
|
||||
<button className={`hub-nav-item ${active ? "is-active" : ""}`} type="button" onClick={() => setRoute(item.key)} key={item.label}>
|
||||
{active && <span className="active-bar" />}
|
||||
<Icon name={item.icon} />
|
||||
{item.label}
|
||||
{item.badge && <b>{item.badge}</b>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<SidebarUserMenu user={user} route={route} setRoute={setRoute} onLogout={onLogout} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -102,15 +102,17 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hub-ai-subnav {
|
||||
.hub-subnav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 2px 0 2px 30px;
|
||||
}
|
||||
|
||||
.hub-ai-nav-item {
|
||||
.hub-subnav-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
@@ -122,22 +124,47 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.hub-ai-nav-item:not(:disabled):hover {
|
||||
.hub-subnav-item:not(:disabled):hover {
|
||||
color: #262626;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.hub-ai-nav-item.is-active {
|
||||
.hub-subnav-item.is-active {
|
||||
color: #0958d9;
|
||||
background: #e6f4ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hub-ai-nav-item:disabled {
|
||||
.hub-subnav-item:disabled {
|
||||
color: #bfbfbf;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.hub-nav-chevron {
|
||||
display: inline-flex;
|
||||
margin-left: auto;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.hub-nav-section-toggle[aria-expanded="false"] .hub-nav-chevron {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.hub-subnav-item b {
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
padding: 0 6px;
|
||||
color: #ffffff;
|
||||
background: #faad14;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.active-bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -179,98 +206,6 @@
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
.sales-workspace-sidebar {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.sales-sidebar-back-wrap {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.sales-sidebar-back {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 0 12px;
|
||||
color: #595959;
|
||||
background: #ffffff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sales-sidebar-back:hover {
|
||||
color: #1677ff;
|
||||
background: #f5f5f5;
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
.sales-sidebar-back svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.sales-sidebar-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 16px 16px 8px;
|
||||
}
|
||||
|
||||
.sales-sidebar-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
color: #1677ff;
|
||||
background: #e6f4ff;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.sales-sidebar-title strong {
|
||||
display: block;
|
||||
color: #1f1f1f;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.sales-sidebar-title span {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: #8c8c8c;
|
||||
font-size: 11px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.sales-workspace-nav {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.sales-workspace-nav b {
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
padding: 0 6px;
|
||||
color: #ffffff;
|
||||
background: #faad14;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
@@ -135,6 +135,32 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
# Отдельный импорт демо-контента портала не создаёт пользователей и не
|
||||
# изменяет пароли, роли, сессии или другие учётные данные.
|
||||
support-portal-seed:
|
||||
profiles: ["support-portal-seed"]
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/backend/Dockerfile
|
||||
env_file:
|
||||
- .env
|
||||
command: ["python", "/opt/custocrm-local-seed/seed_support_portal.py"]
|
||||
working_dir: /app/apps/backend
|
||||
environment:
|
||||
CUS_DB_ROLE: migration
|
||||
CUSTOCRM_LOCAL_SEED: "1"
|
||||
CUS_HELP_BASE_DOMAIN: localhost
|
||||
CUS_HELP_PUBLIC_SCHEME: http
|
||||
CUS_HELP_PUBLIC_PORT: ""
|
||||
volumes:
|
||||
- ./apps/backend:/app/apps/backend
|
||||
- ./dev/local-seed:/opt/custocrm-local-seed:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
# Dev: gateway — nginx с local.conf вместо Caddy (та же маршрутизация, HMR-friendly).
|
||||
gateway:
|
||||
image: nginx:1.27-alpine
|
||||
|
||||
@@ -32,7 +32,8 @@ server {
|
||||
}
|
||||
|
||||
location /chat/ {
|
||||
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors *" always;
|
||||
# Vite React Refresh injects an inline preamble in local development.
|
||||
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors *" always;
|
||||
proxy_pass http://web-chat:5175;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local-only, credential-safe demo importer for the FoxRay support portal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SEED_DIR = Path(__file__).resolve().parent
|
||||
MANIFEST_PATH = SEED_DIR / "support_portal.json"
|
||||
|
||||
|
||||
def require_local_environment() -> None:
|
||||
if (
|
||||
os.environ.get("CUS_ENV") != "local"
|
||||
or os.environ.get("CUSTOCRM_LOCAL_SEED") != "1"
|
||||
):
|
||||
raise SystemExit(
|
||||
"Support portal seed is available only through compose.dev.yaml "
|
||||
"with CUS_ENV=local."
|
||||
)
|
||||
|
||||
|
||||
def load_manifest() -> dict:
|
||||
with MANIFEST_PATH.open(encoding="utf-8") as stream:
|
||||
manifest = json.load(stream)
|
||||
if manifest.get("schemaVersion") != 1:
|
||||
raise SystemExit("Unsupported support portal seed manifest version.")
|
||||
return manifest
|
||||
|
||||
|
||||
def update_fields(instance, values: dict) -> None:
|
||||
changed = []
|
||||
for field, value in values.items():
|
||||
if getattr(instance, field) != value:
|
||||
setattr(instance, field, value)
|
||||
changed.append(field)
|
||||
if changed:
|
||||
instance.full_clean()
|
||||
if any(field.name == "updated_at" for field in instance._meta.fields):
|
||||
changed.append("updated_at")
|
||||
instance.save(update_fields=changed)
|
||||
|
||||
|
||||
def run() -> None:
|
||||
require_local_environment()
|
||||
sys.path.insert(0, "/app/apps/backend")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings")
|
||||
|
||||
import django
|
||||
|
||||
django.setup()
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from hub_platform.channels.models import Channel
|
||||
from hub_platform.identity.models import Department, Organization
|
||||
from hub_platform.integrations.models import (
|
||||
Integration,
|
||||
IntegrationKind,
|
||||
IntegrationProvider,
|
||||
IntegrationStatus,
|
||||
)
|
||||
from hub_platform.products.models import Product
|
||||
from hub_platform.support_portals.addressing import hosted_domain
|
||||
from hub_platform.support_portals.models import (
|
||||
PortalArticle,
|
||||
PortalArticleRevision,
|
||||
PortalCategory,
|
||||
SupportPortal,
|
||||
SupportPortalProduct,
|
||||
)
|
||||
from hub_platform.support_portals.statuses import ArticleStatus, PortalStatus
|
||||
|
||||
manifest = load_manifest()
|
||||
portal_data = manifest["portal"]
|
||||
widget_data = manifest["widget"]
|
||||
now = timezone.now()
|
||||
|
||||
with transaction.atomic():
|
||||
organization = Organization.objects.get(slug=manifest["organization"])
|
||||
department = Department.objects.get(
|
||||
organization=organization,
|
||||
code="support",
|
||||
)
|
||||
product = Product.objects.get(
|
||||
organization=organization,
|
||||
code=portal_data["product"],
|
||||
)
|
||||
|
||||
channel, _ = Channel.objects.get_or_create(
|
||||
organization=organization,
|
||||
code=widget_data["channel"],
|
||||
defaults={
|
||||
"name": widget_data["channelName"],
|
||||
"department": department,
|
||||
"product": product,
|
||||
},
|
||||
)
|
||||
update_fields(
|
||||
channel,
|
||||
{
|
||||
"name": widget_data["channelName"],
|
||||
"department": department,
|
||||
"product": product,
|
||||
"is_active": True,
|
||||
"requires_authenticated_product_identity": False,
|
||||
"allow_anonymous_sessions": True,
|
||||
"allow_self_reported_contact": True,
|
||||
"allow_sales_attribution": False,
|
||||
"allow_checkout_actions": False,
|
||||
},
|
||||
)
|
||||
|
||||
integration, _ = Integration.objects.get_or_create(
|
||||
organization=organization,
|
||||
provider=IntegrationProvider.WEB,
|
||||
name=widget_data["integrationName"],
|
||||
defaults={"kind": IntegrationKind.MESSENGER},
|
||||
)
|
||||
update_fields(
|
||||
integration,
|
||||
{
|
||||
"kind": IntegrationKind.MESSENGER,
|
||||
"channel": channel,
|
||||
"config": widget_data["config"],
|
||||
"status": IntegrationStatus.OK,
|
||||
"is_active": True,
|
||||
"last_error": "",
|
||||
},
|
||||
)
|
||||
|
||||
portal, _ = SupportPortal.objects.get_or_create(
|
||||
slug=portal_data["slug"],
|
||||
defaults={
|
||||
"organization": organization,
|
||||
"department": department,
|
||||
"hosted_domain": hosted_domain(portal_data["slug"]),
|
||||
"name": portal_data["name"],
|
||||
"default_locale": portal_data["locale"],
|
||||
},
|
||||
)
|
||||
update_fields(
|
||||
portal,
|
||||
{
|
||||
"department": department,
|
||||
"hosted_domain": hosted_domain(portal_data["slug"]),
|
||||
"name": portal_data["name"],
|
||||
"default_locale": portal_data["locale"],
|
||||
"widget_channel": channel,
|
||||
"status": PortalStatus.PUBLISHED,
|
||||
"published_at": portal.published_at or now,
|
||||
},
|
||||
)
|
||||
|
||||
authenticated_channel = Channel.objects.filter(
|
||||
organization=organization,
|
||||
code=portal_data["authenticatedSupportChannel"],
|
||||
product=product,
|
||||
department=department,
|
||||
requires_authenticated_product_identity=True,
|
||||
allow_anonymous_sessions=False,
|
||||
).first()
|
||||
product_link, _ = SupportPortalProduct.objects.get_or_create(
|
||||
organization=organization,
|
||||
portal=portal,
|
||||
product=product,
|
||||
defaults={"support_channel": authenticated_channel},
|
||||
)
|
||||
if product_link.support_channel_id != getattr(
|
||||
authenticated_channel,
|
||||
"id",
|
||||
None,
|
||||
):
|
||||
product_link.support_channel = authenticated_channel
|
||||
product_link.full_clean()
|
||||
product_link.save(update_fields=["support_channel"])
|
||||
|
||||
categories = {}
|
||||
for item in manifest["categories"]:
|
||||
category, _ = PortalCategory.objects.get_or_create(
|
||||
organization=organization,
|
||||
portal=portal,
|
||||
slug=item["slug"],
|
||||
defaults={"name": item["name"]},
|
||||
)
|
||||
update_fields(
|
||||
category,
|
||||
{
|
||||
"name": item["name"],
|
||||
"description": item["description"],
|
||||
"sort_order": item["sortOrder"],
|
||||
},
|
||||
)
|
||||
categories[item["slug"]] = category
|
||||
|
||||
for item in manifest["articles"]:
|
||||
article, _ = PortalArticle.objects.get_or_create(
|
||||
organization=organization,
|
||||
portal=portal,
|
||||
locale=portal_data["locale"],
|
||||
slug=item["slug"],
|
||||
defaults={"category": categories[item["category"]]},
|
||||
)
|
||||
category = categories[item["category"]]
|
||||
latest = article.revisions.order_by("-revision").first()
|
||||
revision_content = (
|
||||
item["title"],
|
||||
item["summary"],
|
||||
item["content"],
|
||||
)
|
||||
if latest is None or (
|
||||
latest.title,
|
||||
latest.summary,
|
||||
latest.content,
|
||||
) != revision_content:
|
||||
latest = PortalArticleRevision.objects.create(
|
||||
organization=organization,
|
||||
article=article,
|
||||
revision=(latest.revision + 1) if latest else 1,
|
||||
title=item["title"],
|
||||
summary=item["summary"],
|
||||
content=item["content"],
|
||||
published_at=now,
|
||||
)
|
||||
elif latest.published_at is None:
|
||||
latest.published_at = now
|
||||
latest.save(update_fields=["published_at"])
|
||||
|
||||
article.category = category
|
||||
article.status = ArticleStatus.PUBLISHED
|
||||
article.published_revision = latest
|
||||
article.full_clean()
|
||||
article.save(
|
||||
update_fields=[
|
||||
"category",
|
||||
"status",
|
||||
"published_revision",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"organization": organization.slug,
|
||||
"portal": portal.slug,
|
||||
"url": f"http://{portal.hosted_domain}/",
|
||||
"widgetChannel": channel.code,
|
||||
"categories": len(categories),
|
||||
"articles": len(manifest["articles"]),
|
||||
"idempotent": True,
|
||||
"credentialsChanged": False,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"organization": "edevs",
|
||||
"portal": {
|
||||
"slug": "foxray",
|
||||
"name": "FoxRay",
|
||||
"locale": "ru",
|
||||
"product": "foxray",
|
||||
"authenticatedSupportChannel": "foxray-support"
|
||||
},
|
||||
"widget": {
|
||||
"channel": "foxray-help",
|
||||
"channelName": "FoxRay — чат поддержки",
|
||||
"integrationName": "FoxRay Help Center Web",
|
||||
"config": {
|
||||
"title": "Поддержка FoxRay",
|
||||
"accent": "#1677ff",
|
||||
"greeting": "Здравствуйте! Поможем разобраться с FoxRay.",
|
||||
"consent_text": "Продолжая, вы соглашаетесь на обработку сообщений для ответа на обращение.",
|
||||
"consent_version": "v1",
|
||||
"quick_replies": [
|
||||
"Как начать работу",
|
||||
"Не могу войти",
|
||||
"Связаться со специалистом"
|
||||
],
|
||||
"allowed_domains": [
|
||||
"foxray.localhost",
|
||||
"localhost"
|
||||
]
|
||||
}
|
||||
},
|
||||
"categories": [
|
||||
{
|
||||
"slug": "getting-started",
|
||||
"name": "Начало работы",
|
||||
"description": "Первые шаги и основные возможности",
|
||||
"sortOrder": 10
|
||||
},
|
||||
{
|
||||
"slug": "account-access",
|
||||
"name": "Аккаунт и доступ",
|
||||
"description": "Вход, восстановление доступа и уведомления",
|
||||
"sortOrder": 20
|
||||
},
|
||||
{
|
||||
"slug": "support",
|
||||
"name": "Обращения в поддержку",
|
||||
"description": "Создание и отслеживание обращений",
|
||||
"sortOrder": 30
|
||||
},
|
||||
{
|
||||
"slug": "billing",
|
||||
"name": "Оплата и документы",
|
||||
"description": "Счета и закрывающие документы",
|
||||
"sortOrder": 40
|
||||
}
|
||||
],
|
||||
"articles": [
|
||||
{
|
||||
"slug": "first-login",
|
||||
"category": "getting-started",
|
||||
"title": "Первый вход в FoxRay",
|
||||
"summary": "Краткий маршрут после получения доступа.",
|
||||
"content": "# Первый вход в FoxRay\n\n1. Откройте адрес рабочего пространства, который прислал администратор.\n2. Введите рабочий email и пароль.\n3. Проверьте название организации в верхней части страницы.\n\n> Если приглашение или адрес не открывается, напишите в чат поддержки на этой странице.\n\n## Что проверить после входа\n\n- доступные разделы;\n- профиль и контактные данные;\n- настройки уведомлений."
|
||||
},
|
||||
{
|
||||
"slug": "restore-access",
|
||||
"category": "account-access",
|
||||
"title": "Как восстановить доступ",
|
||||
"summary": "Что проверить, если не получается войти.",
|
||||
"content": "# Как восстановить доступ\n\nСначала убедитесь, что используете рабочий email и правильный адрес организации.\n\n## Если войти не удалось\n\n- повторите ввод без пробелов в начале и конце;\n- проверьте раскладку и `Caps Lock`;\n- обратитесь к администратору вашей организации;\n- напишите в чат поддержки и укажите рабочий email.\n\nПароль в сообщении отправлять не нужно."
|
||||
},
|
||||
{
|
||||
"slug": "notifications",
|
||||
"category": "account-access",
|
||||
"title": "Настройка уведомлений",
|
||||
"summary": "Как выбрать важные события и не пропустить ответ.",
|
||||
"content": "# Настройка уведомлений\n\nОткройте настройки профиля и проверьте доступные способы уведомлений.\n\n| Событие | Рекомендация |\n| --- | --- |\n| Ответ специалиста | Оставить включённым |\n| Изменение статуса | Оставить включённым |\n| Информационные сообщения | Настроить по необходимости |\n\nИзменения применяются после сохранения формы."
|
||||
},
|
||||
{
|
||||
"slug": "create-support-request",
|
||||
"category": "support",
|
||||
"title": "Как обратиться в поддержку",
|
||||
"summary": "Какие данные помогут быстрее разобраться в вопросе.",
|
||||
"content": "# Как обратиться в поддержку\n\nНажмите синюю кнопку чата в правом нижнем углу портала.\n\nОпишите:\n\n- что вы хотели сделать;\n- что произошло вместо ожидаемого результата;\n- адрес страницы;\n- время возникновения проблемы.\n\nСекреты, пароли и платёжные данные отправлять не нужно."
|
||||
},
|
||||
{
|
||||
"slug": "track-request",
|
||||
"category": "support",
|
||||
"title": "Где посмотреть статус обращения",
|
||||
"summary": "Как продолжить уже начатый разговор.",
|
||||
"content": "# Где посмотреть статус обращения\n\nВернитесь на портал в том же браузере и снова откройте чат. Активная локальная сессия продолжится автоматически.\n\nСтатусы в чате показывают, кто сейчас готовит ответ: виртуальный помощник или специалист поддержки."
|
||||
},
|
||||
{
|
||||
"slug": "payment-documents",
|
||||
"category": "billing",
|
||||
"title": "Оплата и документы",
|
||||
"summary": "Куда обратиться по вопросам счетов и документов.",
|
||||
"content": "# Оплата и документы\n\nПо вопросу оплаты или закрывающих документов напишите в чат поддержки.\n\nУкажите:\n\n1. название организации;\n2. номер счёта, если он известен;\n3. период, за который нужны документы.\n\nНе прикладывайте данные банковской карты."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,6 +11,9 @@ CUSTOCRM_PLATFORM_DOMAIN=platform.example.com
|
||||
CUS_HELP_BASE_DOMAIN=help.example.com
|
||||
CUS_HELP_PUBLIC_SCHEME=https
|
||||
CUS_HELP_PUBLIC_PORT=
|
||||
# Необязательный отдельный IPv4 для A-записей собственных доменов.
|
||||
# По умолчанию используется CUSTOCRM_WEB_LISTENING_IP.
|
||||
CUS_HELP_PUBLIC_IPV4=203.0.113.10
|
||||
CUSTOCRM_ACME_EMAIL=admin@example.com
|
||||
CUSTOCRM_ADMIN_PORT=18001
|
||||
# IP web-gateway. Оставьте 0.0.0.0 без profile calls. Для calls укажите
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Portal-only demo data: no users, passwords, roles, sessions, or credentials.
|
||||
docker compose -f compose.yaml -f compose.dev.yaml --profile support-portal-seed `
|
||||
--env-file .env.example --env-file .env run --rm support-portal-seed
|
||||
@@ -145,24 +145,23 @@ async function login(page: Page, user: object) {
|
||||
await page.getByRole("button", { name: "Войти" }).click();
|
||||
}
|
||||
|
||||
test("OWNER logs in and lands on the command center with the global sidebar", async ({ page }) => {
|
||||
test("OWNER logs in and sees every available sidebar section collapsed", async ({ page }) => {
|
||||
await login(page, OWNER_IDENTITY);
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/organizations/${ORGANIZATION_PUBLIC_ID}/`));
|
||||
await expect(page.getByRole("heading", { name: "Командный центр" })).toBeVisible();
|
||||
// Глобальный sidebar уровня компании.
|
||||
await expect(page.getByText("Уровень компании")).toBeVisible();
|
||||
await expect(page.getByText("Рабочее пространство")).toHaveCount(0);
|
||||
await expect(page.locator(".hub-nav-section-toggle", { hasText: "Продажи" })).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(page.locator(".hub-nav-section-toggle", { hasText: "Поддержка" })).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(page.locator(".hub-nav-section-toggle", { hasText: "AI" })).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(page.getByRole("button", { name: "Отделы", exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("OPERATOR logs in and lands on sales dialogs with the sales sidebar", async ({ page }) => {
|
||||
test("OPERATOR logs in and sees the accessible department in the main sidebar", async ({ page }) => {
|
||||
await login(page, OPERATOR_IDENTITY);
|
||||
|
||||
await expect(page).toHaveURL(/\/departments\/sales\/dialogs/);
|
||||
// Операторская навигация показывает только рабочее пространство продаж.
|
||||
await expect(page.getByText("Рабочее пространство")).toBeVisible();
|
||||
await expect(page.getByText("Уровень компании")).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Назад" })).toHaveCount(0);
|
||||
await expect(page.locator(".hub-nav-section-toggle", { hasText: "Продажи" })).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(page.getByRole("button", { name: "Поддержка", exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("OPERATOR opening an owner-only route sees the 403 permission screen", async ({ page }) => {
|
||||
|
||||
Reference in new issue
Block a user