From 30d024eb68080713ad635ba42b2a781e05f8fc08 Mon Sep 17 00:00:00 2001 From: PrashantUnity Date: Mon, 15 Jun 2026 21:38:39 +0530 Subject: [PATCH 01/10] Missing Ui --- docs/OPS.md | 19 +- src/website_profiling/tools/alert_checker.py | 73 +++++ tests/test_alert_checker.py | 72 +++++ web/app/api/alerts/check/route.ts | 16 +- web/app/api/report/audit-tool/route.ts | 45 ++++ web/app/api/report/custom/compose/route.ts | 45 ++++ web/app/api/report/custom/export/route.ts | 51 ++++ web/src/ReportShell.tsx | 8 + .../chat/blocks/ChatImageAuditBlock.tsx | 131 ++------- .../components/export/CustomReportBuilder.tsx | 210 +++++++++++++++ .../imageSeo/ImageAuditSummaryCards.tsx | 146 ++++++++++ web/src/components/issues/IssueTaskBoard.tsx | 64 ++++- web/src/lib/appNav.ts | 5 + web/src/lib/axeViolations.ts | 74 ++++++ web/src/lib/customReportTools.ts | 40 +++ web/src/lib/fetchAuditTool.ts | 21 ++ web/src/routes.ts | 6 + web/src/server/auditToolAllowlist.ts | 30 +++ web/src/server/auditToolRoute.test.ts | 77 ++++++ web/src/server/customReportRoute.test.ts | 82 ++++++ web/src/server/issuesStatusRoute.test.ts | 33 +++ web/src/server/spawnAuditTool.ts | 118 +++++++++ web/src/server/spawnCustomReport.ts | 151 +++++++++++ web/src/strings.json | 97 ++++++- web/src/views/Accessibility.tsx | 250 ++++++++++++++++++ web/src/views/ExportReport.tsx | 28 ++ web/src/views/GeoReadiness.tsx | 199 ++++++++++++++ web/src/views/ImageSeo.tsx | 216 +++++++++++++++ 28 files changed, 2177 insertions(+), 130 deletions(-) create mode 100644 web/app/api/report/audit-tool/route.ts create mode 100644 web/app/api/report/custom/compose/route.ts create mode 100644 web/app/api/report/custom/export/route.ts create mode 100644 web/src/components/export/CustomReportBuilder.tsx create mode 100644 web/src/components/imageSeo/ImageAuditSummaryCards.tsx create mode 100644 web/src/lib/axeViolations.ts create mode 100644 web/src/lib/customReportTools.ts create mode 100644 web/src/lib/fetchAuditTool.ts create mode 100644 web/src/server/auditToolAllowlist.ts create mode 100644 web/src/server/auditToolRoute.test.ts create mode 100644 web/src/server/customReportRoute.test.ts create mode 100644 web/src/server/spawnAuditTool.ts create mode 100644 web/src/server/spawnCustomReport.ts create mode 100644 web/src/views/Accessibility.tsx create mode 100644 web/src/views/GeoReadiness.tsx create mode 100644 web/src/views/ImageSeo.tsx diff --git a/docs/OPS.md b/docs/OPS.md index c283ed9..765ffd0 100644 --- a/docs/OPS.md +++ b/docs/OPS.md @@ -65,7 +65,24 @@ POST /api/alerts/check?propertyId={id} ### Behavior -Evaluates health-score changes and stale GSC Links imports for the specified property. When `alert_webhook_url` is configured on the property, sends a POST notification to that URL. +Evaluates health-score changes and stale GSC Links imports for the specified property. When `alert_webhook_url` is configured on the property, sends a POST notification to that URL. When `alert_email` is set and SMTP is configured on the server, sends a plain-text email summary. + +Response JSON includes `alerts`, `webhook_sent`, and `email_sent`. + +### SMTP (optional, for alert email) + +Set on the host running the web app (Docker: web service environment): + +| Variable | Required | Default | Purpose | +|----------|----------|---------|---------| +| `SMTP_HOST` | Yes (with `SMTP_FROM`) | — | SMTP server hostname | +| `SMTP_FROM` | Yes (with `SMTP_HOST`) | — | From address | +| `SMTP_PORT` | No | `587` | SMTP port | +| `SMTP_USER` | No | — | Login user (if auth required) | +| `SMTP_PASS` | No | — | Login password | +| `SMTP_USE_TLS` | No | `true` | Use STARTTLS | + +If SMTP is not configured, alert checks still succeed; `email_sent` is `false`. ### Example diff --git a/src/website_profiling/tools/alert_checker.py b/src/website_profiling/tools/alert_checker.py index 50df913..e08dac5 100644 --- a/src/website_profiling/tools/alert_checker.py +++ b/src/website_profiling/tools/alert_checker.py @@ -2,8 +2,81 @@ from __future__ import annotations import json +import logging +import os +import smtplib +from email.message import EmailMessage from typing import Any +logger = logging.getLogger(__name__) + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.environ.get(name) + if raw is None or str(raw).strip() == "": + return default + return str(raw).strip().lower() in ("1", "true", "yes", "on") + + +def smtp_configured() -> bool: + host = (os.environ.get("SMTP_HOST") or "").strip() + from_addr = (os.environ.get("SMTP_FROM") or "").strip() + return bool(host and from_addr) + + +def _format_alert_email_body(payload: dict[str, Any]) -> str: + lines = ["Site Audit property alerts", ""] + prop_id = payload.get("property_id") + if prop_id is not None: + lines.append(f"Property ID: {prop_id}") + lines.append("") + alerts = payload.get("alerts") or [] + if not alerts: + lines.append("No alerts.") + return "\n".join(lines) + for i, alert in enumerate(alerts, start=1): + if not isinstance(alert, dict): + continue + severity = alert.get("severity") or "info" + msg = alert.get("message") or alert.get("type") or "Alert" + lines.append(f"{i}. [{severity}] {msg}") + return "\n".join(lines) + + +def dispatch_email(to: str, payload: dict[str, Any]) -> bool: + """Send alert summary via SMTP. Returns False when unconfigured or on failure.""" + recipient = (to or "").strip() + if not recipient: + return False + if not smtp_configured(): + logger.info("SMTP not configured (SMTP_HOST and SMTP_FROM required); skipping alert email") + return False + + host = os.environ.get("SMTP_HOST", "").strip() + port = int(os.environ.get("SMTP_PORT") or "587") + user = (os.environ.get("SMTP_USER") or "").strip() + password = os.environ.get("SMTP_PASS") or "" + from_addr = os.environ.get("SMTP_FROM", "").strip() + use_tls = _env_bool("SMTP_USE_TLS", True) + + msg = EmailMessage() + msg["Subject"] = "Site Audit alerts" + msg["From"] = from_addr + msg["To"] = recipient + msg.set_content(_format_alert_email_body(payload)) + + try: + with smtplib.SMTP(host, port, timeout=20) as smtp: + if use_tls: + smtp.starttls() + if user: + smtp.login(user, password) + smtp.send_message(msg) + return True + except Exception: + logger.exception("Failed to send alert email to %s", recipient) + return False + def check_health_alerts(property_id: int, threshold_drop: int = 10) -> list[dict[str, Any]]: from ..db.storage import db_session diff --git a/tests/test_alert_checker.py b/tests/test_alert_checker.py index a262eaf..b237691 100644 --- a/tests/test_alert_checker.py +++ b/tests/test_alert_checker.py @@ -10,7 +10,9 @@ check_all_alerts, check_gsc_links_stale_alerts, check_health_alerts, + dispatch_email, dispatch_webhook, + smtp_configured, ) @@ -105,6 +107,76 @@ def test_dispatch_webhook_empty_url() -> None: assert dispatch_webhook(" ", {"alerts": []}) is False +def test_smtp_configured_requires_host_and_from(monkeypatch) -> None: + monkeypatch.delenv("SMTP_HOST", raising=False) + monkeypatch.delenv("SMTP_FROM", raising=False) + assert smtp_configured() is False + monkeypatch.setenv("SMTP_HOST", "smtp.example.com") + monkeypatch.setenv("SMTP_FROM", "alerts@example.com") + assert smtp_configured() is True + + +def test_dispatch_email_empty_recipient() -> None: + assert dispatch_email(" ", {"alerts": []}) is False + + +def test_dispatch_email_skips_when_smtp_not_configured(monkeypatch) -> None: + monkeypatch.delenv("SMTP_HOST", raising=False) + monkeypatch.delenv("SMTP_FROM", raising=False) + assert dispatch_email("ops@example.com", {"alerts": [{"message": "x"}]}) is False + + +@patch("website_profiling.tools.alert_checker.smtplib.SMTP") +def test_dispatch_email_success(mock_smtp_cls, monkeypatch) -> None: + monkeypatch.setenv("SMTP_HOST", "smtp.example.com") + monkeypatch.setenv("SMTP_FROM", "alerts@example.com") + monkeypatch.setenv("SMTP_PORT", "587") + monkeypatch.setenv("SMTP_USE_TLS", "true") + smtp = MagicMock() + mock_smtp_cls.return_value.__enter__.return_value = smtp + payload = {"property_id": 1, "alerts": [{"severity": "high", "message": "Health drop"}]} + assert dispatch_email("ops@example.com", payload) is True + smtp.starttls.assert_called_once() + smtp.send_message.assert_called_once() + + +@patch("website_profiling.tools.alert_checker.smtplib.SMTP") +def test_dispatch_email_with_auth(mock_smtp_cls, monkeypatch) -> None: + monkeypatch.setenv("SMTP_HOST", "smtp.example.com") + monkeypatch.setenv("SMTP_FROM", "alerts@example.com") + monkeypatch.setenv("SMTP_USER", "alerts@example.com") + monkeypatch.setenv("SMTP_PASS", "secret") + monkeypatch.setenv("SMTP_USE_TLS", "false") + smtp = MagicMock() + mock_smtp_cls.return_value.__enter__.return_value = smtp + assert dispatch_email("ops@example.com", {"alerts": [{"message": "x"}]}) is True + smtp.login.assert_called_once_with("alerts@example.com", "secret") + smtp.starttls.assert_not_called() + + +def test_format_alert_email_body_empty_alerts() -> None: + from website_profiling.tools.alert_checker import _format_alert_email_body + + body = _format_alert_email_body({"property_id": 5, "alerts": []}) + assert "No alerts." in body + assert "Property ID: 5" in body + + +def test_format_alert_email_body_skips_non_dict_alerts() -> None: + from website_profiling.tools.alert_checker import _format_alert_email_body + + body = _format_alert_email_body({"alerts": ["bad", {"severity": "low", "message": "ok"}]}) + assert "2. [low] ok" in body + assert "bad" not in body + + +@patch("website_profiling.tools.alert_checker.smtplib.SMTP", side_effect=OSError("smtp down")) +def test_dispatch_email_failure(_mock_smtp, monkeypatch) -> None: + monkeypatch.setenv("SMTP_HOST", "smtp.example.com") + monkeypatch.setenv("SMTP_FROM", "alerts@example.com") + assert dispatch_email("ops@example.com", {"alerts": [{"message": "x"}]}) is False + + @pytest.fixture def property_id(): if not (os.environ.get("DATABASE_URL") or "").strip(): diff --git a/web/app/api/alerts/check/route.ts b/web/app/api/alerts/check/route.ts index 0d3d86e..8a7428e 100644 --- a/web/app/api/alerts/check/route.ts +++ b/web/app/api/alerts/check/route.ts @@ -25,22 +25,28 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise((resolve) => { diff --git a/web/app/api/report/audit-tool/route.ts b/web/app/api/report/audit-tool/route.ts new file mode 100644 index 0000000..769a04f --- /dev/null +++ b/web/app/api/report/audit-tool/route.ts @@ -0,0 +1,45 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; +import { spawnAuditTool } from '@/server/spawnAuditTool'; +import type { ApiRouteHandler } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * POST /api/report/audit-tool — dispatch allowlisted read-only audit tools for report UI. + */ +export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + + let body: { + toolName?: string; + propertyId?: number; + reportId?: number; + args?: Record; + }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const toolName = String(body.toolName || '').trim(); + const propertyId = Number(body.propertyId || 0); + if (!toolName || !propertyId) { + return NextResponse.json({ error: 'toolName and propertyId required' }, { status: 400 }); + } + + const result = await spawnAuditTool({ + toolName, + propertyId, + reportId: body.reportId, + args: body.args, + }); + + if (!result.ok) { + return NextResponse.json({ error: result.error, ...result.data }, { status: result.status }); + } + return NextResponse.json(result.data); +}; diff --git a/web/app/api/report/custom/compose/route.ts b/web/app/api/report/custom/compose/route.ts new file mode 100644 index 0000000..ed27113 --- /dev/null +++ b/web/app/api/report/custom/compose/route.ts @@ -0,0 +1,45 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; +import { composeCustomReport } from '@/server/spawnCustomReport'; +import type { ApiRouteHandler } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + + let body: { + title?: string; + sections?: Array>; + propertyId?: number; + reportId?: number; + }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const title = String(body.title || '').trim(); + const propertyId = Number(body.propertyId || 0); + const sections = body.sections; + if (!title || !propertyId || !Array.isArray(sections) || sections.length === 0) { + return NextResponse.json({ error: 'title, propertyId, and sections required' }, { status: 400 }); + } + if (sections.length > 12) { + return NextResponse.json({ error: 'sections max 12' }, { status: 400 }); + } + + const result = await composeCustomReport({ + title, + sections, + propertyId, + reportId: body.reportId, + }); + if (!result.ok) { + return NextResponse.json({ error: result.error, ...result.data }, { status: result.status }); + } + return NextResponse.json(result.data); +}; diff --git a/web/app/api/report/custom/export/route.ts b/web/app/api/report/custom/export/route.ts new file mode 100644 index 0000000..4100abe --- /dev/null +++ b/web/app/api/report/custom/export/route.ts @@ -0,0 +1,51 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; +import { exportCustomReportArtifact } from '@/server/spawnCustomReport'; +import type { ApiRouteHandler } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + + const params = request.nextUrl.searchParams; + const reportSpecId = String(params.get('specId') || '').trim(); + const format = (params.get('format') || 'html').toLowerCase(); + const propertyId = Number(params.get('propertyId') || '0'); + const reportIdRaw = params.get('reportId'); + const reportId = reportIdRaw && /^\d+$/.test(reportIdRaw) ? Number(reportIdRaw) : null; + + if (!reportSpecId || !propertyId) { + return NextResponse.json({ error: 'specId and propertyId required' }, { status: 400 }); + } + if (format !== 'html' && format !== 'pdf') { + return NextResponse.json({ error: 'format must be html or pdf' }, { status: 400 }); + } + + const result = await exportCustomReportArtifact({ + reportSpecId, + format, + propertyId, + reportId, + }); + if (!result.ok) { + return NextResponse.json({ error: result.error, ...result.data }, { status: result.status }); + } + + const filename = String(result.data.filename || `custom-report.${format}`); + const mimeType = String(result.data.mime_type || (format === 'pdf' ? 'application/pdf' : 'text/html')); + const b64 = String(result.data.data_b64 || ''); + const buf = Buffer.from(b64, 'base64'); + const dispositionParam = params.get('disposition'); + const inline = dispositionParam === 'inline'; + + return new NextResponse(buf, { + status: 200, + headers: { + 'Content-Type': mimeType, + 'Content-Disposition': `${inline ? 'inline' : 'attachment'}; filename="${filename}"`, + }, + }); +}; diff --git a/web/src/ReportShell.tsx b/web/src/ReportShell.tsx index a4f955c..56d61db 100644 --- a/web/src/ReportShell.tsx +++ b/web/src/ReportShell.tsx @@ -13,6 +13,8 @@ import { FileText, ShieldAlert, Bug, + Accessibility, + Image, Gauge, Share2, BarChart2, @@ -60,6 +62,9 @@ const Redirects = dynamic(() => import('./views/Redirects'), { loading: () => vi const Content = dynamic(() => import('./views/Content'), { loading: () => viewLoading() }); const Security = dynamic(() => import('./views/Security'), { loading: () => viewLoading() }); const JavaScriptErrors = dynamic(() => import('./views/JavaScriptErrors'), { loading: () => viewLoading() }); +const AccessibilityView = dynamic(() => import('./views/Accessibility'), { loading: () => viewLoading() }); +const ImageSeo = dynamic(() => import('./views/ImageSeo'), { loading: () => viewLoading() }); +const GeoReadiness = dynamic(() => import('./views/GeoReadiness'), { loading: () => viewLoading() }); const Lighthouse = dynamic(() => import('./views/Lighthouse'), { loading: () => viewLoading() }); const Network = dynamic(() => import('./views/Network'), { ssr: false, @@ -122,6 +127,9 @@ const VIEW_CONFIG: ViewConfigEntry[] = [ { id: 'lighthouse', component: Lighthouse as ComponentType, icon: Gauge }, { id: 'security', component: Security as ComponentType, icon: ShieldAlert }, { id: 'javascript-errors', component: JavaScriptErrors as ComponentType, icon: Bug }, + { id: 'accessibility', component: AccessibilityView as ComponentType, icon: Accessibility }, + { id: 'image-seo', component: ImageSeo as ComponentType, icon: Image }, + { id: 'geo-readiness', component: GeoReadiness as ComponentType, icon: Globe2 }, { id: 'content-analytics', component: ContentAnalytics as ComponentType, icon: BarChart2 }, { id: 'text-content-analysis', component: TextContentAnalysis as ComponentType, icon: TextSearch }, { id: 'tech-stack', component: TechStack as ComponentType, icon: Cpu }, diff --git a/web/src/components/chat/blocks/ChatImageAuditBlock.tsx b/web/src/components/chat/blocks/ChatImageAuditBlock.tsx index c1cdbf9..f115b6a 100644 --- a/web/src/components/chat/blocks/ChatImageAuditBlock.tsx +++ b/web/src/components/chat/blocks/ChatImageAuditBlock.tsx @@ -1,126 +1,25 @@ 'use client'; -import { ImageIcon } from 'lucide-react'; -import { SimpleBarChart } from '@/components/charts/SimpleBarChart'; import type { ChatBlock } from '@/components/chat/deriveChatBlocks'; -import { strings } from '@/lib/strings'; +import ImageAuditSummaryCards from '@/components/imageSeo/ImageAuditSummaryCards'; type Block = Extract; -const ib = strings.components.chat.blocks.imageAudit; - -function StatCard({ - label, - value, - tone, -}: { - label: string; - value: number; - tone: 'ok' | 'warn' | 'neutral'; -}) { - const toneClass = - tone === 'ok' - ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200' - : tone === 'warn' - ? 'border-amber-500/30 bg-amber-500/10 text-amber-100' - : 'border-default bg-brand-800/40 text-foreground'; - - return ( -
-

{label}

-

{value.toLocaleString()}

-
- ); -} export default function ChatImageAuditBlock({ block }: { block: Block }) { - const chartItems = [ - { label: ib.missingAlt, value: block.pagesMissingAlt }, - { label: ib.noLazyLoad, value: block.pagesWithoutLazy }, - { label: ib.missingDimensions, value: block.pagesMissingDimensions }, - { label: ib.lighthouseIssues, value: block.lighthouseImageDiagnostics }, - ].filter((i) => i.value > 0); - return ( -
-
-
- -
-
-

{ib.title}

-

{ib.subtitle}

-
-
-

- {block.imagesTotal.toLocaleString()} -

-

{ib.totalImages}

-
-
- -
- 0 ? 'warn' : 'ok'} - /> - 0 ? 'warn' : 'ok'} - /> - 0 ? 'warn' : 'ok'} - /> - 0 ? 'warn' : 'ok'} - /> -
- -
- {block.ogCoveragePct != null ? ( - - {ib.ogCoverage}:{' '} - - {block.ogCoveragePct % 1 === 0 - ? block.ogCoveragePct - : block.ogCoveragePct.toFixed(1)} - % - - {block.ogMissingCount != null && block.ogMissingCount > 0 - ? ` · ${block.ogMissingCount} missing` - : ''} - - ) : null} - - {ib.sizeProbe}:{' '} - - {block.inventoryAvailable ? ib.probeOn : ib.probeOff} - - {block.inventoryAvailable && block.inventoryProbed != null - ? ` · ${block.inventoryProbed} URLs` - : ''} - -
- - {chartItems.length > 0 ? ( -
-

{ib.issueBreakdown}

- i.label)} - values={chartItems.map((i) => i.value)} - ariaLabel={ib.issueBreakdown} - /> -
- ) : null} -
+ ); } diff --git a/web/src/components/export/CustomReportBuilder.tsx b/web/src/components/export/CustomReportBuilder.tsx new file mode 100644 index 0000000..6dc76b8 --- /dev/null +++ b/web/src/components/export/CustomReportBuilder.tsx @@ -0,0 +1,210 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { Loader2, Plus, Trash2 } from 'lucide-react'; +import Button from '@/components/Button'; +import { apiUrl } from '@/lib/publicBase'; +import { + CUSTOM_REPORT_TOOLS, + CUSTOM_SECTION_TYPES, + sectionsToPayload, + type CustomReportSection, +} from '@/lib/customReportTools'; +import { strings } from '@/lib/strings'; + +function newSection(): CustomReportSection { + return { + id: crypto.randomUUID(), + type: 'executive_summary', + }; +} + +export interface CustomReportBuilderProps { + propertyId: number | null; + reportId: number | null; +} + +export default function CustomReportBuilder({ propertyId, reportId }: CustomReportBuilderProps) { + const ve = strings.views.exportReport; + const [title, setTitle] = useState(''); + const [sections, setSections] = useState([newSection()]); + const [specId, setSpecId] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const compose = useCallback(async () => { + if (!propertyId || !title.trim()) return null; + setBusy(true); + setError(null); + try { + const res = await fetch(apiUrl('/report/custom/compose'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: title.trim(), + sections: sectionsToPayload(sections), + propertyId, + reportId, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(String(data.error || ve.customSaveFailed)); + const id = String(data.report_spec_id || ''); + setSpecId(id || null); + return id; + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + return null; + } finally { + setBusy(false); + } + }, [propertyId, reportId, sections, title, ve.customSaveFailed]); + + const exportUrl = (format: 'html' | 'pdf', inline = false) => { + if (!specId || !propertyId) return '#'; + const p = new URLSearchParams({ + specId, + format, + propertyId: String(propertyId), + }); + if (reportId != null) p.set('reportId', String(reportId)); + if (inline) p.set('disposition', 'inline'); + return apiUrl(`/report/custom/export?${p.toString()}`); + }; + + const handlePreview = async () => { + const id = specId || (await compose()); + if (!id) return; + window.open(exportUrl('html', true), '_blank', 'noopener,noreferrer'); + }; + + const handleDownload = async (format: 'html' | 'pdf') => { + const id = specId || (await compose()); + if (!id) return; + window.location.href = exportUrl(format, false); + }; + + const updateSection = (id: string, patch: Partial) => { + setSections((prev) => prev.map((s) => (s.id === id ? { ...s, ...patch } : s))); + setSpecId(null); + }; + + const addSection = () => { + if (sections.length >= 12) return; + setSections((prev) => [...prev, newSection()]); + setSpecId(null); + }; + + const removeSection = (id: string) => { + setSections((prev) => (prev.length <= 1 ? prev : prev.filter((s) => s.id !== id))); + setSpecId(null); + }; + + if (!propertyId) { + return ( +

{strings.views.issues.taskBoardNoProperty}

+ ); + } + + return ( +
+
+

{ve.customTitle}

+

{ve.customHint}

+
+ + + +
+ {sections.map((section, index) => ( +
+
+ + {ve.customSectionType} {index + 1} + + +
+ + {section.type === 'tool' ? ( + + ) : null} + {section.type === 'notes' ? ( +